Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions lang/dev/lobby.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,20 +296,22 @@
},
"settings": {
"title": "seinttgs",
"fullscreen": "Fecrslluen",
"windowSize": "Wdoniw Size",
"sectionDisplay": "Daspliy",
"sectionSound": "Suond",
"sectionGeneral": "Geranel",
"resolution": "Rsueiloton",
"fullscreenOption": "Fecrslluen",
"maximizedOption": "Maimeixzd",
"display": "Daspliy",
"skipIntro": "Skip Inrto",
"loginAutomatically": "Lgoin Aluaclatitomy",
"uiScale": "Iartfnece slace",
"sfxVolume": "Sfx Vmolue",
"musicVolume": "Msuic Vmolue",
"devMode": "Dev Mode",
"uploadLogs": "Uolpad logs",
"logUrlCopied": "Log URL was cpeiod to clpbriaod.",
"couldNotUploadLog": "Culod not upolad log.",
"labelSm": "Samll",
"labelMd": "Meuidm",
"labelLg": "Lgare",
"labelDisplay": "Daspliy {id}",
"language": "Lguganae",
"storage": "Sraogte",
Expand Down
12 changes: 7 additions & 5 deletions lang/en/lobby.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,20 +296,22 @@
},
"settings": {
"title": "settings",
"fullscreen": "Fullscreen",
"windowSize": "Window Size",
"sectionDisplay": "Display",
"sectionSound": "Sound",
"sectionGeneral": "General",
"resolution": "Resolution",
"fullscreenOption": "Fullscreen",
"maximizedOption": "Maximized",
"display": "Display",
"skipIntro": "Skip Intro",
"loginAutomatically": "Login Automatically",
"uiScale": "Interface scale",
"sfxVolume": "Sfx Volume",
"musicVolume": "Music Volume",
"devMode": "Dev Mode",
"uploadLogs": "Upload logs",
"logUrlCopied": "Log URL was copied to clipboard.",
"couldNotUploadLog": "Could not upload log.",
"labelSm": "Small",
"labelMd": "Medium",
"labelLg": "Large",
"labelDisplay": "Display {id}",
"language": "Language",
"storage": "Storage",
Expand Down
26 changes: 26 additions & 0 deletions src/main/config/window.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors
//
// SPDX-License-Identifier: MIT

// Device independent pixels throughout. Chromium and the OS translate to physical pixels,
// so a size given here is the same physical size on any display and needs no conversion.

// Smallest and largest window the layout is expected to work in.
export const MIN_WINDOW_SIZE = { width: 1280, height: 720 };

// Windowed sizes are offered across this range of shapes; fullscreen uses the display's own.
export const SUPPORTED_ASPECT_RATIOS = [
{ label: "4:3", ratio: 4 / 3 },
{ label: "16:10", ratio: 16 / 10 },
{ label: "16:9", ratio: 16 / 9 },
{ label: "21:9", ratio: 21 / 9 },
];

export const WINDOW_HEIGHT_STEPS = [720, 900, 1080, 1200, 1440];

// Absolute interface scale, in the same units the OS calls its scaling percentage.
export const UI_SCALE_MIN = 0.75;
export const UI_SCALE_MAX = 2.5;
export const UI_SCALE_STEP = 0.05;

export const clampUiScale = (scale: number) => Math.min(UI_SCALE_MAX, Math.max(UI_SCALE_MIN, scale));
7 changes: 6 additions & 1 deletion src/main/json/model/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@

import { Type } from "@sinclair/typebox";

import { UI_SCALE_MAX, UI_SCALE_MIN } from "@main/config/window";

export const settingsSchema = Type.Object({
fullscreen: Type.Boolean({ default: true }),
size: Type.Number({ default: 900 }),
maximized: Type.Boolean({ default: false }),
windowWidth: Type.Number({ default: 1280 }),
windowHeight: Type.Number({ default: 720 }),
displayIndex: Type.Number({ default: 0 }),
uiScale: Type.Union([Type.Number({ minimum: UI_SCALE_MIN, maximum: UI_SCALE_MAX }), Type.Null()], { default: null }),
skipIntro: Type.Boolean({ default: false }),
sfxVolume: Type.Number({ default: 5, minimum: 0, maximum: 100 }),
musicVolume: Type.Number({ default: 5, minimum: 0, maximum: 100 }),
Expand Down
166 changes: 129 additions & 37 deletions src/main/main-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,25 @@
//
// SPDX-License-Identifier: MIT

import { app, BrowserWindow, nativeImage } from "electron";
import { app, BrowserWindow, nativeImage, screen } from "electron";
import path from "path";
import { settingsService } from "./services/settings.service";
import { logger } from "./utils/logger";
import icon from "@main/resources/icon.png";
import { MIN_WINDOW_SIZE, UI_SCALE_MAX, UI_SCALE_MIN, UI_SCALE_STEP } from "@main/config/window";
import { purgeLogFiles } from "@main/services/log.service";
import { typedWebContents, ipcMain } from "@main/typed-ipc";
import { gameAPI } from "@main/game/game";
import contentService from "@main/services/content.service";

const ZOOM_FACTOR_BASELINE_HEIGHT = 1080;

const log = logger("main-window");

export function createWindow() {
const settings = settingsService.getSettings();
log.info("Creating main window with settings: ", settings);

function getWindowSize(windowedHeight: number) {
return {
width: (windowedHeight * 16) / 9,
height: windowedHeight,
};
function scaleRange() {
return { min: UI_SCALE_MIN, max: UI_SCALE_MAX, os: osScale() };
}

const mainWindow = new BrowserWindow({
Expand All @@ -35,9 +31,10 @@ export function createWindow() {
frame: false,
show: false,
autoHideMenuBar: true,
...getWindowSize(settings.size),
minWidth: 640,
minHeight: 360,
width: settings.windowWidth,
height: settings.windowHeight,
minWidth: MIN_WINDOW_SIZE.width,
minHeight: MIN_WINDOW_SIZE.height,
backgroundColor: "#000000",
webPreferences: {
preload: path.join(__dirname, "../build/preload.js"),
Expand All @@ -51,26 +48,67 @@ export function createWindow() {

// Disable zoom shortcuts
webContents.on("before-input-event", (event, input) => {
// Block Ctrl/Cmd + '+', '-', '0' (zoom shortcuts)
if (((input.control || input.meta) && (input.key === "+" || input.key === "-" || input.key === "=")) || (input.key === "0" && (input.control || input.meta))) {
event.preventDefault();
}
// Chromium's own zoom is bypassed so the scale stays a persisted setting.
if (input.type !== "keyDown" || !(input.control || input.meta)) return;
const delta = input.key === "+" || input.key === "=" ? UI_SCALE_STEP : input.key === "-" ? -UI_SCALE_STEP : input.key === "0" ? 0 : null;
if (delta === null) return;

event.preventDefault();
nudgeUiScale(delta);
});

// The setting is an absolute interface scale, matching what the OS calls its
// scaling percentage, so Chromium's own OS-derived scaling has to be divided out.
function osScale() {
return screen.getDisplayMatching(mainWindow.getBounds()).scaleFactor || 1;
}

function applyScale(uiScale: number | null) {
const { min, max, os } = scaleRange();

webContents.setZoomFactor(Math.min(max, Math.max(min, uiScale ?? os)) / os);
}

function updateZoom() {
if (mainWindow.getContentSize()[1] > 0) {
const zoomFactor = mainWindow.getContentSize()[1] / ZOOM_FACTOR_BASELINE_HEIGHT;
webContents.setZoomFactor(zoomFactor);
}
applyScale(settingsService.getSettings().uiScale);
}

// We handle direct window `resize` event, not only `mainWindow:resized` from renderer as
// that offers much lower latency and offers more fluid experience when resizing. We can't
// use only `resize` event as looks like under some platforms not all window shape changes
// trigger this event.
mainWindow.on("resize", () => {
updateZoom();
});
// Stored by the renderer, which writes the whole settings object and would otherwise
// overwrite a value set behind its back.
function nudgeUiScale(delta: number) {
const { min, max } = scaleRange();
const current = settingsService.getSettings().uiScale ?? osScale();
const next = delta === 0 ? null : Math.min(max, Math.max(min, Math.round((current + delta) * 100) / 100));

webContents.send("mainWindow:uiScaleNudged", next);
}

// Resize fires continuously while dragging, hence the trailing timer.
let zoomUpdate: NodeJS.Timeout | undefined;
function scheduleZoomUpdate() {
clearTimeout(zoomUpdate);
zoomUpdate = setTimeout(() => {
updateZoom();
webContents.send("mainWindow:scaleRangeChanged", scaleRange());
reportWindowState();
}, 100);
}

// No size while fullscreen or maximised, since the remembered one is where to return to.
function reportWindowState() {
const maximized = mainWindow.isMaximized();
const [width, height] = mainWindow.getSize();
const settled = !maximized && !mainWindow.isFullScreen();

webContents.send("mainWindow:windowStateChanged", { maximized, size: settled ? { width, height } : null });
}

mainWindow.on("resize", scheduleZoomUpdate);
mainWindow.on("maximize", scheduleZoomUpdate);
mainWindow.on("unmaximize", scheduleZoomUpdate);
mainWindow.on("enter-full-screen", scheduleZoomUpdate);
mainWindow.on("leave-full-screen", scheduleZoomUpdate);
mainWindow.on("closed", () => clearTimeout(zoomUpdate));

process.env.MAIN_WINDOW_ID = mainWindow.id.toString();

Expand All @@ -85,6 +123,8 @@ export function createWindow() {
// Note: `fullscreen: true` conflicts with `show: false`, so we apply fullscreen here.
if (settings.fullscreen) {
mainWindow.setFullScreen(true);
} else if (settings.maximized) {
mainWindow.maximize();
}
updateZoom();
mainWindow.show();
Expand All @@ -111,30 +151,82 @@ export function createWindow() {

app.on("browser-window-focus", () => mainWindow.flashFrame(false));

//TODO add an IPC handler for changing display via the settings

// Register IPC handlers for the main window
ipcMain.handle("mainWindow:setFullscreen", (_event, flag: boolean) => {
mainWindow.setFullScreen(flag);
updateZoom();
});
ipcMain.handle("mainWindow:setSize", (_event, size: number) => {
if (!mainWindow.isFullScreen() && !mainWindow.isMaximized()) {
const { width, height } = getWindowSize(size);
mainWindow.setSize(width, height);
ipcMain.handle("mainWindow:setMaximized", (_event, flag: boolean) => {
if (flag === mainWindow.isMaximized()) return;
if (!flag) {
mainWindow.unmaximize();
return;
}

if (mainWindow.isFullScreen()) mainWindow.setFullScreen(false);
mainWindow.maximize();
});
ipcMain.handle("mainWindow:setSize", (_event, width: number, height: number) => {
// Their own settings are written alongside the size and applied first; leaving those
// modes here as well undid a revert back to fullscreen.
if (mainWindow.isFullScreen() || mainWindow.isMaximized()) return;

const target = { width: Math.max(width, MIN_WINDOW_SIZE.width), height: Math.max(height, MIN_WINDOW_SIZE.height) };
const [currentWidth, currentHeight] = mainWindow.getSize();
// Arrives again for a size the window already has, and re-centring would yank a drag.
if (target.width === currentWidth && target.height === currentHeight) return;

mainWindow.setSize(target.width, target.height);
mainWindow.center();
updateZoom();
});
ipcMain.handle("mainWindow:setDisplay", (_event, index: number) => {
const display = screen.getAllDisplays()[index];
if (!display) return;

// Nothing but the display changes. The size comes from the setting rather than the
// window, so it survives the trip out of fullscreen or maximised, and it is not
// trimmed to the new display: a size that no longer fits is the window's business,
// not a reason to rewrite what the user chose.
const { windowWidth, windowHeight } = settingsService.getSettings();
const width = Math.max(windowWidth, MIN_WINDOW_SIZE.width);
const height = Math.max(windowHeight, MIN_WINDOW_SIZE.height);
const { workArea } = display;

// Fullscreen and maximised are tied to the display they were entered on.
const wasFullScreen = mainWindow.isFullScreen();
const wasMaximized = mainWindow.isMaximized();
if (wasFullScreen) mainWindow.setFullScreen(false);
if (wasMaximized) mainWindow.unmaximize();

mainWindow.setBounds({
width,
height,
x: Math.round(workArea.x + (workArea.width - width) / 2),
y: Math.round(workArea.y + (workArea.height - height) / 2),
});

if (wasFullScreen) mainWindow.setFullScreen(true);
else if (wasMaximized) mainWindow.maximize();

updateZoom();
});
ipcMain.handle("mainWindow:flashFrame", (_event, flag: boolean) => {
mainWindow.flashFrame(flag);
});
ipcMain.handle("mainWindow:setUiScale", (_event, scale: number | null) => applyScale(scale));
ipcMain.handle("mainWindow:getScaleRange", () => scaleRange());
ipcMain.handle("mainWindow:getDisplays", () =>
screen.getAllDisplays().map((display, index) => ({
index,
scaleFactor: display.scaleFactor || 1,
workArea: { width: display.workAreaSize.width, height: display.workAreaSize.height },
// workArea has the taskbar removed, so it is the wrong shape for an aspect ratio.
size: { width: display.size.width, height: display.size.height },
}))
);
ipcMain.handle("mainWindow:minimize", () => mainWindow.minimize());
ipcMain.handle("mainWindow:isFullscreen", () => mainWindow.isFullScreen());

ipcMain.handle("mainWindow:resized", () => {
updateZoom();
});

// Get download progress updates to update the dock/taskbar
contentService.registerProgressHandler(mainWindow);

Expand Down
14 changes: 10 additions & 4 deletions src/main/typed-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import type { AuthState } from "@main/services/auth.service";
import type { StoredIdentity } from "@main/model/user";
import type { BattleWithMetadata } from "@main/game/battle/battle-types";
import type { BattleStartRequestData, BattleEndedEventData } from "tachyon-protocol/types";
import type { BattleStartRequestData } from "tachyon-protocol/types";
import type { ContentRef } from "@main/content/content-ref";
import type { ContentPresence, ContentState } from "@main/content/content-state";
import type { DownloadInfo } from "@main/content/downloads";
Expand Down Expand Up @@ -40,6 +40,9 @@ export type IPCEvents = {
"game:launched": () => void;
"maps:mapAdded": (filename: string) => void;
"maps:mapDeleted": (filename: string) => void;
"mainWindow:scaleRangeChanged": (range: { min: number; max: number; os: number }) => void;
"mainWindow:windowStateChanged": (state: { maximized: boolean; size: { width: number; height: number } | null }) => void;
"mainWindow:uiScaleNudged": (scale: number | null) => void;
"navigation:navigateTo": (target: string) => void;
"notifications:showAlert": (alertConfig: { text: string; severity?: "info" | "warning" | "error"; timeoutMs?: number }) => void;
"paths:copyProgress": (progress: { copied: number; total: number }) => void;
Expand All @@ -48,7 +51,6 @@ export type IPCEvents = {
"replays:replayDeleted": (filename: string) => void;
"replays:highlightOpened": (fileNames: string[]) => void;
"tachyon:battleStart": (data: BattleStartRequestData) => void;
"tachyon:battleEnded": (data: BattleEndedEventData) => void;
"tachyon:connected": () => void;
"tachyon:disconnected": () => void;
"tachyon:event": (event: TachyonEvent) => void;
Expand Down Expand Up @@ -90,8 +92,12 @@ export type IPCCommands = {
"log:upload": () => string;
"mainWindow:flashFrame": (flag: boolean) => void;
"mainWindow:setFullscreen": (flag: boolean) => void;
"mainWindow:resized": () => void;
"mainWindow:setSize": (size: number) => void;
"mainWindow:setMaximized": (flag: boolean) => void;
"mainWindow:setSize": (width: number, height: number) => void;
"mainWindow:setUiScale": (scale: number | null) => void;
"mainWindow:getScaleRange": () => { min: number; max: number; os: number };
"mainWindow:getDisplays": () => Array<{ index: number; scaleFactor: number; workArea: { width: number; height: number }; size: { width: number; height: number } }>;
"mainWindow:setDisplay": (index: number) => void;
"mainWindow:minimize": () => void;
"mainWindow:isFullscreen": () => boolean;
"maps:downloadMap": (springName: string) => void;
Expand Down
Loading
Loading