diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d52b26de..a825682e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: # `gjsify-lock.json` resolves, so bootstrap and locked toolchain are the # same CLI. - name: Install gjsify CLI - run: npm install -g @gjsify/cli@0.51.1 + run: npm install -g @gjsify/cli@0.63.0 - name: Install dependencies run: gjsify install --immutable @@ -151,7 +151,7 @@ jobs: restore-keys: gjsify-tarballs- - name: Install gjsify CLI - run: npm install -g @gjsify/cli@0.51.1 + run: npm install -g @gjsify/cli@0.63.0 - name: Install dependencies run: gjsify install --immutable diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 8da0d8dd..47cb600b 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -84,7 +84,7 @@ jobs: restore-keys: gjsify-tarballs- - name: Install gjsify CLI - run: npm install -g @gjsify/cli@0.51.1 + run: npm install -g @gjsify/cli@0.63.0 - name: Install dependencies run: gjsify install --immutable diff --git a/.github/workflows/issue-to-pr.yml b/.github/workflows/issue-to-pr.yml index 31b09125..8884f097 100644 --- a/.github/workflows/issue-to-pr.yml +++ b/.github/workflows/issue-to-pr.yml @@ -116,7 +116,7 @@ jobs: # node_modules exists. Pinned exactly to what `gjsify-lock.json` resolves; # the build steps below use the workspace-local CLI regardless. - name: Install gjsify CLI - run: npm install -g @gjsify/cli@0.51.1 + run: npm install -g @gjsify/cli@0.63.0 - name: Install dependencies run: gjsify install --immutable diff --git a/.github/workflows/package-managers.yml b/.github/workflows/package-managers.yml index 2001cb12..e944b4ee 100644 --- a/.github/workflows/package-managers.yml +++ b/.github/workflows/package-managers.yml @@ -37,7 +37,7 @@ jobs: setup: "npm install -g pnpm" install: "pnpm install" - pm: gjsify - setup: "npm install -g @gjsify/cli@0.51.1" + setup: "npm install -g @gjsify/cli@0.63.0" install: "gjsify install" steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 62c254ab..5c2847d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ env: # Bootstrap pin. Must equal the version `gjsify-lock.json` resolves — the # assertion step below fails the run when the two drift, the same guard # ci.yml uses. - GJSIFY_CLI_VERSION: "0.51.1" + GJSIFY_CLI_VERSION: "0.63.0" jobs: artifacts: diff --git a/packages/app-android/app/services/main-window.services.ts b/packages/app-android/app/services/main-window.services.ts new file mode 100644 index 00000000..21ae1533 --- /dev/null +++ b/packages/app-android/app/services/main-window.services.ts @@ -0,0 +1,55 @@ +import { Application, ApplicationSettings, Utils } from "@nativescript/core"; +import { localize as _ } from "@nativescript/localize"; +import type { MainWindowServices } from "@learn6502/ui"; +import { FileService } from "./file.service"; +import { notificationService } from "./notification.service"; + +/** Learn6502 project repository — the "share your example" destination. */ +const SHARE_URL = "https://github.com/JumpLink/Learn6502"; + +const fileService = new FileService(); + +function copyToClipboard(text: string): boolean { + if (!Application.android) return false; + const clipboardManager = Application.android.context.getSystemService( + android.content.Context.CLIPBOARD_SERVICE + ) as android.content.ClipboardManager; + clipboardManager.setPrimaryClip(android.content.ClipData.newPlainText("6502 Code", text)); + return true; +} + +/** Hand the editor's code to the Android share sheet; GNOME opens its own share dialog instead. */ +function shareCode(code: string): void { + const activity = Application.android?.foregroundActivity; + if (!activity || !code) return; + const intent = new android.content.Intent(android.content.Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(android.content.Intent.EXTRA_TEXT, code); + activity.startActivity(android.content.Intent.createChooser(intent, _("Share"))); +} + +/** What the shared main window asks of Android: SAF for files, the share sheet, app settings. */ +export function createMainWindowServices(showHelp: () => void): MainWindowServices { + return { + files: { + openFile: () => fileService.openFile(), + saveFile: (content) => fileService.saveFile(content), + saveFileAs: (content, suggestedName) => fileService.saveFileAs(content, suggestedName), + getCurrentFileName: () => fileService.getCurrentFileName(), + hasCurrentFile: () => fileService.getCurrentFileUri() !== null, + hasUnsavedChanges: () => fileService.hasUnsavedChanges(), + setUnsavedChanges: (hasChanges) => fileService.setUnsavedChanges(hasChanges), + }, + settings: { + getInt: (key) => ApplicationSettings.getNumber(key, 0), + setInt: (key, value) => ApplicationSettings.setNumber(key, value), + getBoolean: (key) => ApplicationSettings.getBoolean(key, false), + setBoolean: (key, value) => ApplicationSettings.setBoolean(key, value), + }, + clipboard: { copy: copyToClipboard }, + notifier: { show: (options) => notificationService.showNotification(options) }, + showHelp, + showShareDialog: (code) => shareCode(code), + submitExample: () => Utils.openUrl(SHARE_URL), + }; +} diff --git a/packages/app-android/app/views/main.ts b/packages/app-android/app/views/main.ts index 5eff1ecb..8255161f 100644 --- a/packages/app-android/app/views/main.ts +++ b/packages/app-android/app/views/main.ts @@ -5,38 +5,17 @@ import { localize as _ } from "@nativescript/localize"; import { systemStates, SystemStates } from "~/states"; // Adwaita native widgets -import { - Adw, - Gio, - Gtk, - NOTIFY_VISIBLE_CHILD, - insertActionGroup, - padForSystemInsets, - setAdwaitaColorScheme, -} from "@gjsify/adwaita-nativescript"; +import { Adw, setAdwaitaColorScheme } from "@gjsify/adwaita-nativescript"; +import type { Gtk } from "@gjsify/adwaita-nativescript"; import { buildWithSiblings } from "@gjsify/adwaita-nativescript/builder"; -// The tab icons are the GNOME app's own (school / code / bug / nintendo-controller). -import { schoolSymbolic, codeSymbolic, bugSymbolic, nintendoControllerSymbolic } from "~/icons"; // The GNOME app's own main window Blueprint, built by the shared-tree builder: its `Adw.Breakpoint`s // move the four screens between the view stack and the three columns. import mainWindowTree from "../../../app-gnome/src/views/main.window.blp?shared-tree"; -// Common interfaces and controllers -import type { MainView } from "@learn6502/common-ui"; -import { - ViewType, - gameConsoleController, - debuggerController, - mainStateController, - editorController, - GameConsoleEventBridge, - MainEventBridge, - MainButtonState, -} from "@learn6502/common-ui"; -import type { SimulatorState } from "@learn6502/core"; +import { ViewType, gameConsoleController } from "@learn6502/common-ui"; // Services / utils -import { notificationService } from "~/services"; +import { createMainWindowServices } from "~/services/main-window.services"; import type { SystemAppearanceChangeEvent } from "~/types"; import { showError, logger } from "~/utils"; import { setAppBackHandler } from "~/utils/navigation"; @@ -45,137 +24,34 @@ import { translate } from "~/utils/translate"; // The template classes main.window.blp names; importing a module registers its class (MainButton and // Toolbar come from @learn6502/ui). import "~/widgets/menu-button"; -import { MainButton } from "@learn6502/ui"; +import { MainWindowController, type MainButton } from "@learn6502/ui"; import type { MenuButton } from "~/widgets/menu-button"; -import { createScreens, type Screens } from "./main/screens"; -import { learnView } from "./main/learn"; -import { debuggerView } from "./main/debugger"; -import { gameConsoleView } from "./main/game-console"; - -/** Notification key -> user-facing title. */ -const NOTIFICATION_TITLES: Record = { - "assembled-successfully": "Assembled successfully", - "assemble-failed": "Assemble failed", - "simulator-failure": "Simulator failure", - "labels-failure": "Labels failure", - "code-copied-to-editor": "Code copied to editor", - "program-completed": "Program completed", -}; - -/** The four screens, in the order the stack lists them, with their tab title and icon. */ -const STACK_PAGES: { id: keyof Screens; view: ViewType; title: () => string; icon: string }[] = [ - { id: "learn", view: ViewType.LEARN, title: () => _("Learn"), icon: schoolSymbolic }, - { id: "editor", view: ViewType.EDITOR, title: () => _("Code"), icon: codeSymbolic }, - { id: "debugger", view: ViewType.DEBUGGER, title: () => _("Debug"), icon: bugSymbolic }, - { id: "gameConsole", view: ViewType.GAME_CONSOLE, title: () => _("Play"), icon: nintendoControllerSymbolic }, -]; - -/** Names of the `win` actions, as `main-button.blp` and `toolbar.blp` spell them. */ -const WIN_ACTIONS = [ - "assemble", - "run-simulator", - "resume-simulator", - "pause-simulator", - "reset-simulator", - "step-simulator", - "share", -] as const; -type WinAction = (typeof WIN_ACTIONS)[number]; +import { createScreens } from "./main/screens"; +import { simulatorStack } from "./main/game-console"; +import { NativeMainWindowShell } from "./main/shell"; /** - * MainController — the window shell, built from the GNOME app's `main.window.blp`: header bar, a - * view stack with a bottom switcher bar on narrow windows, three columns beside a run toolbar on - * wide ones, the main button over it all. Its `Adw.Breakpoint`s decide which; this class moves the - * four screens between the stack and the columns when they do, as the GNOME window does. Implements - * MainView; all 6502 logic stays in the common-ui controllers + event bridges. + * The Android shell around the shared `MainWindowController`: the window built from the GNOME app's + * `main.window.blp`, the Android services (SAF files, share sheet, settings), the gamepad keys and + * the hardware back button. Navigation, the run/step/assemble flow and the files are the shared + * controller's. */ -export class MainController implements MainView { - private page: Page | null = null; - - private _window: View | null = null; - private _stack: Adw.ViewStack | null = null; - private _layoutHost: Gtk.Stack | null = null; - private _switcherBar: Adw.ViewSwitcherBar | null = null; - private _leftColumn: Gtk.Box | null = null; - private _centerColumn: Gtk.Box | null = null; - private _rightTopBox: Gtk.Box | null = null; - private _rightBottomBox: Gtk.Box | null = null; - private _rightColumnContent: Gtk.Box | null = null; - private _releaseColumnInsets: (() => void) | null = null; - private _learnBackButton: Gtk.Button | null = null; - private _mainButton: MainButton | null = null; - private _about: Adw.AboutDialog | null = null; - private _screens: Screens | null = null; - private readonly _actions = new Map(); - private _currentName: string | null = null; - - private _activeView: ViewType = ViewType.EDITOR; +export class MainController { private log = logger.scoped("MainController"); - private gameConsoleBridge: GameConsoleEventBridge; - private mainBridge: MainEventBridge; - - get state(): SimulatorState { - return gameConsoleView.simulator.state; - } - - get activeView(): ViewType { - return this._activeView; - } - - /** Whether the breakpoints have put the screens in columns (the GNOME window's `three`). */ - private get threeColumns(): boolean { - return this._layoutHost?.visibleChildName === "three"; - } + private shell: NativeMainWindowShell | null = null; + private controller: MainWindowController | null = null; + private mainButton: MainButton | null = null; + private about: Adw.AboutDialog | null = null; constructor() { this.log.debug("Initialized"); this.onSystemAppearanceChanged = this.onSystemAppearanceChanged.bind(this); - this.onLearnSubpageChanged = this.onLearnSubpageChanged.bind(this); - - this.gameConsoleBridge = new GameConsoleEventBridge({ - formatAndLog: (message, params) => { - try { - debuggerController.log(_(message, ...(params ?? []).map(String))); - } catch (error) { - this.log.error(`Failed to localize message "${message}":`, error); - debuggerController.log(message); - } - }, - updateDebugger: () => { - if (gameConsoleView.memory && gameConsoleView.simulator) { - debuggerView.update(gameConsoleView.memory, gameConsoleView.simulator); - } - }, - updateAssemblerViews: (assembler) => { - debuggerView.updateHexdump(assembler); - debuggerView.updateDisassembled(assembler); - }, - updateUiState: () => { - this.updateMainUiState(); - }, - showNotification: (key) => { - notificationService.showNotification({ title: _(NOTIFICATION_TITLES[key] || key), timeout: 2 }); - }, - }); - - this.mainBridge = new MainEventBridge({ - mainView: this, - onStateChanged: () => { - this.updateMainUiState(); - }, - onLearnCodeCopied: (code) => { - this.log.debug("Learn: Code copied to editor", code); - }, - showNotification: (key) => { - notificationService.showNotification({ title: _(NOTIFICATION_TITLES[key] || key), timeout: 2 }); - }, - }); } // --- Lifecycle --- public onLoaded(args: EventData): void { - this.page = args.object as Page; + const page = args.object as Page; // Follow the OS color scheme for the symbolic-icon bitmaps (CSS .ns-dark is // handled by NS; this keeps the pre-coloured icons in sync). Seed at mount. @@ -186,48 +62,25 @@ export class MainController implements MainView { } systemStates.events.on(SystemStates.systemAppearanceChangedEvent, this.onSystemAppearanceChanged); - learnView.events.on("subpage-changed", this.onLearnSubpageChanged); this.setupAndroidKeyHandling(); - this.initializeGameConsoleController(); - - this.gameConsoleBridge.connect(); - this.mainBridge.connect(); - - mainStateController.init(); // Build the shell from main.window.blp and install it as the page content. - this.page.content = this.buildShell(); + page.content = this.buildShell(); // Hardware back: let the visible screens consume it (e.g. the Learn screen pops its internal // Adw.NavigationView). Registered with the global back handler so it runs before the default // Frame / move-to-background logic. - setAppBackHandler(() => { - const screens = this._screens; - if (!screens) return false; - if (this.threeColumns) return (screens.editor.onBack?.() ?? false) || (screens.learn.onBack?.() ?? false); - const current = STACK_PAGES.find((page) => page.id === this._currentName); - return current ? (screens[current.id].onBack?.() ?? false) : false; - }); - - // Start on the editor, the default screen. - this.navigateToView(ViewType.EDITOR); + setAppBackHandler(() => this.shell?.handleBack() ?? false); } public onUnloaded(args: EventData): void { const view = args.object as Page; this.log.debug("unloaded:", view.id); setAppBackHandler(null); - this.gameConsoleBridge.disconnect(); - this.mainBridge.disconnect(); - this._mainButton?.unbind(); + this.controller?.disconnect(); + this.mainButton?.unbind(); systemStates.events.off(SystemStates.systemAppearanceChangedEvent, this.onSystemAppearanceChanged); - learnView.events.off("subpage-changed", this.onLearnSubpageChanged); - } - - /** Learn's navigation stack changed — re-evaluate the header back button. */ - private onLearnSubpageChanged(): void { - this.updateLearnBackButtonVisibility(); } private onSystemAppearanceChanged(event: SystemAppearanceChangeEvent): void { @@ -242,60 +95,59 @@ export class MainController implements MainView { // --- Shell construction --- private buildShell(): View { - this._screens = createScreens(); + const screens = createScreens(); const { root } = buildWithSiblings(mainWindowTree, { // `clicked => $_onLearnBackButtonClicked()` in the blueprint. - scope: { _onLearnBackButtonClicked: () => learnView.navigateBack() }, + scope: { _onLearnBackButtonClicked: () => screens.learn.widget.navigateBack() }, translate, }); - this._window = root; + // The widgets are typed against the GJS bindings, but at run time they are NativeScript views. const byId = (id: string): T => root.getViewById(id) as unknown as T; - this._stack = byId("stack"); - this._layoutHost = byId("layoutHost"); - this._switcherBar = byId("switcherBar"); - this._leftColumn = byId("leftColumn"); - this._centerColumn = byId("centerColumn"); - this._rightTopBox = byId("rightTopBox"); - this._rightBottomBox = byId("rightBottomBox"); - this._rightColumnContent = byId("rightColumnContent"); - this._learnBackButton = byId("learnBackButton"); - this._mainButton = byId("mainButton"); + const shell = new NativeMainWindowShell( + { + root, + stack: byId("stack"), + switcherBar: byId("switcherBar"), + layoutHost: byId("layoutHost"), + leftSidebar: byId("leftSidebar"), + leftColumn: byId("leftColumn"), + centerColumn: byId("centerColumn"), + rightTopBox: byId("rightTopBox"), + rightBottomBox: byId("rightBottomBox"), + rightColumnContent: byId("rightColumnContent"), + unsavedChangesDialog: byId("unsavedChangesDialog"), + titleLabel: byId("titleLabel"), + learnBackButton: byId("learnBackButton"), + }, + screens + ); + this.shell = shell; + this.mainButton = byId("mainButton"); + + const controller = new MainWindowController( + shell, + { + editor: screens.editor.widget, + gameConsole: screens.gameConsole.widget, + learn: screens.learn.widget, + debugger: screens.debugger.widget, + mainButton: this.mainButton, + simulatorStack, + }, + createMainWindowServices(() => controller.navigateToView(ViewType.LEARN)) + ); + this.controller = controller; + controller.init(); + shell.insertActions(); byId("menuButton").onActivated = (id) => this.onMenuItem(id); - - this.setupActions(root); this.setupAbout(root); - this._stack.addEventListener(NOTIFY_VISIBLE_CHILD, () => this.onStackChanged(this._stack!.visibleChildName)); - this._layoutHost.connect("notify::visible-child-name", () => this.mountLayout()); - this.mountLayout(); - return root; } - /** The `win` action group the main button and the run toolbar fire (their `action-name`s). */ - private setupActions(root: View): void { - const group = new Gio.SimpleActionGroup(); - const handlers: Record void> = { - assemble: () => mainStateController.emitAssemble(), - "run-simulator": () => mainStateController.emitRun(), - "resume-simulator": () => mainStateController.emitResume(), - "pause-simulator": () => mainStateController.emitPause(), - "reset-simulator": () => mainStateController.emitReset(), - "step-simulator": () => mainStateController.emitStep(), - share: () => this.shareCode(), - }; - for (const name of WIN_ACTIONS) { - const action = new Gio.SimpleAction({ name }); - action.connect("activate", handlers[name]); - group.add_action(action); - this._actions.set(name, action); - } - insertActionGroup(root, "win", group); - } - /** * About dialog — an in-page modal card painted over everything (the window's last child), * revealed from the app menu. Mirrors the GNOME app's Adw.AboutDialog.new_from_appdata(metainfo, @@ -308,192 +160,27 @@ export class MainController implements MainView { about.developerName = "Pascal Garber"; about.comments = _("Program vintage game consoles"); about.website = "https://flathub.org/apps/eu.jumplink.Learn6502"; - this._about = about; + this.about = about; GridLayout.setRow(about, 0); GridLayout.setColumn(about, 0); (root as GridLayout).addChild(about); } - // --- Layout: the stack on narrow windows, the columns on wide ones --- - - /** Put the four screens where the current layout wants them, as the GNOME window does. */ - private mountLayout(): void { - if (this.threeColumns) this.mountThreeColumnLayout(); - else this.mountSingleLayout(); - this.updateLearnBackButtonVisibility(); - this.updateMainUiState(); - } - - /** Detach a screen from whichever container holds it, ready for the other layout. */ - private detach(view: View): void { - const parent = view.parent as unknown as { remove?: (child: View) => unknown; removeChild?: (v: View) => void }; - if (!parent) return; - if (parent === (this._stack as unknown)) this._stack!.remove(view); - else if (typeof parent.remove === "function") parent.remove(view); - else parent.removeChild?.(view); - } - - private mountSingleLayout(): void { - const stack = this._stack!; - const screens = this._screens!; - const keep = this._currentName ?? STACK_PAGES[1]!.id; - for (const page of STACK_PAGES) this.detach(screens[page.id].view); - for (const page of STACK_PAGES) stack.add(screens[page.id].view, page.id, page.title(), page.icon); - this._switcherBar!.set_stack(stack); - this.padColumnsForSystemInsets(false); - this._currentName = null; - stack.visibleChildName = keep; - this.onStackChanged(stack.visibleChildName); - } - - private mountThreeColumnLayout(): void { - const screens = this._screens!; - for (const page of STACK_PAGES) this.detach(screens[page.id].view); - // A box hands spare space only to a child that asks for it, and the screens are plain views. - for (const page of STACK_PAGES) Object.assign(screens[page.id].view, { hexpand: true, vexpand: true }); - this._leftColumn!.append(screens.learn.view); - this._centerColumn!.append(screens.editor.view); - this._rightTopBox!.append(screens.gameConsole.view); - this._rightBottomBox!.append(screens.debugger.view); - // No bottom bar in columns; the screens are all on show. - this._switcherBar!.set_stack(null); - this.padColumnsForSystemInsets(true); - this._currentName = null; - for (const page of STACK_PAGES) screens[page.id].onShow?.(); - this._activeView = ViewType.EDITOR; - mainStateController.setViewType(ViewType.EDITOR); - } - - /** - * The columns run down to the screen's bottom edge, behind the gesture area, with their - * backgrounds. What scrolls in them (and the editor's Help bar) clears it instead of the - * columns being shortened. In the stack the switcher bar below pays it, so nothing does here. - */ - private padColumnsForSystemInsets(on: boolean): void { - this._releaseColumnInsets?.(); - this._releaseColumnInsets = on ? padForSystemInsets(this._rightColumnContent!) : null; - for (const page of STACK_PAGES) this._screens![page.id].padSystemInsets?.(on); - } - - // --- Navigation --- - public navigateToView(viewType: ViewType): void { - // In columns every screen is on show already. - if (this.threeColumns || !this._stack) return; - const page = STACK_PAGES.find((candidate) => candidate.view === viewType); - if (!page) return; - this._stack.visibleChildName = page.id; // fires notify::visible-child if changed - this.onStackChanged(page.id); // covers the already-on-that-view case - } - - private onStackChanged(name: string): void { - const page = STACK_PAGES.find((candidate) => candidate.id === name); - if (!page || !this._screens || this.threeColumns) return; - if (name !== this._currentName) { - if (this._currentName) this._screens[this._currentName as keyof Screens]?.onHide?.(); - this._screens[page.id].onShow?.(); - this._currentName = name; - } - this._activeView = page.view; - mainStateController.setViewType(page.view); - this.updateMainUiState(); - this.updateLearnBackButtonVisibility(); - } - - /** Show the header back button only while Learn has a subpage open and is on screen: in columns - * always, in the stack only while its tab is the active one (as in the GNOME window). */ - private updateLearnBackButtonVisibility(): void { - if (!this._learnBackButton) return; - const learnOnScreen = this.threeColumns || this._activeView === ViewType.LEARN; - const visible = learnOnScreen && learnView.hasVisibleSubpage; - this._learnBackButton.visibility = visible ? "visible" : "collapsed"; - } - - // --- MainView implementation --- - public assembleGameConsole(): void { - this.navigateToView(ViewType.DEBUGGER); - const code = editorController.code; - mainStateController.setCodeChanged(false); - gameConsoleController.assemble(code); - } - - public runGameConsole(): void { - this.navigateToView(ViewType.GAME_CONSOLE); - gameConsoleView.run(); - } - - public pauseGameConsole(): void { - gameConsoleView.stop(); - } - - public reset(): void { - debuggerView.reset(); - gameConsoleView.reset(); - } - - public stepGameConsole(): void { - this.navigateToView(ViewType.DEBUGGER); - if (!gameConsoleView.simulator.stepperEnabled) { - gameConsoleView.simulator.enableStepper(); - } - gameConsoleView.simulator.debugExecStep(); - this.updateMainUiState(); - } - - public setEditorCode(code: string): void { - this.navigateToView(ViewType.EDITOR); - editorController.setCode(code); - mainStateController.setCodeChanged(false); - } - - // --- Menu / share handlers --- + // --- Menu handlers --- private onMenuItem(id: string): void { switch (id) { case "about": - this._about?.present(); + this.about?.present(); break; case "help": - this.navigateToView(ViewType.LEARN); + this.controller?.navigateToView(ViewType.LEARN); break; case "quit": - Application.android?.foregroundActivity?.finish(); + this.shell?.closeWindow(); break; } } - /** Hand the editor's code to the Android share sheet; GNOME opens its own share dialog instead. */ - private shareCode(): void { - const activity = Application.android?.foregroundActivity; - const code = editorController.code; - if (!activity || !code) return; - const intent = new android.content.Intent(android.content.Intent.ACTION_SEND); - intent.setType("text/plain"); - intent.putExtra(android.content.Intent.EXTRA_TEXT, code); - activity.startActivity(android.content.Intent.createChooser(intent, _("Share"))); - } - - private updateMainUiState(): void { - if (!this._mainButton) return; - const simulatorState = this.state; - let state = mainStateController.updateFromSimulatorState(simulatorState); - // The action button is hidden on the Learn screen (matching the GNOME app). - if (this._activeView === ViewType.LEARN && !this.threeColumns) state = MainButtonState.HIDDEN; - this._mainButton.setState(state); - - // The run toolbar's buttons follow the same enabled states the GNOME window gives its actions. - const enabled = mainStateController.getActionEnabledState( - simulatorState, - editorController.hasCode, - mainStateController.getCodeChanged() - ); - this._actions.get("assemble")!.enabled = enabled.assemble; - this._actions.get("run-simulator")!.enabled = enabled.run; - this._actions.get("resume-simulator")!.enabled = enabled.resume; - this._actions.get("pause-simulator")!.enabled = enabled.pause; - this._actions.get("reset-simulator")!.enabled = enabled.reset; - this._actions.get("step-simulator")!.enabled = enabled.step; - this._actions.get("share")!.enabled = enabled.share; - } - // --- Setup helpers --- private setupAndroidKeyHandling(): void { const KEY_UP = 19; @@ -524,19 +211,6 @@ export class MainController implements MainView { showError(error, { silent: true }); } } - - gameConsoleController.on("keyPressed", (event) => { - this.log.debug("Gamepad key pressed:", event.key, event.keyCode); - }); - } - - private initializeGameConsoleController(): void { - gameConsoleController.initPartial({ - memory: gameConsoleView.memory, - simulator: gameConsoleView.simulator, - assembler: gameConsoleView.assembler, - labels: gameConsoleView.labels, - }); } } diff --git a/packages/app-android/app/views/main/debugger.ts b/packages/app-android/app/views/main/debugger.ts index 812c55c9..13983669 100644 --- a/packages/app-android/app/views/main/debugger.ts +++ b/packages/app-android/app/views/main/debugger.ts @@ -1,85 +1,22 @@ -import { Application } from "@nativescript/core"; -import { - debuggerController, - DebuggerState, - mainStateController, - editorController, - ViewType, -} from "@learn6502/common-ui"; -import type { Memory, Simulator, Assembler } from "@learn6502/core"; +import { DebuggerState, debuggerController } from "@learn6502/common-ui"; import { Debugger as DebuggerWidget } from "@learn6502/ui"; -import { notificationService } from "~/services"; import { asView } from "~/utils/as-view"; -import { gameConsoleView } from "./game-console"; import type { ScreenModule } from "./editor"; -/** - * Thin Android glue around the shared `@learn6502/ui` Debugger: the clipboard and the hop to the - * editor are Android's own; everything else goes through the shared controller. - */ -class DebuggerGlue { - private widget: DebuggerWidget | null = null; - - constructor() { - debuggerController.on("copyToClipboard", (content: string) => this.copyToClipboard(content)); - debuggerController.on("copyToEditor", (code: string) => this.copyToEditor(code)); - } - - get state(): DebuggerState { - return debuggerController.state; - } - set state(value: DebuggerState) { - debuggerController.state = value; - } - - build(): ScreenModule { - // The previous debugger, if the activity was recreated, stops listening to the controller first. - this.widget?.unbind(); - const widget = new DebuggerWidget(); - widget.setSimulatorStack(gameConsoleView); - this.widget = widget; - return { - // The shared widget is typed against the GJS bindings, so it is not a NativeScript view to the compiler. - view: asView(widget), - onShow: () => { - this.state = DebuggerState.ACTIVE; - }, - }; - } - - update(memory: Memory, simulator: Simulator): void { - debuggerController.update(memory, simulator); - } - updateHexdump(assembler: Assembler): void { - debuggerController.updateHexdump(assembler); - } - updateDisassembled(assembler: Assembler): void { - debuggerController.updateDisassembled(assembler); - } - reset(): void { - debuggerController.reset(); - } - - private copyToClipboard(content: string): void { - if (!Application.android) return; - const clipboardManager = Application.android.context.getSystemService( - android.content.Context.CLIPBOARD_SERVICE - ) as android.content.ClipboardManager; - clipboardManager.setPrimaryClip(android.content.ClipData.newPlainText("6502 Code", content)); - notificationService.showNotification({ title: "Copied to clipboard", timeout: 2 }); - } - - private copyToEditor(code: string): void { - mainStateController.events.dispatch("navigate-to-view", { viewType: ViewType.EDITOR }); - editorController.setCode(code); - notificationService.showNotification({ title: "Code copied to editor", timeout: 2 }); - } -} - -export const debuggerView = new DebuggerGlue(); - -/** Build the debugger screen for the shell's Adw.ViewStack. */ -export function buildDebuggerScreen(): ScreenModule { - return debuggerView.build(); +let widget: DebuggerWidget | null = null; + +/** Build the debugger screen; the shared main window controller hands it the stack. */ +export function buildDebuggerScreen(): ScreenModule { + // The previous debugger, if the activity was recreated, stops listening to the controller first. + widget?.unbind(); + widget = new DebuggerWidget(); + return { + // The shared widget is typed against the GJS bindings, so it is not a NativeScript view to the compiler. + view: asView(widget), + widget, + onShow: () => { + debuggerController.state = DebuggerState.ACTIVE; + }, + }; } diff --git a/packages/app-android/app/views/main/editor.ts b/packages/app-android/app/views/main/editor.ts index b8754147..918536d3 100644 --- a/packages/app-android/app/views/main/editor.ts +++ b/packages/app-android/app/views/main/editor.ts @@ -18,9 +18,11 @@ registerTemplateClass("QuickHelpView", QuickHelpView); const SETTINGS_CODE = "editor.code"; -/** A built screen: its root view + optional show/hide lifecycle hooks. */ -export interface ScreenModule { +/** A built screen: its root view, the shared widget behind it and optional show/hide lifecycle hooks. */ +export interface ScreenModule { view: View; + /** The `@learn6502/ui` widget the shared main window controller drives. */ + widget: W; onShow?(): void; onHide?(): void; /** Handle the hardware back button while this screen is active. Return true if @@ -79,7 +81,7 @@ class Editor extends Observable implements EditorView { /** Build the shared `@learn6502/ui` editor (the source view over the quick-help bottom sheet) * and wire it to the controller (once). */ - build(): View { + build(): EditorWidget { // The shared `Editor` is typed against the GJS bindings, so it is not a NativeScript view to the compiler. this._pane?.unbind(); const pane = new EditorWidget(); @@ -97,7 +99,7 @@ class Editor extends Observable implements EditorView { this._initialized = true; } - return asView(pane); + return pane; } /** The back button closes the quick-help sheet first. */ @@ -130,9 +132,12 @@ class Editor extends Observable implements EditorView { export const editorView = new Editor(); /** Build the editor screen for the shell's Adw.ViewStack. */ -export function buildEditorScreen(): ScreenModule { +export function buildEditorScreen(): ScreenModule { + const widget = editorView.build(); return { - view: editorView.build(), + // The shared widget is typed against the GJS bindings, so it is not a NativeScript view to the compiler. + view: asView(widget), + widget, onHide: () => editorView.save(), onBack: () => editorView.closeSheet(), padSystemInsets: (on) => { diff --git a/packages/app-android/app/views/main/game-console.ts b/packages/app-android/app/views/main/game-console.ts index feb2303f..8a484251 100644 --- a/packages/app-android/app/views/main/game-console.ts +++ b/packages/app-android/app/views/main/game-console.ts @@ -1,59 +1,18 @@ -import type { SimulatorState, Memory, Labels, Simulator, Assembler } from "@learn6502/core"; import { GameConsole as GameConsoleWidget } from "@learn6502/ui"; -import { gameConsoleController, createSimulatorStack } from "@learn6502/common-ui"; +import { createSimulatorStack } from "@learn6502/common-ui"; import { asView } from "~/utils/as-view"; import type { ScreenModule } from "./editor"; /** One simulator stack for the process: the activity, and the widgets with it, are rebuilt around it. */ -const stack = createSimulatorStack(); +export const simulatorStack = createSimulatorStack(); -/** - * Thin Android glue around the shared `@learn6502/ui` GameConsole: it owns the simulator stack - * (the widget receives it) and forwards the shell's calls to the shared controller. - */ -class GameConsoleGlue { - private widget: GameConsoleWidget | null = null; +let widget: GameConsoleWidget | null = null; - get memory(): Memory { - return stack.memory; - } - get labels(): Labels { - return stack.labels; - } - get simulator(): Simulator { - return stack.simulator; - } - get assembler(): Assembler { - return stack.assembler; - } - get state(): SimulatorState { - return stack.simulator.state; - } - - build(): ScreenModule { - // The previous console, if the activity was recreated, stops listening to memory first. - this.widget?.unbind(); - const widget = new GameConsoleWidget(); - widget.setSimulatorStack(stack); - this.widget = widget; - // The shared widget is typed against the GJS bindings, so it is not a NativeScript view to the compiler. - return { view: asView(widget) }; - } - - run(): void { - gameConsoleController.run(); - } - stop(): void { - gameConsoleController.stop(); - } - reset(): void { - gameConsoleController.reset(); - } -} - -export const gameConsoleView = new GameConsoleGlue(); - -/** Build the game console screen for the shell's Adw.ViewStack. */ -export function buildGameConsoleScreen(): ScreenModule { - return gameConsoleView.build(); +/** Build the game console screen; the shared main window controller hands it the stack. */ +export function buildGameConsoleScreen(): ScreenModule { + // The previous console, if the activity was recreated, stops listening to memory first. + widget?.unbind(); + widget = new GameConsoleWidget(); + // The shared widget is typed against the GJS bindings, so it is not a NativeScript view to the compiler. + return { view: asView(widget), widget }; } diff --git a/packages/app-android/app/views/main/learn.ts b/packages/app-android/app/views/main/learn.ts index a33daecb..68976e9d 100644 --- a/packages/app-android/app/views/main/learn.ts +++ b/packages/app-android/app/views/main/learn.ts @@ -1,6 +1,5 @@ import type { View } from "@nativescript/core"; import { asView } from "~/utils/as-view"; -import { Utils } from "@nativescript/core"; import { padForSystemInsets } from "@gjsify/adwaita-nativescript"; import { registerTemplateClass } from "@gjsify/adwaita-nativescript/builder"; import { localize } from "@nativescript/localize"; @@ -13,8 +12,6 @@ import type { ScreenModule } from "./editor"; // The shared learn view's source views are `@learn6502/ui`'s; this arm teaches NativeScript what they are. import "~/widgets/source-view"; -const SHARE_URL = "https://github.com/JumpLink/Learn6502"; - // The shared widget's strings go through the app's catalogue. setTranslator(localize); @@ -43,7 +40,7 @@ class Learn implements LearnView { private releaseInsets: (() => void)[] = []; private log = logger.scoped("Learn"); - build(): View { + build(): LearnWidget { // The shared `Learn` is typed against the GJS bindings, so it is not a NativeScript view to the compiler. const widget = new LearnWidget(); this.widget = widget; @@ -52,8 +49,7 @@ class Learn implements LearnView { if (event.tag === "tutorial") tutorialView?.build(); this.events.dispatch("subpage-changed", event); }); - widget.events.on("share", () => Utils.openUrl(SHARE_URL)); - return asView(widget); + return widget; } /** Whether a subpage (Tutorial/Examples) is open — drives the shell's header back button. */ @@ -93,9 +89,12 @@ class Learn implements LearnView { const learnView = new Learn(); /** Build the learn screen for the shell's Adw.ViewStack. */ -export function buildLearnScreen(): ScreenModule { +export function buildLearnScreen(): ScreenModule { + const widget = learnView.build(); return { - view: learnView.build(), + // The shared `Learn` is typed against the GJS bindings, so it is not a NativeScript view to the compiler. + view: asView(widget), + widget, onHide: () => learnView.saveScrollPosition(), onBack: () => learnView.navigateBack(), padSystemInsets: (on) => learnView.padSystemInsets(on), diff --git a/packages/app-android/app/views/main/screens.ts b/packages/app-android/app/views/main/screens.ts index b282ba6f..78619653 100644 --- a/packages/app-android/app/views/main/screens.ts +++ b/packages/app-android/app/views/main/screens.ts @@ -1,3 +1,4 @@ +import type { Debugger, GameConsole, Learn, Editor } from "@learn6502/ui"; import { registerTemplateClass } from "@gjsify/adwaita-nativescript/builder"; import { buildEditorScreen, type ScreenModule } from "./editor"; import { buildLearnScreen } from "./learn"; @@ -6,10 +7,10 @@ import { buildGameConsoleScreen } from "./game-console"; /** The four screens, by the id `main.window.blp` gives the object that stands for each. */ export interface Screens { - learn: ScreenModule; - editor: ScreenModule; - debugger: ScreenModule; - gameConsole: ScreenModule; + learn: ScreenModule; + editor: ScreenModule; + debugger: ScreenModule; + gameConsole: ScreenModule; } /** diff --git a/packages/app-android/app/views/main/shell.ts b/packages/app-android/app/views/main/shell.ts new file mode 100644 index 00000000..c549a799 --- /dev/null +++ b/packages/app-android/app/views/main/shell.ts @@ -0,0 +1,212 @@ +import type { View } from "@nativescript/core"; +import type GjsGtk from "@girs/gtk-4.0"; +import { Application, GridLayout } from "@nativescript/core"; +import { localize as _ } from "@nativescript/localize"; +import { Gio, NOTIFY_VISIBLE_CHILD, insertActionGroup, padForSystemInsets } from "@gjsify/adwaita-nativescript"; +import type { Adw, Gtk } from "@gjsify/adwaita-nativescript"; +import { ViewType, mainStateController } from "@learn6502/common-ui"; +import type { MainWindowShell, ShellAction, UnsavedChangesAnswer } from "@learn6502/ui"; +import { schoolSymbolic, codeSymbolic, bugSymbolic, nintendoControllerSymbolic } from "~/icons"; +import type { Screens } from "./screens"; + +/** The four screens, in the order the stack lists them, with their tab title and icon. */ +const STACK_PAGES: { id: keyof Screens; view: ViewType; title: () => string; icon: string }[] = [ + { id: "learn", view: ViewType.LEARN, title: () => _("Learn"), icon: schoolSymbolic }, + { id: "editor", view: ViewType.EDITOR, title: () => _("Code"), icon: codeSymbolic }, + { id: "debugger", view: ViewType.DEBUGGER, title: () => _("Debug"), icon: bugSymbolic }, + { id: "gameConsole", view: ViewType.GAME_CONSOLE, title: () => _("Play"), icon: nintendoControllerSymbolic }, +]; + +/** The widgets `main.window.blp` builds, as the shell places the screens in them. */ +export interface NativeShellParts { + /** The built window; the NativeScript `Adw.ApplicationWindow`. */ + root: View; + stack: Adw.ViewStack; + switcherBar: Adw.ViewSwitcherBar; + layoutHost: Gtk.Stack; + leftSidebar: Adw.OverlaySplitView; + leftColumn: Gtk.Box; + centerColumn: Gtk.Box; + rightTopBox: Gtk.Box; + rightBottomBox: Gtk.Box; + rightColumnContent: Gtk.Box; + unsavedChangesDialog: Adw.AlertDialog; + titleLabel: Gtk.Label; + learnBackButton: Gtk.Button; +} + +/** + * `MainWindowShell` over the NativeScript Adwaita widgets: the stack on narrow windows, the + * columns on wide ones, as the GNOME window's `Adw.Breakpoint`s decide. + */ +export class NativeMainWindowShell implements MainWindowShell { + readonly window: GjsGtk.Window; + // The back button is not mapped to `close-request` in gjsify yet (a core gap), so nothing fires + // it and the unsaved-changes guard stays unwired here instead of being worked around. + readonly emitsCloseRequest = false; + + private readonly actionGroup = new Gio.SimpleActionGroup(); + private releaseColumnInsets: (() => void) | null = null; + private currentName: string | null = null; + private current: ViewType = ViewType.EDITOR; + private onViewChanged: ((view: ViewType, previous: ViewType) => void) | null = null; + + constructor( + private readonly parts: NativeShellParts, + private readonly screens: Screens + ) { + // The root is the NativeScript Adw.ApplicationWindow, typed against GJS in the shared package. + this.window = parts.root as unknown as GjsGtk.Window; + // The unsaved-changes card is an in-page modal painted over everything, as the About dialog is. + const dialog = parts.unsavedChangesDialog as unknown as View; + GridLayout.setRow(dialog, 0); + GridLayout.setColumn(dialog, 0); + (parts.root as GridLayout).addChild(dialog); + parts.stack.addEventListener(NOTIFY_VISIBLE_CHILD, () => this.onStackChanged(parts.stack.visibleChildName)); + } + + isDesktopLayout(): boolean { + return this.parts.layoutHost.visibleChildName === "three"; + } + + onLayoutChanged(handler: () => void): void { + this.parts.layoutHost.connect("notify::visible-child-name", () => { + this.mountLayout(); + handler(); + }); + } + + mountLayout(): void { + if (this.isDesktopLayout()) this.mountThreeColumnLayout(); + else this.mountSingleLayout(); + } + + activeView(): ViewType { + return this.current; + } + + showView(view: ViewType): void { + const page = STACK_PAGES.find((candidate) => candidate.view === view); + if (!page) return; + this.parts.stack.visibleChildName = page.id; // fires notify::visible-child if changed + this.onStackChanged(page.id); // covers the already-on-that-view case + } + + onActiveViewChanged(handler: (view: ViewType, previous: ViewType) => void): void { + this.onViewChanged = handler; + } + + setLearnBackVisible(visible: boolean): void { + this.parts.learnBackButton.visibility = visible ? "visible" : "collapsed"; + } + + setFileTitle(filename: string): void { + this.parts.titleLabel.label = filename; + } + + closeSidebarIfOverlay(): void { + if (this.isDesktopLayout() && this.parts.leftSidebar.collapsed) this.parts.leftSidebar.showSidebar = false; + } + + // The focus follow is a desktop-layout matter and nothing here takes the focus. + isInsideConsole(): boolean { + return false; + } + + addAction(name: string, activate: () => void): ShellAction { + const action = new Gio.SimpleAction({ name }); + action.connect("activate", () => activate()); + this.actionGroup.add_action(action); + return { + setEnabled: (enabled) => { + action.enabled = enabled; + }, + }; + } + + /** The `win` action group the main button and the run toolbar fire (their `action-name`s). */ + insertActions(): void { + insertActionGroup(this.parts.root, "win", this.actionGroup); + } + + async askUnsavedChanges(): Promise { + const response = await this.parts.unsavedChangesDialog.present(); + return response === "save" || response === "discard" ? response : "cancel"; + } + + closeWindow(): void { + Application.android?.foregroundActivity?.finish(); + } + + /** The hardware back button, offered to the screens in view first (the Learn screen pops a page). */ + handleBack(): boolean { + if (this.isDesktopLayout()) { + return (this.screens.editor.onBack?.() ?? false) || (this.screens.learn.onBack?.() ?? false); + } + const page = STACK_PAGES.find((candidate) => candidate.id === this.currentName); + return page ? (this.screens[page.id].onBack?.() ?? false) : false; + } + + private onStackChanged(name: string): void { + const page = STACK_PAGES.find((candidate) => candidate.id === name); + if (!page || this.isDesktopLayout()) return; + if (name !== this.currentName) { + if (this.currentName) this.screens[this.currentName as keyof Screens].onHide?.(); + this.screens[page.id].onShow?.(); + this.currentName = name; + } + const previous = this.current; + this.current = page.view; + this.onViewChanged?.(page.view, previous); + } + + /** Detach a screen from whichever container holds it, ready for the other layout. */ + private detach(view: View): void { + const parent = view.parent as unknown as { remove?: (child: View) => unknown; removeChild?: (v: View) => void }; + if (!parent) return; + if (parent === (this.parts.stack as unknown)) this.parts.stack.remove(view); + else if (typeof parent.remove === "function") parent.remove(view); + else parent.removeChild?.(view); + } + + private mountSingleLayout(): void { + const { stack, switcherBar } = this.parts; + const keep = this.currentName ?? STACK_PAGES[1]!.id; + for (const page of STACK_PAGES) this.detach(this.screens[page.id].view); + for (const page of STACK_PAGES) stack.add(this.screens[page.id].view, page.id, page.title(), page.icon); + switcherBar.set_stack(stack); + this.padColumnsForSystemInsets(false); + this.currentName = null; + stack.visibleChildName = keep; + this.onStackChanged(stack.visibleChildName); + } + + private mountThreeColumnLayout(): void { + const { screens } = this; + for (const page of STACK_PAGES) this.detach(screens[page.id].view); + // A box hands spare space only to a child that asks for it, and the screens are plain views. + for (const page of STACK_PAGES) Object.assign(screens[page.id].view, { hexpand: true, vexpand: true }); + this.parts.leftColumn.append(screens.learn.view); + this.parts.centerColumn.append(screens.editor.view); + this.parts.rightTopBox.append(screens.gameConsole.view); + this.parts.rightBottomBox.append(screens.debugger.view); + // No bottom bar in columns; the screens are all on show. + this.parts.switcherBar.set_stack(null); + this.padColumnsForSystemInsets(true); + this.currentName = null; + for (const page of STACK_PAGES) screens[page.id].onShow?.(); + this.current = ViewType.EDITOR; + mainStateController.setViewType(ViewType.EDITOR); + } + + /** + * The columns run down to the screen's bottom edge, behind the gesture area, with their + * backgrounds. What scrolls in them (and the editor's Help bar) clears it instead of the + * columns being shortened. In the stack the switcher bar below pays it, so nothing does here. + */ + private padColumnsForSystemInsets(on: boolean): void { + this.releaseColumnInsets?.(); + this.releaseColumnInsets = on ? padForSystemInsets(this.parts.rightColumnContent) : null; + for (const page of STACK_PAGES) this.screens[page.id].padSystemInsets?.(on); + } +} diff --git a/packages/app-gnome/src/views/main.window.shell.ts b/packages/app-gnome/src/views/main.window.shell.ts new file mode 100644 index 00000000..fd18be6a --- /dev/null +++ b/packages/app-gnome/src/views/main.window.shell.ts @@ -0,0 +1,224 @@ +import type Adw from "@girs/adw-1"; +import Gio from "@girs/gio-2.0"; +import type Gtk from "@girs/gtk-4.0"; + +import { ViewType } from "@learn6502/common-ui"; +import { + type Debugger, + type Editor, + type GameConsole, + type Learn, + type MainWindowShell, + type ShellAction, + type UnsavedChangesAnswer, +} from "@learn6502/ui"; + +/** The widgets the window's template provides for placing the four screens. */ +export interface MainWindowShellParts { + editor: Editor; + gameConsole: GameConsole; + learn: Learn; + debugger: Debugger; + stack: Adw.ViewStack; + switcherBar: Adw.ViewSwitcherBar; + layoutHost: Gtk.Stack; + leftSidebar: Adw.OverlaySplitView; + leftColumn: Gtk.Box; + centerColumn: Gtk.Box; + rightTopBox: Gtk.Box; + rightBottomBox: Gtk.Box; + unsavedChangesDialog: Adw.AlertDialog; + titleLabel: Gtk.Label; + learnBackButton: Gtk.Button; +} + +/** `MainWindowShell` over the GTK widgets of `main.window.blp`. */ +export class GtkMainWindowShell implements MainWindowShell { + private readonly viewWidgets = new Map(); + private previousVisibleChild: Gtk.Widget | null = null; + + constructor( + readonly window: Adw.ApplicationWindow, + private readonly parts: MainWindowShellParts + ) { + this.viewWidgets.set(ViewType.LEARN, parts.learn); + this.viewWidgets.set(ViewType.EDITOR, parts.editor); + this.viewWidgets.set(ViewType.DEBUGGER, parts.debugger); + this.viewWidgets.set(ViewType.GAME_CONSOLE, parts.gameConsole); + } + + isDesktopLayout(): boolean { + return this.parts.layoutHost.get_visible_child_name() === "three"; + } + + onLayoutChanged(handler: () => void): void { + this.parts.layoutHost.connect("notify::visible-child-name", () => { + this.mountLayout(); + handler(); + }); + } + + mountLayout(): void { + if (this.isDesktopLayout()) this.mountThreeColumnLayout(); + else this.mountSingleLayout(); + this.previousVisibleChild = this.parts.stack.get_visible_child(); + } + + activeView(): ViewType { + return this.viewOf(this.parts.stack.get_visible_child()); + } + + showView(view: ViewType): void { + const widget = this.viewWidgets.get(view); + if (widget) this.parts.stack.set_visible_child(widget); + else console.error(`showView: Widget not found for view ${view}`); + } + + onActiveViewChanged(handler: (view: ViewType, previous: ViewType) => void): void { + this.parts.stack.connect("notify::visible-child", () => { + const { learn } = this.parts; + const current = this.parts.stack.get_visible_child(); + const previous = this.previousVisibleChild; + this.previousVisibleChild = current; + + if (previous === learn && current !== learn) { + learn.saveScrollPosition(); + } + if (current === learn && previous !== learn) { + // The Learn widget has to be mapped before its scroll position can be restored + if (learn.get_mapped()) { + learn.restoreScrollPosition(); + } else { + const handlerId = learn.connect("map", () => { + learn.restoreScrollPosition(); + learn.disconnect(handlerId); + }); + } + } + + handler(this.viewOf(current), this.viewOf(previous)); + }); + } + + setLearnBackVisible(visible: boolean): void { + this.parts.learnBackButton.visible = visible; + } + + setFileTitle(filename: string): void { + this.parts.titleLabel.label = filename; + } + + closeSidebarIfOverlay(): void { + if (this.isDesktopLayout() && this.parts.leftSidebar.collapsed) { + this.parts.leftSidebar.show_sidebar = false; + } + } + + isInsideConsole(widget: Gtk.Widget | null): boolean { + let current: Gtk.Widget | null = widget; + while (current) { + if (current === this.parts.gameConsole) return true; + current = current.get_parent(); + } + return false; + } + + addAction(name: string, activate: () => void, accels?: string[]): ShellAction { + const action = new Gio.SimpleAction({ name }); + action.connect("activate", () => activate()); + this.window.add_action(action); + if (accels) this.window.get_application()?.set_accels_for_action(`win.${name}`, accels); + return { setEnabled: (enabled) => action.set_enabled(enabled) }; + } + + askUnsavedChanges(): Promise { + const { unsavedChangesDialog } = this.parts; + return new Promise((resolve) => { + const handlerId = unsavedChangesDialog.connect("response", (_dialog: Adw.AlertDialog, response: string) => { + unsavedChangesDialog.disconnect(handlerId); + resolve(response === "save" || response === "discard" ? response : "cancel"); + }); + unsavedChangesDialog.present(this.window); + }); + } + + closeWindow(): void { + this.window.close(); + } + + private viewOf(widget: Gtk.Widget | null): ViewType { + for (const [view, candidate] of this.viewWidgets.entries()) { + if (candidate === widget) return view; + } + return ViewType.LEARN; + } + + /** Mobile: all views as pages of one stack. */ + private mountSingleLayout(): void { + const { stack, learn, editor, debugger: debuggerView, gameConsole } = this.parts; + for (const view of [learn, editor, debuggerView, gameConsole]) this.detachFromParent(view); + + const pages = stack.get_pages(); + const toRemove: Gtk.Widget[] = []; + for (let i = 0; i < pages.get_n_items(); i++) { + const child = (pages.get_item(i) as Gtk.StackPage).get_child(); + if (child) toRemove.push(child); + } + for (const child of toRemove) stack.remove(child); + + // TRANSLATORS: ViewSwitcher/tab title for the Learn/Tutorial view + stack.add_titled_with_icon(learn, "learn", _("Learn"), "school-symbolic"); + // TRANSLATORS: ViewSwitcher/tab title for the Editor view + stack.add_titled_with_icon(editor, "editor", _("Code"), "code-symbolic"); + // TRANSLATORS: ViewSwitcher/tab title for the Debugger view + stack.add_titled_with_icon(debuggerView, "debugger", _("Debug"), "bug-symbolic"); + // TRANSLATORS: ViewSwitcher/tab title for the Game Console view + stack.add_titled_with_icon(gameConsole, "gameConsole", _("Play"), "nintendo-controller-symbolic"); + + this.parts.switcherBar.set_stack(stack); + // The debugger scrolls on its own when it fills a whole page + debuggerView.scrollable = true; + } + + /** Desktop: Learn | Editor | Game Console over Debugger. */ + private mountThreeColumnLayout(): void { + const { learn, editor, gameConsole, debugger: debuggerView } = this.parts; + for (const box of [ + this.parts.leftColumn, + this.parts.centerColumn, + this.parts.rightTopBox, + this.parts.rightBottomBox, + ]) { + this.clearBoxChildren(box); + } + + const placements: [Gtk.Widget, Gtk.Box][] = [ + [learn, this.parts.leftColumn], + [editor, this.parts.centerColumn], + [gameConsole, this.parts.rightTopBox], + [debuggerView, this.parts.rightBottomBox], + ]; + for (const [view, box] of placements) { + this.detachFromParent(view); + box.append(view); + } + + // The right column scrolls as a whole + debuggerView.scrollable = false; + this.parts.switcherBar.set_stack(null); + } + + private clearBoxChildren(box: Gtk.Box): void { + let child = box.get_first_child(); + while (child) { + const next = child.get_next_sibling(); + box.remove(child); + child = next; + } + } + + private detachFromParent(widget: Gtk.Widget): void { + const parent = widget.get_parent() as (Gtk.Widget & { remove?: (child: Gtk.Widget) => void }) | null; + parent?.remove?.(widget); + } +} diff --git a/packages/app-gnome/src/views/main.window.ts b/packages/app-gnome/src/views/main.window.ts index 115a2a3e..0fb6de59 100644 --- a/packages/app-gnome/src/views/main.window.ts +++ b/packages/app-gnome/src/views/main.window.ts @@ -2,34 +2,30 @@ import GObject from "@girs/gobject-2.0"; import Adw from "@girs/adw-1"; import Gtk from "@girs/gtk-4.0"; import Gdk from "@girs/gdk-4.0"; -import Gio from "@girs/gio-2.0"; -import GLib from "@girs/glib-2.0"; -import { SimulatorState, num2hex, debounce } from "@learn6502/core"; +import { gameConsoleController } from "@learn6502/common-ui"; +import type { Memory } from "@learn6502/core"; +import { + MainWindowController, + type Debugger, + type Editor, + type GameConsole, + type Learn, + type MainButton, + type MainWindowServices, +} from "@learn6502/ui"; -import type { Editor, Learn, GameConsole, Debugger, MainButton } from "@learn6502/ui"; import { HelpWindow } from "./help.window.ts"; +import { GtkMainWindowShell } from "./main.window.shell.ts"; import { ShareDialog } from "../widgets"; import { copyToClipboard } from "../utils.ts"; import { themeService, notificationService, fileService } from "../services"; import { settings } from "../settings.ts"; import Template from "./main.window.blp"; -import { - type MainButtonState, - type MainView, - ViewType, - debuggerController, - gameConsoleController, - mainStateController, - editorController, - learnController, - GameConsoleEventBridge, - createSimulatorStack, -} from "@learn6502/common-ui"; -export class MainWindow extends Adw.ApplicationWindow implements MainView { - // Child widgets +/** The GNOME shell around the shared `MainWindowController`: the template, the services and the keyboard. */ +export class MainWindow extends Adw.ApplicationWindow { declare private _editor: Editor; declare private _gameConsole: GameConsole; declare private _learn: Learn; @@ -39,21 +35,16 @@ export class MainWindow extends Adw.ApplicationWindow implements MainView { declare private _debugger: Debugger; declare private _toastOverlay: Adw.ToastOverlay; declare private _layoutHost: Gtk.Stack; - declare private _unsavedChangesDialog: Adw.AlertDialog; declare private _titleLabel: Gtk.Label; declare private _unsavedChangesIndicator: Gtk.Button; - declare private _sidebarToggleButton: Gtk.ToggleButton; declare private _learnBackButton: Gtk.Button; - - // Three column layout widgets declare private _leftSidebar: Adw.OverlaySplitView; declare private _leftColumn: Gtk.Box; declare private _centerColumn: Gtk.Box; - declare private _rightColumn: Gtk.ScrolledWindow; - declare private _rightColumnContent: Gtk.Box; declare private _rightTopBox: Gtk.Box; declare private _rightBottomBox: Gtk.Box; + static { GObject.registerClass( { @@ -100,639 +91,93 @@ export class MainWindow extends Adw.ApplicationWindow implements MainView { this._learn.navigateBack(); } - private updateLearnBackButtonVisibility(): void { - const isDesktop = this.isDesktopMode(); - const isLearnVisible = this._stack.get_visible_child() === this._learn; - const hasSubpage = this._learn.hasVisibleSubpage; - - // Show back button only when a subpage is open (in desktop mode always, in mobile only when Learn is visible) - this._learnBackButton.visible = hasSubpage && (isDesktop || isLearnVisible); - } - - // Event bridge for game console signals - private gameConsoleBridge: GameConsoleEventBridge | null = null; - - // State - private previousVisibleChild: Gtk.Widget | null = null; - private currentFile: Gio.File | null = null; - private pendingDialogAction: "open" | "close" | null = null; - private codeToAssembleChanged: boolean = false; - private lastRunAtMs: number = 0; - private focusSourceId: number | null = null; - - // Debounced handler to avoid immediate pause on transient focus loss (e.g., menu popovers) - private handleFocusChangeDebounced = debounce(() => { - if (!this.is_active) { - const state = this._gameConsole.simulator.state; - if (state === SimulatorState.RUNNING) { - this.pauseGameConsole(); - } - } - }, 250); - - // Debounced handler for in-window focus changes in desktop mode - private handleChildFocusChangeDebounced = debounce(() => { - if (!this.isDesktopMode()) return; - - const focused: Gtk.Widget | null = this.get_focus() ?? null; - const isFocusInsideConsole = this.widgetIsDescendantOf(focused, this._gameConsole); - if (!isFocusInsideConsole) { - // Suppress immediate auto-pause right after starting the program - if (Date.now() - this.lastRunAtMs < 400) return; - const state = this._gameConsole.simulator.state; - if (state === SimulatorState.RUNNING) { - this.pauseGameConsole(); - } - } - }, 150); - - // Map from ViewType to widget - private viewWidgetMap = new Map(); - - private isDesktopMode(): boolean { - return this._layoutHost?.get_visible_child_name?.() === "three"; - } - - private set unsavedChanges(unsavedChanges: boolean) { - fileService?.setUnsavedChanges(unsavedChanges); - } - - private get unsavedChanges(): boolean { - return this._unsavedChangesIndicator.visible; - } - - // Simulator actions - private assembleAction = new Gio.SimpleAction({ name: "assemble" }); - private runSimulatorAction = new Gio.SimpleAction({ name: "run-simulator" }); - private resumeSimulatorAction = new Gio.SimpleAction({ - name: "resume-simulator", - }); - private pauseSimulatorAction = new Gio.SimpleAction({ - name: "pause-simulator", - }); - private resetSimulatorAction = new Gio.SimpleAction({ - name: "reset-simulator", - }); - private stepSimulatorAction = new Gio.SimpleAction({ - name: "step-simulator", - }); - private shareAction = new Gio.SimpleAction({ name: "share" }); - - // File actions - private openFileAction = new Gio.SimpleAction({ name: "open-file" }); - private saveFileAction = new Gio.SimpleAction({ name: "save-file" }); - private saveAsFileAction = new Gio.SimpleAction({ name: "save-as-file" }); - - // Help actions - private showHelpAction = new Gio.SimpleAction({ name: "show-help" }); - constructor(application: Adw.Application) { super({ application }); - // One simulator stack for the console that runs it and the debugger that shows it. - const stack = createSimulatorStack(); - this._gameConsole.setSimulatorStack(stack); - this._debugger.setSimulatorStack(stack); - - // Initialize services fileService.init(this); fileService.setUnsavedChangesIndicator(this._unsavedChangesIndicator); - themeService.init(); - notificationService.init(this, this._toastOverlay); + // Register this window to receive mode/accent classes automatically + themeService.registerThemedWidget(this); - // Mount layout based on current breakpoint mode and listen for changes - const initialMode = this._layoutHost.get_visible_child_name(); - if (initialMode === "three") { - this.mountThreeColumnLayout(); - } else { - this.mountSingleLayout(); - } - - this._layoutHost.connect("notify::visible-child-name", () => { - const mode = this._layoutHost.get_visible_child_name(); - if (mode === "three") { - this.mountThreeColumnLayout(); - } else { - this.mountSingleLayout(); - } - }); - - // Setup view to widget mapping - this.viewWidgetMap.set(ViewType.LEARN, this._learn); - this.viewWidgetMap.set(ViewType.EDITOR, this._editor); - this.viewWidgetMap.set(ViewType.DEBUGGER, this._debugger); - this.viewWidgetMap.set(ViewType.GAME_CONSOLE, this._gameConsole); + const screens = { + editor: this._editor, + gameConsole: this._gameConsole, + learn: this._learn, + debugger: this._debugger, + }; + new MainWindowController( + new GtkMainWindowShell(this, { + ...screens, + stack: this._stack, + switcherBar: this._switcherBar, + layoutHost: this._layoutHost, + leftSidebar: this._leftSidebar, + leftColumn: this._leftColumn, + centerColumn: this._centerColumn, + rightTopBox: this._rightTopBox, + rightBottomBox: this._rightBottomBox, + unsavedChangesDialog: this._unsavedChangesDialog, + titleLabel: this._titleLabel, + learnBackButton: this._learnBackButton, + }), + { ...screens, mainButton: this._mainButton }, + this.createServices() + ).init(); - // Initialize controllers and services - this.setupGeneralSignalListeners(); - this.setupActions(); - this.setupFileActions(); - this.setupMainButton(); - this.setupGameConsoleBridge(); this.setupKeyboardListener(); - this.setupLearnTutorialSignalListeners(); - this.setupEditorSignalListeners(); - this.setupDebuggerSignalListeners(); - this.setupHelpActions(); - this.setupThemeManagement(); - this.setupMainStateEventListeners(); - - // Initialize main state controller - mainStateController.init(); - - // Initialize the previous visible child after all setup is done - this.previousVisibleChild = this._stack.get_visible_child(); - - // Load and setup window size management - this.loadWindowSize(); - this.setupWindowSizeSaving(); - } - - public get state(): SimulatorState { - return this._gameConsole.simulator.state; - } - - public get activeView(): ViewType { - // Get current visible child and return the corresponding ViewType - const visibleChild = this._stack.get_visible_child(); - for (const [viewType, widget] of this.viewWidgetMap.entries()) { - if (widget === visibleChild) { - return viewType; - } - } - return ViewType.LEARN; // Default to LEARN if not found - } - - /** - * Navigates to a specific view/tab - * @param viewType The view to navigate to - */ - public navigateToView(viewType: ViewType): void { - // In desktop mode, all relevant views are visible. Skip switching the stack. - if (this.isDesktopMode()) { - return; - } - const widget = this.viewWidgetMap.get(viewType); - if (widget) { - this._stack.set_visible_child(widget); - } else { - console.error(`navigateToView: Widget not found for view ${viewType}`); - } - } - - private setupLearnTutorialSignalListeners(): void { - learnController.on("copy", ({ code }: { code: string }) => { - this.setEditorCode(code); - this.closeSidebarIfOverlay(); - this.showToast({ - // TRANSLATORS: Toast message title after copying code snippet into the editor - title: _("Code copied to editor"), - timeout: 2, - }); - }); } - private setupEditorSignalListeners(): void { - // Connect to text buffer's changed signal - this._editor.events.on("changed", () => { - this.unsavedChanges = true; - - // Use mainStateController to handle code changed state - mainStateController.setCodeChanged(true); - }); - } - - private setupDebuggerSignalListeners(): void { - debuggerController.on("copyToClipboard", this.onCopyToClipboard.bind(this)); - debuggerController.on("copyToEditor", this.onCopyToEditor.bind(this)); - debuggerController.on("stepperToggled", this.onStepperToggled.bind(this)); + private createServices(): MainWindowServices { + return { + files: { + openFile: () => fileService.openFile(), + saveFile: async (content) => (await fileService.saveFile(content)) || false, + saveFileAs: (content, suggestedName) => fileService.saveFileAs(content, suggestedName), + getCurrentFileName: () => fileService.getCurrentFileName() || "", + hasCurrentFile: () => fileService.getCurrentGioFile() !== null, + hasUnsavedChanges: () => fileService.hasUnsavedChanges(), + setUnsavedChanges: (hasChanges) => fileService.setUnsavedChanges(hasChanges), + }, + settings: { + getInt: (key) => settings.get_int(key), + setInt: (key, value) => settings.set_int(key, value), + getBoolean: (key) => settings.get_boolean(key), + setBoolean: (key, value) => settings.set_boolean(key, value), + }, + clipboard: { copy: (text) => copyToClipboard(text, this.get_clipboard()) }, + notifier: { show: (options) => notificationService.showNotification(options) }, + showHelp: () => new HelpWindow().present(), + showShareDialog: (code, memory) => this.presentShareDialog(code, memory), + submitExample: (code, memory) => this.presentShareDialog(code, memory), + }; } - private onCopyToClipboard(code: string): void { - const success = copyToClipboard(code, this.get_clipboard()); - if (success) { - this.showToast({ - // TRANSLATORS: Toast message title after copying to clipboard - title: _("Copied to clipboard"), - timeout: 2, - }); - } else { - this.showToast({ - // TRANSLATORS: Toast message title when clipboard copy fails - title: _("Failed to copy to clipboard"), - timeout: 2, + private presentShareDialog(code: string, memory: Memory): void { + const dialog = new ShareDialog(); + dialog.setCode(code); + dialog.setMemory(memory); + dialog.connect("submit", (submitted: ShareDialog) => { + console.log("Example submitted:", submitted.submittedExample); + // TODO: Implement GitHub PR creation logic here + notificationService.showNotification({ + // TRANSLATORS: Toast message when example is submitted + title: _("Example submitted for review"), + timeout: 3, }); - } - } - - private onCopyToEditor(code: string): void { - this.setEditorCode(code); - this.closeSidebarIfOverlay(); - } - - private onStepperToggled(enabled: boolean): void { - // Update the main button state when stepper changes - this.updateRunActions(this._gameConsole.simulator.state); - } - - private setupGeneralSignalListeners(): void { - this.connect("close-request", this.onCloseRequest.bind(this)); - this._stack.connect("notify::visible-child", this.onStackVisibleChildChanged.bind(this)); - this.connect("notify::is-active", this.onFocusChanged.bind(this)); - // Listen to focus changes within the window for desktop-mode pause behavior - this.connect("notify::focus-widget", this.onFocusWidgetChanged.bind(this)); - - // Update back button visibility when layout changes - this._layoutHost.connect("notify::visible-child-name", () => { - this.updateLearnBackButtonVisibility(); - }); - - // Update back button visibility when learn navigation changes - this._learn.events.on("subpage-changed", () => { - this.updateLearnBackButtonVisibility(); }); - - // The examples page offers to share one's own example - this._learn.events.on("share", () => { - this.shareAction.activate(null); - }); - } - - private onStackVisibleChildChanged(): void { - const currentChild = this._stack.get_visible_child(); - - // Update back button visibility when switching views - this.updateLearnBackButtonVisibility(); - - // Save scroll position when navigating away from Learn view - if (this.previousVisibleChild === this._learn && currentChild !== this._learn) { - this._learn.saveScrollPosition(); - } - - // Restore scroll position when returning to Learn view - // Only restore if we're coming from a different view - if (currentChild === this._learn && this.previousVisibleChild !== this._learn) { - // Make sure the Learn widget is properly mapped before restoring - if (this._learn.get_mapped()) { - this._learn.restoreScrollPosition(); - } else { - // Connect a one-time handler to restore after mapping - const handler = this._learn.connect("map", () => { - this._learn.restoreScrollPosition(); - try { - this._learn.disconnect(handler); - } catch (error) { - console.error("[MainWindow] Failed to disconnect handler", error); - } - }); - } - } - - // Auto-pause program when switching away from game console or debugger while program is running - if (this.previousVisibleChild === this._gameConsole || this.previousVisibleChild === this._debugger) { - // Check if simulator is in running state - const state = this._gameConsole.simulator.state; - if (state === SimulatorState.RUNNING) { - // Pause the program - this.pauseGameConsole(); - } - } - - // Update previous child - this.previousVisibleChild = currentChild; - - // Update main state controller with current view type - mainStateController.setViewType(this.activeView); - - if (currentChild === this._debugger) { - this.updateDebugger(); - } - } - - private onCloseRequest(): boolean { - // Check for unsaved changes before closing - if (this.unsavedChanges) { - this.showUnsavedChangesDialog("close"); - return true; // Block the close and handle it in the dialog response - } - - this._gameConsole.close(); - this._debugger.close(); - return false; - } - - private onFocusChanged(): void { - // Debounce to ignore transient focus changes from in-window popovers - this.handleFocusChangeDebounced(); - } - - private onFocusWidgetChanged(): void { - // Debounce in-window focus changes to avoid pausing on transient focus shifts - this.handleChildFocusChangeDebounced(); - } - - private widgetIsDescendantOf(widget: Gtk.Widget | null, ancestor: Gtk.Widget): boolean { - let current: Gtk.Widget | null = widget; - while (current) { - if (current === ancestor) return true; - current = current.get_parent() as Gtk.Widget | null; - } - return false; - } - - private setupActions(): void { - this.assembleAction.connect("activate", this.assembleGameConsole.bind(this)); - this.add_action(this.assembleAction); - - this.runSimulatorAction.connect("activate", this.runGameConsole.bind(this)); - this.add_action(this.runSimulatorAction); - - this.resumeSimulatorAction.connect("activate", this.runGameConsole.bind(this)); - this.add_action(this.resumeSimulatorAction); - - this.pauseSimulatorAction.connect("activate", this.pauseGameConsole.bind(this)); - this.add_action(this.pauseSimulatorAction); - - this.resetSimulatorAction.connect("activate", this.reset.bind(this)); - this.add_action(this.resetSimulatorAction); - - this.stepSimulatorAction.connect("activate", this.stepGameConsole.bind(this)); - this.add_action(this.stepSimulatorAction); - - this.shareAction.connect("activate", this.showShareDialog.bind(this)); - this.add_action(this.shareAction); - } - - private setupFileActions(): void { - // Open file action - this.openFileAction.connect("activate", this.openFile.bind(this)); - this.add_action(this.openFileAction); - - // Save file action - this.saveFileAction.connect("activate", this.saveFile.bind(this)); - this.add_action(this.saveFileAction); - - // Save as file action - this.saveAsFileAction.connect("activate", this.saveAsFile.bind(this)); - this.add_action(this.saveAsFileAction); - - // Set keyboard shortcuts - const app = this.get_application(); - if (app) { - app.set_accels_for_action(`win.${this.openFileAction.get_name()}`, ["o"]); - app.set_accels_for_action(`win.${this.saveFileAction.get_name()}`, ["s"]); - app.set_accels_for_action(`win.${this.saveAsFileAction.get_name()}`, ["s"]); - } - - // Connect unsaved changes dialog responses - this._unsavedChangesDialog.connect("response", this.onUnsavedChangesResponse.bind(this)); - } - - private setupHelpActions(): void { - this.showHelpAction.connect("activate", this.showHelp.bind(this)); - this.add_action(this.showHelpAction); - } - - private setupSingleStackPages(): void { - // Ensure views are detached from any current parent before adding to stack - this.detachFromParent(this._learn); - this.detachFromParent(this._editor); - this.detachFromParent(this._debugger); - this.detachFromParent(this._gameConsole); - - // Remove existing pages - const model: Gtk.SelectionModel = this._stack.get_pages(); - if (model) { - const toRemove: Gtk.Widget[] = []; - const n: number = model.get_n_items?.() ?? 0; - for (let i = 0; i < n; i++) { - const page = model.get_item(i) as Gtk.StackPage; - const child = page.get_child(); - if (child) toRemove.push(child); - } - for (const child of toRemove) this._stack.remove(child); - } - - // Add four pages - this._stack.add_titled_with_icon( - this._learn, - "learn", - // TRANSLATORS: ViewSwitcher/tab title for the Learn/Tutorial view - _("Learn"), - "school-symbolic" - ); - this._stack.add_titled_with_icon( - this._editor, - "editor", - // TRANSLATORS: ViewSwitcher/tab title for the Editor view - _("Code"), - "code-symbolic" - ); - this._stack.add_titled_with_icon( - this._debugger, - "debugger", - // TRANSLATORS: ViewSwitcher/tab title for the Debugger view - _("Debug"), - "bug-symbolic" - ); - this._stack.add_titled_with_icon( - this._gameConsole, - "gameConsole", - // TRANSLATORS: ViewSwitcher/tab title for the Game Console view - _("Play"), - "nintendo-controller-symbolic" - ); - } - - // Mount single layout: all views in one PageStack - private mountSingleLayout(): void { - this.setupSingleStackPages(); - this._switcherBar.set_stack(this._stack); - - // Enable debugger's own scrollbar in single layout mode - this._debugger.scrollable = true; - } - - // Mount three-column layout: Learn | Editor | (GameConsole over Debugger in scrollable container) - private mountThreeColumnLayout(): void { - // Clear containers - this.clearBoxChildren(this._leftColumn); - this.clearBoxChildren(this._centerColumn); - this.clearBoxChildren(this._rightTopBox); - this.clearBoxChildren(this._rightBottomBox); - - // Left: Learn - this.detachFromParent(this._learn); - this._leftColumn.append(this._learn); - // Center: Editor - this.detachFromParent(this._editor); - this._centerColumn.append(this._editor); - // Right: Game Console and Debugger in scrollable container - this.detachFromParent(this._gameConsole); - this._rightTopBox.append(this._gameConsole); - this.detachFromParent(this._debugger); - this._rightBottomBox.append(this._debugger); - - // Disable debugger's own scrollbar since it's now in a scrollable container - this._debugger.scrollable = false; - - // Detach bottom switcher in three-column mode - this._switcherBar.set_stack(null as unknown as Adw.ViewStack); - } - - private clearBoxChildren(box: Gtk.Box): void { - let child = box.get_first_child(); - while (child) { - const next = child.get_next_sibling(); - box.remove(child); - child = next; - } - } - - private detachFromParent(widget: Gtk.Widget): void { - const parent = widget.get_parent(); - if (parent && (parent as Gtk.Box | Gtk.Grid | Gtk.Fixed | Gtk.Stack).remove) { - (parent as Gtk.Box | Gtk.Grid | Gtk.Fixed | Gtk.Stack).remove(widget); - } - } - - private showHelp(): void { - const helpWindow = new HelpWindow(); - helpWindow.present(); - } - - private setupMainButton(): void { - // Initial button setup - this.updateRunActions(this._gameConsole.simulator.state); - } - - public runGameConsole(): void { - // Use platform-independent navigation logic - const targetView = mainStateController.getNavigationTarget("run"); - if (targetView && targetView !== this.activeView) { - this.navigateToView(targetView); - } - // Ensure stepper mode is disabled when running continuously - if (debuggerController.stepperEnabled) { - debuggerController.stepperEnabled = false; - } - this._gameConsole.run(); - // Ensure the game console receives focus so keyboard/gamepad input works immediately - // Debounce focus slightly to avoid pausing due to transient toolbar focus - this.lastRunAtMs = Date.now(); - this._gameConsole.focus(); - if (this.focusSourceId) { - GLib.source_remove(this.focusSourceId); - this.focusSourceId = null; - } - this.focusSourceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 150, () => { - this._gameConsole.focus(); - this.focusSourceId = null; - return GLib.SOURCE_REMOVE; - }); - } - - public pauseGameConsole(): void { - this._gameConsole.stop(); - this.showToast({ - // TRANSLATORS: Toast message title shown when pausing the game console - title: _("Program paused"), - timeout: 2, - }); - } - - public reset(): void { - this._gameConsole.reset(); - this._debugger.reset(); - } - - public assembleGameConsole(): void { - this._debugger.reset(); - this.navigateToView(ViewType.DEBUGGER); - - // Reset the code changed flag BEFORE assembling using mainStateController - mainStateController.setCodeChanged(false); - this._gameConsole.assemble(editorController.code); - } - - public setEditorCode(code: string): void { - editorController.setCode(code); - // Set the editor as the visible child in the stack - this.navigateToView(ViewType.EDITOR); - - // Reset the code changed flag after setting code using mainStateController - mainStateController.setCodeChanged(false); - this.unsavedChanges = false; - } - - private showToast(params: Partial): void { - notificationService.showNotification({ - title: params.title || "", - timeout: params.timeout || 2, - }); - } - - private updateDebugger(): void { - // Update debugger when visible: either active page in mobile, or desktop mode - if (this.isDesktopMode() || this._stack.get_visible_child() === this._debugger) { - this._debugger.update(this._gameConsole.memory, this._gameConsole.simulator); - } - } - - /** Notification key to translated GNOME title mapping */ - private static readonly NOTIFICATION_TITLES: Record string> = { - // TRANSLATORS: Toast message title after successful assembly - "assembled-successfully": () => _("Assembled successfully"), - // TRANSLATORS: Toast message title after assembly failure - "assemble-failed": () => _("Assemble failed"), - // TRANSLATORS: Toast message title when simulator reports a failure - "simulator-failure": () => _("Simulator failure"), - // TRANSLATORS: Toast message title when labels processing fails - "labels-failure": () => _("Labels failure"), - // TRANSLATORS: Toast message title when the program has finished execution - "program-completed": () => _("Program completed"), - }; - - private setupGameConsoleBridge(): void { - this.gameConsoleBridge = new GameConsoleEventBridge({ - formatAndLog: (message, params) => { - // GNOME: use gettext formatting for translated messages - const formattedParams = params || []; - this._debugger.log(_(message).format(...formattedParams)); - }, - updateDebugger: () => { - this.updateDebugger(); - }, - updateAssemblerViews: (assembler) => { - this._debugger.updateHexdump(this._gameConsole.assembler); - this._debugger.updateDisassembled(this._gameConsole.assembler); - }, - updateUiState: () => { - this.updateRunActions(this._gameConsole.simulator.state); - }, - showNotification: (key) => { - const titleFn = MainWindow.NOTIFICATION_TITLES[key]; - this.showToast({ - title: titleFn ? titleFn() : key, - timeout: 2, - }); - }, - }); - this.gameConsoleBridge.connect(); + dialog.present(this); } private setupKeyboardListener(): void { - // Platform-specific key controller for GNOME const keyController = new Gtk.EventControllerKey(); this.add_controller(keyController); - keyController.connect("key-pressed", (_controller: any, keyval: number, keycode: number, state: any) => { + keyController.connect("key-pressed", (_controller: Gtk.EventControllerKey, keyval: number) => { return gameConsoleController.handleKeyPress(keyval); }); - // Register platform-specific keycodes gameConsoleController.registerKeyMappings({ [Gdk.KEY_w]: "Up", [Gdk.KEY_s]: "Down", @@ -747,240 +192,6 @@ export class MainWindow extends Adw.ApplicationWindow implements MainView { [Gdk.KEY_space]: "B", [Gdk.KEY_e]: "B", }); - - // Event listener for gamepad inputs - gameConsoleController.on("keyPressed", (event) => { - // If we're in the game console or debugger view, log the key press - const visibleChild = this._stack.get_visible_child(); - if (visibleChild === this._gameConsole || visibleChild === this._debugger) { - this._debugger.log( - // TRANSLATORS: Debugger log message when gamepad key is pressed - _("Gamepad key pressed:") + " $" + num2hex(event.keyCode).toUpperCase() - ); - } - }); - } - - private updateRunActions(state: SimulatorState): MainButtonState { - // Check if editor has code - const hasCode = editorController.hasCode; - - // Get enabled states for actions from MainButton helper - const enabledState = mainStateController.getActionEnabledState(state, hasCode, this.codeToAssembleChanged); - - // Set enabled state for all actions - this.assembleAction.set_enabled(enabledState.assemble); - this.runSimulatorAction.set_enabled(enabledState.run); - this.resumeSimulatorAction.set_enabled(enabledState.resume); - this.pauseSimulatorAction.set_enabled(enabledState.pause); - this.resetSimulatorAction.set_enabled(enabledState.reset); - this.stepSimulatorAction.set_enabled(enabledState.step); - this.shareAction.set_enabled(enabledState.share); - - // Update the button state based on simulator state and current view - return this._mainButton.updateFromSimulatorState(state); - } - - public stepGameConsole(): void { - // Use platform-independent navigation logic - const targetView = mainStateController.getNavigationTarget("step"); - if (targetView && targetView !== this.activeView) { - this.navigateToView(targetView); - } - - // Enable stepper using debugger controller (this will sync UI properly) - if (!debuggerController.stepperEnabled) { - debuggerController.stepperEnabled = true; - } - - // Execute a single step - this._gameConsole.simulator.debugExecStep(); - - // Update the UI - this.updateDebugger(); - this.updateRunActions(this._gameConsole.simulator.state); - } - - private async openFile(): Promise { - // Check for unsaved changes first - if (this.unsavedChanges) { - this.showUnsavedChangesDialog("open"); - return; - } - - const result = await fileService.openFile(); - if (result) { - this.setEditorCode(result.content); - this.currentFile = fileService.getCurrentGioFile() || null; - this._titleLabel.label = result.filename; - this.showToast({ - // TRANSLATORS: Toast message title after opening a file, includes filename - title: _("Opened %s").format(result.filename), - timeout: 2, - }); - } - } - - private async saveFile(): Promise { - if (this.currentFile) { - return (await fileService.saveFile(editorController.code)) || false; - } else { - return await this.saveAsFile(); - } - } - - private getCurrentFileName(): string { - return fileService.getCurrentFileName() || ""; - } - - private async saveAsFile(): Promise { - const success = await fileService.saveFileAs( - editorController.code, - this.currentFile ? this.getCurrentFileName() : "untitled.asm" - ); - if (success) { - this.currentFile = fileService.getCurrentGioFile() || null; - this._titleLabel.label = this.getCurrentFileName(); - this.showToast({ - // TRANSLATORS: Toast message title after saving file successfully, includes filename - title: _("Saved as %s").format(this.getCurrentFileName()), - timeout: 2, - }); - } - return success || false; - } - - private showShareDialog(): void { - const dialog = new ShareDialog(); - - // Set the current code - dialog.setCode(editorController.code); - - // Set the current memory state - dialog.setMemory(this._gameConsole.memory); - - // Handle submit event - dialog.connect("submit", (submitted: ShareDialog) => { - console.log("Example submitted:", submitted.submittedExample); - // TODO: Implement GitHub PR creation logic here - this.showToast({ - // TRANSLATORS: Toast message when example is submitted - title: _("Example submitted for review"), - timeout: 3, - }); - }); - - // Present the dialog - dialog.present(this); - } - - private showUnsavedChangesDialog(action: "open" | "close"): void { - this.pendingDialogAction = action; - this._unsavedChangesDialog.present(this); - } - - private onUnsavedChangesResponse(dialog: Adw.AlertDialog, response: string): void { - switch (response) { - case "save": - // Save file then continue with pending action - this.saveFile().then((success) => { - if (success && this.pendingDialogAction === "open") { - // After saving, continue with opening new file - this.openFile(); - } else if (success && this.pendingDialogAction === "close") { - // After saving, continue with closing - this.close(); - } - }); - break; - case "discard": - // Discard changes and continue with pending action - this.unsavedChanges = false; - if (this.pendingDialogAction === "open") { - this.openFile(); - } else if (this.pendingDialogAction === "close") { - this.close(); - } - break; - case "cancel": - default: - // Do nothing, cancel the operation - break; - } - this.pendingDialogAction = null; - } - - private setupThemeManagement(): void { - // Register this window to receive mode/accent classes automatically - themeService.registerThemedWidget(this); - } - - private setupMainStateEventListeners(): void { - // Listen for state changes - mainStateController.events.on("state-changed", (state) => { - // React on main state changes by updating run actions - this.updateRunActions(this._gameConsole.simulator.state); - }); - - // Listen for code changed events - mainStateController.events.on("state-changed:code-changed", (changed) => { - this.codeToAssembleChanged = changed; - this.updateRunActions(this._gameConsole.simulator.state); - }); - } - - private loadWindowSize(): void { - try { - const width = settings.get_int("window-width"); - const height = settings.get_int("window-height"); - const isMaximized = settings.get_boolean("is-maximized"); - - if (isMaximized) { - this.maximize(); - } else { - this.set_default_size(width, height); - } - } catch (error) { - console.warn("Could not load window size from settings:", error); - // Fallback to Blueprint defaults - } - } - - private setupWindowSizeSaving(): void { - // Save window size when it changes - this.connect("notify::default-width", () => this.saveWindowSize()); - this.connect("notify::default-height", () => this.saveWindowSize()); - - // Save maximized state - this.connect("notify::maximized", () => { - const isMaximized = this.is_maximized(); - settings.set_boolean("is-maximized", isMaximized); - }); - } - - private saveWindowSize(): void { - if (this.is_maximized()) { - return; // Don't save size when maximized - } - - try { - const [width, height] = this.get_default_size(); - settings.set_int("window-width", width); - settings.set_int("window-height", height); - } catch (error) { - console.warn("Could not save window size to settings:", error); - } - } - - /** - * Close the sidebar if it's in overlay mode (collapsed) - * This provides better UX when copying code to editor - */ - private closeSidebarIfOverlay(): void { - // Only close sidebar if we're in desktop mode and sidebar is collapsed (overlay mode) - if (this.isDesktopMode() && this._leftSidebar.collapsed) { - this._leftSidebar.show_sidebar = false; - } } } diff --git a/packages/app-web/src/app/file.service.ts b/packages/app-web/src/app/file.service.ts new file mode 100644 index 00000000..a681b6b7 --- /dev/null +++ b/packages/app-web/src/app/file.service.ts @@ -0,0 +1,111 @@ +import { Gio, Gtk } from "@gjsify/adwaita-web"; +import { FileService as BaseFileService } from "@learn6502/common-ui"; +import { _ } from "@learn6502/ui"; + +/** What the web door's `Gtk.FileDialog` and `Gio.File` give back; their typings still list the callback forms. */ +interface PickedFile { + get_basename(): string; + load_contents_async(cancellable: null): Promise<[Uint8Array, string | null]>; + replace_contents_async( + contents: Uint8Array, + etag: null, + makeBackup: boolean, + flags: number, + cancellable: null + ): Promise<[boolean, string | null]>; +} + +interface PromisedFileDialog { + open(parent: null, cancellable: null): Promise; + save(parent: null, cancellable: null): Promise; +} + +/** + * Opens and saves through `Gtk.FileDialog`, which the browser answers with the system file pickers where + * it has them and with an in-page dialog over the origin's private storage where it does not. + */ +export class FileService extends BaseFileService { + private currentFile: PickedFile | null = null; + + public async openFile(): Promise<{ content: string; filename: string } | null> { + try { + const file = await this.createDialog(_("Open Assembly File")).open(null, null); + const [contents] = await file.load_contents_async(null); + this.currentFile = file; + this.currentFileName = file.get_basename(); + return { content: new TextDecoder().decode(contents), filename: this.currentFileName }; + } catch (error) { + // Dismissing the dialog is a refusal, not a failure worth reporting + if (!isDismissed(error)) console.error("Error opening file:", error); + return null; + } + } + + public async saveFileAs(content: string, suggestedName?: string): Promise { + try { + const file = await this.createDialog(_("Save Assembly File"), suggestedName).save(null, null); + if (!(await this.write(file, content))) return false; + this.currentFile = file; + this.currentFileName = file.get_basename(); + this.setUnsavedChanges(false); + return true; + } catch (error) { + if (!isDismissed(error)) console.error("Error in save as:", error); + return false; + } + } + + protected async saveToCurrentFile(content: string): Promise { + return this.currentFile ? this.write(this.currentFile, content) : false; + } + + public hasCurrentFile(): boolean { + return this.currentFile !== null; + } + + private async write(file: PickedFile, content: string): Promise { + try { + const [ok] = await file.replace_contents_async(new TextEncoder().encode(content), null, false, 0, null); + return ok; + } catch (error) { + console.error("Error saving file:", error); + return false; + } + } + + private createDialog(title: string, initialName?: string): PromisedFileDialog { + const filters = new Gio.ListStore({ item_type: Gtk.FileFilter.$gtype }); + + const asmFilter = Gtk.FileFilter.new(); + // TRANSLATORS: File filter name for assembly source files + asmFilter.set_name(_("Assembly Files")); + asmFilter.add_pattern("*.asm"); + asmFilter.add_pattern("*.s"); + + const allFilter = Gtk.FileFilter.new(); + // TRANSLATORS: File filter name for all file types + allFilter.set_name(_("All Files")); + allFilter.add_pattern("*"); + + filters.append(asmFilter); + filters.append(allFilter); + + const dialog = new Gtk.FileDialog({ + title, + modal: true, + filters, + initial_name: initialName || this.getCurrentFileName(), + }); + // The typings keep GIR's callback signatures; the web door promisifies them. + return dialog as unknown as PromisedFileDialog; + } +} + +function isDismissed(error: unknown): boolean { + return ( + (error as { matches?: (domain: unknown, code: number) => boolean }).matches?.( + Gtk.DialogError, + Gtk.DialogError.DISMISSED + ) === true + ); +} diff --git a/packages/app-web/src/app/main-window.shell.ts b/packages/app-web/src/app/main-window.shell.ts new file mode 100644 index 00000000..ba3591c3 --- /dev/null +++ b/packages/app-web/src/app/main-window.shell.ts @@ -0,0 +1,367 @@ +import type GjsGtk from "@girs/gtk-4.0"; +import { Adw, type Gtk } from "@gjsify/adwaita-web"; +import { ViewType, mainStateController } from "@learn6502/common-ui"; +import { _, type Debugger, type Editor, type GameConsole, type Learn } from "@learn6502/ui"; +import type { MainWindowShell, ShellAction, UnsavedChangesAnswer } from "@learn6502/ui"; + +/** The widgets `MainWindow` builds around the four screens. */ +export interface DomShellParts { + window: Adw.Window; + learn: Learn; + editor: Editor; + debugger: Debugger; + gameConsole: GameConsole; + switcherBar: Adw.ViewSwitcherBar; + sidebarToggle: Gtk.Button; + learnBackButton: Gtk.Button; + runControls: HTMLElement; + titleWidget: Adw.WindowTitle; +} + +/** The four screens in stack order, with their tab title and icon. */ +const SCREENS = [ + { view: ViewType.LEARN, name: "learn", title: () => _("Learn"), icon: "go-home" }, + { view: ViewType.EDITOR, name: "editor", title: () => _("Code"), icon: "document-edit" }, + { view: ViewType.DEBUGGER, name: "debugger", title: () => _("Debug"), icon: "system-search" }, + { view: ViewType.GAME_CONSOLE, name: "gameConsole", title: () => _("Play"), icon: "application-x-executable" }, +] as const; + +type ScreenName = (typeof SCREENS)[number]["name"]; + +/** `s` against a key event. */ +function matchesAccelerator(event: KeyboardEvent, accelerator: string): boolean { + const key = accelerator.replace(/<[^>]+>/g, "").toLowerCase(); + return ( + event.key.toLowerCase() === key && + event.ctrlKey === accelerator.includes("") && + event.shiftKey === accelerator.includes("") && + event.altKey === accelerator.includes("") + ); +} + +/** + * The slice of `Gtk.Window` the shared window behaviour reads, answered by the document. `close-request` + * has no listener: `Adw.Window` on the web has no close request to hook (a core gap in gjsify's + * adwaita-web, not worked around here), so the shell sets `emitsCloseRequest = false`. + */ +class DocumentWindow { + private readonly events: Record = { + "notify::is-active": ["focus", "blur"], + "notify::focus-widget": ["focusin", "focusout"], + }; + + get is_active(): boolean { + return document.hasFocus(); + } + + get_focus(): Element | null { + return document.activeElement; + } + + connect(signal: string, handler: () => void): number { + for (const type of this.events[signal] ?? []) window.addEventListener(type, handler, true); + return 0; + } +} + +/** + * `MainWindowShell` over the DOM: a view stack on narrow viewports, three columns on wide ones, the + * switch being the `matchMedia` twin of the GNOME window's `Adw.Breakpoint`s. + */ +export class DomMainWindowShell implements MainWindowShell { + readonly window: GjsGtk.Window; + readonly emitsCloseRequest = false; + /** The element to put into the toast overlay. */ + readonly layoutHost = document.createElement("div"); + + private readonly mobileLayout = document.createElement("div"); + private readonly desktopLayout = document.createElement("div"); + private readonly splitView = new Adw.OverlaySplitView(); + private readonly sidebarColumn = document.createElement("div"); + private readonly centerColumn = document.createElement("div"); + private readonly rightTop = document.createElement("div"); + private readonly rightBottom = document.createElement("div"); + + // Mirror the GNOME window's breakpoint thresholds + private readonly desktopMq = window.matchMedia("(min-width: 800px) and (min-height: 600px)"); + private readonly wideMq = window.matchMedia("(min-width: 1000px) and (min-height: 600px)"); + + // Persistent per-screen wrappers, re-homed between the two layouts + private readonly slots: Record; + private mobileStack: Adw.ViewStack | null = null; + + private current: ViewType = ViewType.LEARN; + private onViewChanged: ((view: ViewType, previous: ViewType) => void) | null = null; + private onLayout: (() => void) | null = null; + + private readonly handlers = new Map void>(); + private readonly enabled = new Map(); + private readonly accelerators: { accelerator: string; name: string }[] = []; + private actionsChanged: (() => void) | null = null; + private dialog: Adw.AlertDialog | null = null; + + constructor(private readonly parts: DomShellParts) { + // Only the members the window behaviour reads exist, hence the cast + this.window = new DocumentWindow() as unknown as GjsGtk.Window; + + const slotOf = (screen: unknown): HTMLDivElement => { + const slot = document.createElement("div"); + slot.className = "shell-view-slot"; + // The shared widgets are typed against the GJS bindings; at run time they are custom elements. + const element = screen as HTMLElement; + element.classList.add("shell-view-slot"); + slot.appendChild(element); + return slot; + }; + this.slots = { + learn: slotOf(parts.learn), + editor: slotOf(parts.editor), + debugger: slotOf(parts.debugger), + gameConsole: slotOf(parts.gameConsole), + }; + + this.buildLayoutHost(); + parts.sidebarToggle.addEventListener("click", () => this.splitView.toggleSidebar()); + this.layoutHost.addEventListener("notify::visible-child", (event) => { + const name = (event as CustomEvent<{ name: string }>).detail.name; + const screen = SCREENS.find((candidate) => candidate.name === name); + if (screen) this.changeTo(screen.view); + }); + this.desktopMq.addEventListener("change", () => this.relayout()); + this.wideMq.addEventListener("change", () => this.relayout()); + document.addEventListener("keydown", (event) => this.onKeyDown(event)); + } + + // --- Layout --- + + isDesktopLayout(): boolean { + return this.desktopMq.matches; + } + + onLayoutChanged(handler: () => void): void { + this.onLayout = handler; + } + + /** Mount the layout for the viewport. The tree must be connected: a view stack rebuilds its pages on connect. */ + mountLayout(): void { + if (this.isDesktopLayout()) this.mountThreeColumnLayout(); + else this.mountSingleLayout(); + } + + private relayout(): void { + this.mountLayout(); + this.onLayout?.(); + } + + private buildLayoutHost(): void { + this.layoutHost.id = "layout-host"; + this.mobileLayout.id = "mobile-layout"; + this.desktopLayout.id = "desktop-layout"; + + // Desktop split view: Learn sidebar | (Editor | GameConsole-over-Debugger) + this.splitView.setAttribute("min-sidebar-width", "260"); + this.splitView.setAttribute("max-sidebar-width", "420"); + this.splitView.setAttribute("sidebar-width-fraction", "0.25"); + + this.sidebarColumn.className = "shell-sidebar-column"; + this.sidebarColumn.setAttribute("slot", "sidebar"); + + const centerRight = document.createElement("div"); + centerRight.className = "shell-center-right"; + centerRight.setAttribute("slot", "content"); + + this.centerColumn.className = "shell-center-column"; + this.rightTop.className = "shell-right-top"; + this.rightBottom.className = "shell-right-bottom"; + const rightColumn = document.createElement("div"); + rightColumn.className = "shell-right-column"; + rightColumn.append(this.rightTop, this.rightBottom); + centerRight.append(this.centerColumn, rightColumn); + + this.splitView.append(this.sidebarColumn, centerRight); + this.desktopLayout.appendChild(this.splitView); + + this.layoutHost.append(this.mobileLayout, this.desktopLayout); + } + + private mountSingleLayout(): void { + const stack = new Adw.ViewStack(); + // Connect first (empty), THEN add pages: connecting rebuilds from declared children and would wipe pre-adds + this.mobileLayout.replaceChildren(stack); + for (const screen of SCREENS) { + this.resetSlot(this.slots[screen.name]); + stack.add(this.slots[screen.name], screen.name, screen.title(), screen.icon); + } + stack.visibleChildName = SCREENS.find((screen) => screen.view === this.current)?.name ?? "editor"; + this.mobileStack = stack; + + const { switcherBar, sidebarToggle, runControls } = this.parts; + switcherBar.setStack(stack); + switcherBar.reveal = true; + sidebarToggle.hidden = true; + // The run controls are desktop-only; the phone layout gets the main button + runControls.hidden = true; + + this.mobileLayout.hidden = false; + this.desktopLayout.hidden = true; + } + + private mountThreeColumnLayout(): void { + this.homeSlot(this.slots.learn, this.sidebarColumn); + this.homeSlot(this.slots.editor, this.centerColumn); + this.homeSlot(this.slots.gameConsole, this.rightTop); + this.homeSlot(this.slots.debugger, this.rightBottom); + + const { switcherBar, sidebarToggle, runControls } = this.parts; + switcherBar.setStack(null); + switcherBar.reveal = false; + this.mobileStack = null; + this.mobileLayout.replaceChildren(); + + sidebarToggle.hidden = false; + runControls.hidden = false; + // Wide windows dock the sidebar; medium ones overlay it + const wide = this.wideMq.matches; + this.splitView.collapsed = !wide; + this.splitView.showSidebar = wide; + + this.desktopLayout.hidden = false; + this.mobileLayout.hidden = true; + + this.current = ViewType.EDITOR; + mainStateController.setViewType(ViewType.EDITOR); + } + + private homeSlot(slot: HTMLDivElement, parent: HTMLElement): void { + this.resetSlot(slot); + parent.appendChild(slot); + } + + private resetSlot(slot: HTMLDivElement): void { + slot.hidden = false; + slot.classList.remove("adw-view-stack-child", "active-view"); + } + + // --- Views --- + + activeView(): ViewType { + return this.current; + } + + showView(view: ViewType): void { + const screen = SCREENS.find((candidate) => candidate.view === view); + if (!screen || !this.mobileStack) return; + this.mobileStack.visibleChildName = screen.name; + this.changeTo(view); + } + + onActiveViewChanged(handler: (view: ViewType, previous: ViewType) => void): void { + this.onViewChanged = handler; + } + + private changeTo(view: ViewType): void { + const previous = this.current; + this.current = view; + if (view !== previous) { + // Preserve the reading position and the editor state across tab switches + if (previous === ViewType.LEARN) this.parts.learn.saveScrollPosition(); + if (previous === ViewType.EDITOR) this.parts.editor.saveState(); + if (view === ViewType.LEARN) this.parts.learn.restoreScrollPosition(); + } + this.onViewChanged?.(view, previous); + } + + setLearnBackVisible(visible: boolean): void { + this.parts.learnBackButton.hidden = !visible; + } + + setFileTitle(filename: string): void { + this.parts.titleWidget.setAttribute("subtitle", filename); + } + + closeSidebarIfOverlay(): void { + if (this.isDesktopLayout() && this.splitView.collapsed) this.splitView.showSidebar = false; + } + + isInsideConsole(widget: GjsGtk.Widget | null): boolean { + return !!widget && (this.parts.gameConsole as unknown as Node).contains(widget as unknown as Node); + } + + // --- Actions --- + + addAction(name: string, activate: () => void, accels: string[] = []): ShellAction { + this.handlers.set(name, activate); + this.enabled.set(name, true); + for (const accelerator of accels) { + this.accelerators.push({ accelerator, name }); + } + return { + setEnabled: (enabled) => { + this.enabled.set(name, enabled); + this.actionsChanged?.(); + }, + }; + } + + isActionEnabled(name: string): boolean { + return this.enabled.get(name) ?? false; + } + + /** Fire an action as its button, menu item or shortcut would. */ + activate(name: string): void { + if (this.isActionEnabled(name)) this.handlers.get(name)?.(); + } + + onActionsChanged(handler: () => void): void { + this.actionsChanged = handler; + } + + private onKeyDown(event: KeyboardEvent): void { + for (const { accelerator, name } of this.accelerators) { + if (!matchesAccelerator(event, accelerator)) continue; + // The browser's own Ctrl+S / Ctrl+O would otherwise take the key + event.preventDefault(); + this.activate(name); + return; + } + } + + // --- Dialog, closing --- + + askUnsavedChanges(): Promise { + const dialog = this.dialog ?? this.createUnsavedChangesDialog(); + return new Promise((resolve) => { + dialog.addEventListener( + "response", + (event) => { + const response = (event as CustomEvent<{ response: string }>).detail.response; + resolve(response === "save" || response === "discard" ? response : "cancel"); + }, + { once: true } + ); + dialog.present(); + }); + } + + private createUnsavedChangesDialog(): Adw.AlertDialog { + const dialog = new Adw.AlertDialog(); + // TRANSLATORS: Alert dialog heading when unsaved changes are present + dialog.heading = _("Save changes?"); + // TRANSLATORS: Alert dialog body explaining risk of losing changes + dialog.body = _("Your file has unsaved changes. Changes will be lost if you don't save them."); + // TRANSLATORS: Alert dialog button labels + dialog.addResponse("cancel", _("Cancel")); + dialog.addResponse("discard", _("Discard")); + dialog.addResponse("save", _("Save")); + dialog.setResponseAppearance("discard", "destructive"); + dialog.setResponseAppearance("save", "suggested"); + dialog.closeResponse = "cancel"; + this.parts.window.appendChild(dialog); + this.dialog = dialog; + return dialog; + } + + closeWindow(): void { + window.close(); + } +} diff --git a/packages/app-web/src/app/main-window.ts b/packages/app-web/src/app/main-window.ts index d4e339eb..8896f4cb 100644 --- a/packages/app-web/src/app/main-window.ts +++ b/packages/app-web/src/app/main-window.ts @@ -1,62 +1,33 @@ /** * MainWindow — the app-web Adwaita SPA shell. * - * Web counterpart of the GNOME `MainWindow` (`app-gnome/src/views/main.window`). - * It reproduces that window's structure and navigation semantics on - * `@gjsify/adwaita-web`, driven by the SAME shared `@learn6502/common-ui` - * controllers/services the GNOME and Android apps use — nothing in common-ui is - * reimplemented here. + * Web counterpart of the GNOME `MainWindow` (`app-gnome/src/views/main.window`). The window's logic — + * navigation, the run/step/assemble flow, files and the unsaved-changes guard — is the shared + * `MainWindowController`; this file builds the chrome on `@gjsify/adwaita-web` and injects what the + * browser provides (file pickers, clipboard, toasts). The layout and the actions live in + * `DomMainWindowShell`. * * Structure (mapped to app-gnome's `main.window.blp`): * Adw.ToolbarView * [top] Adw.HeaderBar — window title, sidebar toggle, learn-back button, * run controls, primary Gtk.MenuButton - * content Adw.ToastOverlay > #layout-host, which holds EITHER - * • the mobile single Adw.ViewStack (one view at a time), or - * • the desktop Adw.OverlaySplitView (Learn sidebar | Editor | - * GameConsole-over-Debugger), mirroring the Gtk.Stack - * "single"/"three" layoutHost swap + * content Adw.ToastOverlay > #layout-host (the shell's single / three-column layouts) * [bottom] Adw.ViewSwitcherBar — mobile tab bar bound to the stack - * - * The Adw.Breakpoint that drives app-gnome's layout has no web primitive, so the - * single ⇄ three swap is a `matchMedia` query mirroring its 800×600 / 1000×600 - * thresholds. - * - * All four views are the shared `@learn6502/ui` widgets (Learn, Editor, Debugger, GameConsole). */ import { Adw, Gtk } from "@gjsify/adwaita-web"; -import { - DebuggerState, - GameConsoleEventBridge, - type MainButtonActionState, - type MainView, - ViewType, - debuggerController, - editorController, - gameConsoleController, - learnController, - mainStateController, -} from "@learn6502/common-ui"; -import { Assembler, Labels, Memory, Simulator, SimulatorState, formatMessage } from "@learn6502/core"; +import { ViewType, editorController, mainStateController } from "@learn6502/common-ui"; +import { Debugger, Editor, GameConsole, Learn, MainWindowController, type MainWindowServices } from "@learn6502/ui"; import "../widgets/learn/index.js"; // registers `$TutorialView` for the shared learn.blp import "../widgets/quick-help.js"; // registers `$QuickHelpView` for the shared editor.blp -import { Debugger, Editor, GameConsole, Learn } from "@learn6502/ui"; +import { FileService } from "./file.service.js"; +import { DomMainWindowShell } from "./main-window.shell.js"; import { injectShellStyles } from "./styles.js"; /** Learn6502 project repository — the "share your example" destination. */ const SHARE_URL = "https://github.com/JumpLink/Learn6502"; -/** Notification key → toast title (the GNOME twin's NOTIFICATION_TITLES, English only). */ -const NOTIFICATION_TITLES: Record = { - "assembled-successfully": "Assembled successfully", - "assemble-failed": "Assemble failed", - "simulator-failure": "Simulator failure", - "labels-failure": "Labels failure", - "program-completed": "Program completed", -}; - /** The starter program the Editor opens with — Assemble, then Run or Step. */ const DEMO_PROGRAM = `; Fill the display page at $0200 with random colors. ; Assemble, then Run or Step to watch the debugger update. @@ -69,206 +40,117 @@ loop: BRK `; -/** Per-view descriptor for building the mobile stack + switcher pages. */ -interface ViewSlot { - type: ViewType; - name: string; - title: string; - icon: string; - element: HTMLDivElement; +/** A header button and the actions it stands for; it is enabled while any of them is. */ +interface RunButton { + button: Gtk.Button; + actions: string[]; } -export class MainWindow implements MainView { +export class MainWindow { /** The root element to mount into the document. */ public readonly root: Adw.Window; - // Core simulator objects (what GameConsole owns on every platform). - private readonly memory = new Memory(); - private readonly labels = new Labels(); - private readonly simulator = new Simulator(this.memory, this.labels); - private readonly assembler = new Assembler(this.memory, this.labels); - - // Every view is now real (Learn, Editor, Debugger, GameConsole). - private readonly debuggerView = new Debugger(); + private readonly learnView = new Learn(); private readonly editorView = new Editor(); + private readonly debuggerView = new Debugger(); private readonly gameConsoleView = new GameConsole(); - private readonly learnView = new Learn(); - // Persistent per-view slot wrappers, re-homed between the two layouts. - private readonly slots: ViewSlot[]; - private readonly learnSlot = this.makeSlot(); - private readonly editorSlot = this.makeSlot(); - private readonly debuggerSlot = this.makeSlot(); - private readonly gameConsoleSlot = this.makeSlot(); - - // Chrome. + private readonly fileService = new FileService(); private readonly toastOverlay = new Adw.ToastOverlay(); private readonly switcherBar = new Adw.ViewSwitcherBar(); private readonly titleWidget = new Adw.WindowTitle(); private readonly runControls = document.createElement("div"); private readonly sidebarToggle = new Gtk.Button(); private readonly learnBackButton = new Gtk.Button(); - private readonly assembleButton = new Gtk.Button(); - private readonly runButton = new Gtk.Button(); - private readonly pauseButton = new Gtk.Button(); - private readonly stepButton = new Gtk.Button(); - private readonly resetButton = new Gtk.Button(); - - // Layout hosts. - private readonly layoutHost = document.createElement("div"); - private readonly mobileLayout = document.createElement("div"); - private readonly desktopLayout = document.createElement("div"); - private readonly splitView = new Adw.OverlaySplitView(); - private readonly sidebarColumn = document.createElement("div"); - private readonly centerColumn = document.createElement("div"); - private readonly rightTop = document.createElement("div"); - private readonly rightBottom = document.createElement("div"); - - // Adaptive-layout media queries (mirror app-gnome's Adw.Breakpoint thresholds). - private readonly desktopMq = window.matchMedia("(min-width: 800px) and (min-height: 600px)"); - private readonly wideMq = window.matchMedia("(min-width: 1000px) and (min-height: 600px)"); - - // The live mobile stack (recreated on each switch to the single layout). - private mobileStack: Adw.ViewStack | null = null; + private readonly runButtons: RunButton[] = []; - // State. - private currentView: ViewType = ViewType.LEARN; - private codeChanged = false; - private bridge: GameConsoleEventBridge | null = null; + private readonly shell: DomMainWindowShell; + private controller: MainWindowController | null = null; constructor() { injectShellStyles(); - this.slots = [ - { type: ViewType.LEARN, name: "learn", title: "Learn", icon: "go-home", element: this.learnSlot }, - { type: ViewType.EDITOR, name: "editor", title: "Code", icon: "document-edit", element: this.editorSlot }, - { - type: ViewType.DEBUGGER, - name: "debugger", - title: "Debug", - icon: "system-search", - element: this.debuggerSlot, - }, - { - type: ViewType.GAME_CONSOLE, - name: "gameConsole", - title: "Play", - icon: "application-x-executable", - element: this.gameConsoleSlot, - }, - ]; - - this.buildViews(); - this.root = this.buildChrome(); - - this.setupControllers(); - this.setupAdaptiveLayout(); - this.seedDemoProgram(); + this.root = new Adw.Window(); + this.root.id = "application-window"; + this.shell = new DomMainWindowShell({ + window: this.root, + learn: this.learnView, + editor: this.editorView, + debugger: this.debuggerView, + gameConsole: this.gameConsoleView, + switcherBar: this.switcherBar, + sidebarToggle: this.sidebarToggle, + learnBackButton: this.learnBackButton, + runControls: this.runControls, + titleWidget: this.titleWidget, + }); + this.buildChrome(); } /** - * Attach the shell to the document, then mount the initial layout. The - * layout MUST be applied after the tree is connected: a freshly created - * Adw.ViewStack rebuilds its pages from declared children on connect, which - * would wipe imperatively-added pages if we mounted while detached. + * Attach the shell to the document, then wire the controller. The layout MUST be mounted after the + * tree is connected: a freshly created Adw.ViewStack rebuilds its pages from declared children on + * connect, which would wipe imperatively-added pages. */ public mount(parent: HTMLElement): void { parent.appendChild(this.root); - this.applyLayout(); - } - - // --- MainView interface (mirrors app-gnome's MainWindow) --- - - public get state(): SimulatorState { - return this.simulator.state; - } - public get activeView(): ViewType { - return this.currentView; - } - - /** Navigate to a view. In the desktop layout every view is visible, so this - * is a no-op there — exactly like the GNOME twin. */ - public navigateToView(viewType: ViewType): void { - this.currentView = viewType; - if (this.isDesktop()) { - if (viewType === ViewType.DEBUGGER) this.updateDebugger(); - return; - } - const slot = this.slots.find((s) => s.type === viewType); - if (slot && this.mobileStack) this.mobileStack.visibleChildName = slot.name; - } - - public assembleGameConsole(): void { - this.debuggerView.reset(); - this.navigateToView(ViewType.DEBUGGER); + editorController.setCode(DEMO_PROGRAM); mainStateController.setCodeChanged(false); - gameConsoleController.assemble(editorController.code); - } - public runGameConsole(): void { - const target = mainStateController.getNavigationTarget("run"); - if (target && target !== this.activeView) this.navigateToView(target); - if (debuggerController.stepperEnabled) debuggerController.stepperEnabled = false; - gameConsoleController.run(); - } - - public pauseGameConsole(): void { - gameConsoleController.stop(); - this.showToast("Program paused"); - } - - public reset(): void { - gameConsoleController.reset(); - this.debuggerView.reset(); - } - - public stepGameConsole(): void { - const target = mainStateController.getNavigationTarget("step"); - if (target && target !== this.activeView) this.navigateToView(target); - if (!debuggerController.stepperEnabled) debuggerController.stepperEnabled = true; - gameConsoleController.step(); - this.updateDebugger(); - this.updateRunActions(this.simulator.state); - } - - public setEditorCode(code: string): void { - editorController.setCode(code); - this.navigateToView(ViewType.EDITOR); - mainStateController.setCodeChanged(false); + const controller = new MainWindowController( + this.shell, + { + editor: this.editorView, + gameConsole: this.gameConsoleView, + learn: this.learnView, + debugger: this.debuggerView, + // The header carries the run controls, so there is no floating main button to update + mainButton: { updateFromSimulatorState: (state) => mainStateController.updateFromSimulatorState(state) }, + }, + this.createServices() + ); + this.controller = controller; + controller.init(); + this.fileService.setUnsavedChanges(false); + this.updateRunButtons(); + } + + private createServices(): MainWindowServices { + const files = this.fileService; + return { + files: { + openFile: () => files.openFile(), + saveFile: (content) => files.saveFile(content), + saveFileAs: (content, suggestedName) => files.saveFileAs(content, suggestedName), + getCurrentFileName: () => files.getCurrentFileName(), + hasCurrentFile: () => files.hasCurrentFile(), + hasUnsavedChanges: () => files.hasUnsavedChanges(), + setUnsavedChanges: (hasChanges) => files.setUnsavedChanges(hasChanges), + }, + // The browser tab owns its size, so there is no geometry to keep + settings: { getInt: () => 0, setInt: () => {}, getBoolean: () => false, setBoolean: () => {} }, + clipboard: { + copy: (text) => { + if (!navigator.clipboard) return false; + void navigator.clipboard.writeText(text); + return true; + }, + }, + notifier: { show: ({ title, timeout }) => this.toastOverlay.addToast(title, { timeout }) }, + // The Editor's 6502 quick-help sheet is the web app's help + showHelp: () => { + this.controller?.navigateToView(ViewType.EDITOR); + editorController.setHelpVisible(true); + }, + showShareDialog: () => window.open(SHARE_URL, "_blank", "noopener,noreferrer"), + submitExample: () => window.open(SHARE_URL, "_blank", "noopener,noreferrer"), + }; } // --- DOM construction --- - private makeSlot(): HTMLDivElement { - const div = document.createElement("div"); - div.className = "shell-view-slot"; - return div; - } - - private buildViews(): void { - // The shared widgets are typed against the GJS bindings; at run time they are custom elements. - const learnElement = this.learnView as unknown as HTMLElement; - learnElement.classList.add("shell-view-slot"); - this.learnSlot.appendChild(learnElement); - - const editorElement = this.editorView as unknown as HTMLElement; - editorElement.classList.add("shell-view-slot"); - this.editorSlot.appendChild(editorElement); - - const gameConsoleElement = this.gameConsoleView as unknown as HTMLElement; - gameConsoleElement.classList.add("shell-view-slot"); - this.gameConsoleSlot.appendChild(gameConsoleElement); - - const debuggerElement = this.debuggerView as unknown as HTMLElement; - debuggerElement.classList.add("shell-view-slot"); - this.debuggerSlot.appendChild(debuggerElement); - } - - private buildChrome(): Adw.Window { - const window_ = new Adw.Window(); - window_.id = "application-window"; - + private buildChrome(): void { const toolbarView = new Adw.ToolbarView(); // --- Header bar (slot=top) --- @@ -278,25 +160,20 @@ export class MainWindow implements MainView { this.configureIconButton(this.sidebarToggle, "sidebar-show", "Toggle Sidebar"); this.sidebarToggle.setAttribute("slot", "start"); this.sidebarToggle.hidden = true; - this.sidebarToggle.addEventListener("click", () => this.splitView.toggleSidebar()); this.configureIconButton(this.learnBackButton, "go-previous", "Back"); this.learnBackButton.setAttribute("slot", "start"); this.learnBackButton.hidden = true; // shown only when a Learn subpage is open this.learnBackButton.addEventListener("click", () => this.learnView.navigateBack()); - // The Learn view reports when its visible sub-page changes (Tutorial / - // Examples pushed or popped) so the shell can reveal its back button. - this.learnView.events.on("subpage-changed", () => this.updateLearnBackButton()); - this.learnView.events.on("share", () => window.open(SHARE_URL, "_blank", "noopener,noreferrer")); this.runControls.className = "shell-run-controls"; this.runControls.setAttribute("slot", "start"); - this.configureTextButton(this.assembleButton, "Assemble", () => this.assembleGameConsole(), "suggested"); - this.configureTextButton(this.runButton, "Run", () => this.runGameConsole()); - this.configureTextButton(this.pauseButton, "Pause", () => this.pauseGameConsole()); - this.configureTextButton(this.stepButton, "Step", () => this.stepGameConsole()); - this.configureTextButton(this.resetButton, "Reset", () => this.reset()); - this.runControls.append(this.assembleButton, this.runButton, this.pauseButton, this.stepButton, this.resetButton); + this.addRunButton("Assemble", ["assemble"], "suggested"); + this.addRunButton("Run", ["run-simulator", "resume-simulator"]); + this.addRunButton("Pause", ["pause-simulator"]); + this.addRunButton("Step", ["step-simulator"]); + this.addRunButton("Reset", ["reset-simulator"]); + this.shell.onActionsChanged(() => this.updateRunButtons()); this.titleWidget.setAttribute("slot", "center"); this.titleWidget.setAttribute("title", "Learn 6502 Assembly"); @@ -305,64 +182,32 @@ export class MainWindow implements MainView { menuButton.setAttribute("slot", "end"); menuButton.menuTitle = "Main Menu"; menuButton.menuModel = [ - { id: "help", label: "Help" }, + { id: "open-file", label: "Open…" }, + { id: "save-file", label: "Save" }, + { id: "save-as-file", label: "Save As…" }, + { id: "show-help", label: "Help" }, { id: "shortcuts", label: "Keyboard Shortcuts" }, { id: "about", label: "About Learn 6502 Assembly" }, ]; menuButton.addEventListener("menu-item-activated", (event) => { const id = (event as CustomEvent<{ id: string }>).detail.id; - if (id === "help") { - // Reveal the Editor's 6502 quick-help sheet (the GNOME twin's help). - this.navigateToView(ViewType.EDITOR); - editorController.setHelpVisible(true); + if (id === "shortcuts" || id === "about") { + this.toastOverlay.addToast(`Menu: ${id} (coming soon)`, { timeout: 2 }); return; } - this.showToast(`Menu: ${id} (coming soon)`); + this.shell.activate(id); }); header.append(this.sidebarToggle, this.learnBackButton, this.runControls, this.titleWidget, menuButton); // --- Content (unslotted): toast overlay > layout host --- - this.buildLayoutHost(); - this.toastOverlay.appendChild(this.layoutHost); + this.toastOverlay.appendChild(this.shell.layoutHost); // --- Bottom: view switcher bar --- this.switcherBar.setAttribute("slot", "bottom"); toolbarView.append(header, this.toastOverlay, this.switcherBar); - window_.appendChild(toolbarView); - return window_; - } - - private buildLayoutHost(): void { - this.layoutHost.id = "layout-host"; - this.mobileLayout.id = "mobile-layout"; - this.desktopLayout.id = "desktop-layout"; - - // Desktop split view: Learn sidebar | (Editor | GameConsole-over-Debugger). - this.splitView.setAttribute("min-sidebar-width", "260"); - this.splitView.setAttribute("max-sidebar-width", "420"); - this.splitView.setAttribute("sidebar-width-fraction", "0.25"); - - this.sidebarColumn.className = "shell-sidebar-column"; - this.sidebarColumn.setAttribute("slot", "sidebar"); - - const centerRight = document.createElement("div"); - centerRight.className = "shell-center-right"; - centerRight.setAttribute("slot", "content"); - - this.centerColumn.className = "shell-center-column"; - this.rightTop.className = "shell-right-top"; - this.rightBottom.className = "shell-right-bottom"; - const rightColumn = document.createElement("div"); - rightColumn.className = "shell-right-column"; - rightColumn.append(this.rightTop, this.rightBottom); - centerRight.append(this.centerColumn, rightColumn); - - this.splitView.append(this.sidebarColumn, centerRight); - this.desktopLayout.appendChild(this.splitView); - - this.layoutHost.append(this.mobileLayout, this.desktopLayout); + this.root.appendChild(toolbarView); } private configureIconButton(button: Gtk.Button, icon: string, tooltip: string): void { @@ -371,226 +216,23 @@ export class MainWindow implements MainView { button.setAttribute("flat", ""); } - private configureTextButton(button: Gtk.Button, label: string, onClick: () => void, variant?: "suggested"): void { + private addRunButton(label: string, actions: string[], variant?: "suggested"): void { + const button = new Gtk.Button(); button.setAttribute("label", label); if (variant) button.setAttribute(variant, ""); - button.addEventListener("click", onClick); - } - - // --- Controller wiring (the shared common-ui pipeline) --- - - private setupControllers(): void { - // The shell owns the simulator stack; the console that runs it and the debugger that shows it share it. - const stack = { memory: this.memory, labels: this.labels, simulator: this.simulator, assembler: this.assembler }; - this.gameConsoleView.setSimulatorStack(stack); - this.debuggerView.setSimulatorStack(stack); - this.debuggerView.state = DebuggerState.ACTIVE; - - // A key press only drives the game while the display is focused, so playing never steals the Editor's keys. - // The shared Display is typed as the GJS widget; on the web it is the element, and key events bubble up to it. - (this.gameConsoleView.display as unknown as HTMLElement).addEventListener("keydown", (event: KeyboardEvent) => { - if (gameConsoleController.handleKeyPress(event.keyCode)) event.preventDefault(); - }); - - // GameConsole signals → debugger/toast updates (same seam as GNOME/Android). - this.bridge = new GameConsoleEventBridge({ - formatAndLog: (message, params) => this.debuggerView.log(formatMessage(message, params ?? [])), - updateDebugger: () => this.updateDebugger(), - updateAssemblerViews: (asm) => { - this.debuggerView.updateHexdump(asm); - this.debuggerView.updateDisassembled(asm); - }, - updateUiState: () => this.updateRunActions(this.simulator.state), - showNotification: (key) => this.showToast(NOTIFICATION_TITLES[key] ?? key), - }); - this.bridge.connect(); - - // Debugger controller → host-level actions the view leaves to the shell. - debuggerController.on("copyToEditor", (code: string) => { - this.setEditorCode(code); - this.showToast("Code copied to editor"); - }); - debuggerController.on("copyToClipboard", (content: string) => { - void navigator.clipboard?.writeText(content); - this.showToast("Copied to clipboard"); - }); - debuggerController.on("stepperToggled", () => this.updateRunActions(this.simulator.state)); - - // Learn → Editor seam (ready for the Learn phase; harmless until then). - learnController.on("copy", ({ code }: { code: string }) => { - this.setEditorCode(code); - this.showToast("Code copied to editor"); - }); - - // A user edit in the Editor marks the program dirty (the MainEventBridge - // seam the GNOME/Android main views use — the web shell wires it directly). - editorController.events.on("changed", () => mainStateController.setCodeChanged(true)); - - // Main UI state → run-control enablement. - mainStateController.events.on("state-changed", () => this.updateRunActions(this.simulator.state)); - mainStateController.events.on("state-changed:code-changed", (changed: boolean) => { - this.codeChanged = changed; - this.updateRunActions(this.simulator.state); - }); - mainStateController.init(); - - this.updateRunActions(this.simulator.state); - } - - private updateDebugger(): void { - if (this.isDesktop() || this.currentView === ViewType.DEBUGGER) { - this.debuggerView.update(this.memory, this.simulator); - } - } - - private updateRunActions(state: SimulatorState): void { - const enabled: MainButtonActionState = mainStateController.getActionEnabledState( - state, - editorController.hasCode, - this.codeChanged - ); - this.setButtonEnabled(this.assembleButton, enabled.assemble); - this.setButtonEnabled(this.runButton, enabled.run || enabled.resume); - this.setButtonEnabled(this.pauseButton, enabled.pause); - this.setButtonEnabled(this.stepButton, enabled.step); - this.setButtonEnabled(this.resetButton, enabled.reset); - } - - private setButtonEnabled(button: Gtk.Button, enabled: boolean): void { - if (enabled) button.removeAttribute("disabled"); - else button.setAttribute("disabled", ""); - } - - private showToast(title: string): void { - this.toastOverlay.addToast(title, 2000); - } - - private seedDemoProgram(): void { - editorController.setCode(DEMO_PROGRAM); - mainStateController.setCodeChanged(false); - this.updateRunActions(this.simulator.state); - } - - // --- Adaptive layout (single ⇄ three, matchMedia mirror of Adw.Breakpoint) --- - - private setupAdaptiveLayout(): void { - // A change to the visible view from the switcher bubbles as - // notify::visible-child; keep MainView state + the debugger in sync. - this.layoutHost.addEventListener("notify::visible-child", (event) => { - const name = (event as CustomEvent<{ name: string }>).detail.name; - const slot = this.slots.find((s) => s.name === name); - if (!slot) return; - const previous = this.currentView; - this.currentView = slot.type; - mainStateController.setViewType(slot.type); - // Auto-pause when switching away from a running program (GNOME twin). - if ( - (previous === ViewType.GAME_CONSOLE || previous === ViewType.DEBUGGER) && - this.simulator.state === SimulatorState.RUNNING - ) { - this.pauseGameConsole(); - } - // Preserve the tutorial's reading position across mobile tab switches - // (the GNOME twin's saveScrollPosition/restoreScrollPosition seam). - if (previous === ViewType.LEARN && slot.type !== ViewType.LEARN) this.learnView.saveScrollPosition(); - if (previous === ViewType.EDITOR && slot.type !== ViewType.EDITOR) this.editorView.saveState(); - if (slot.type === ViewType.LEARN) this.learnView.restoreScrollPosition(); - if (slot.type === ViewType.DEBUGGER) this.updateDebugger(); - this.updateLearnBackButton(); + // The first enabled action wins: run and resume both start the program + button.addEventListener("click", () => { + const action = actions.find((name) => this.shell.isActionEnabled(name)); + if (action) this.shell.activate(action); }); - - // Initial layout is applied by mount() once the tree is connected. - this.desktopMq.addEventListener("change", () => this.applyLayout()); - this.wideMq.addEventListener("change", () => this.applyLayout()); - } - - private applyLayout(): void { - if (this.isDesktop()) this.mountThreeColumnLayout(); - else this.mountSingleLayout(); - this.updateLearnBackButton(); + this.runButtons.push({ button, actions }); + this.runControls.appendChild(button); } - /** - * Reveal the header back button when the Learn view has a sub-page open AND - * the Learn view is on screen — the whole sidebar on desktop, or the active - * tab on mobile (the GNOME twin gates its back button the same way). - */ - private updateLearnBackButton(): void { - const learnOnScreen = this.isDesktop() || this.currentView === ViewType.LEARN; - this.learnBackButton.hidden = !(learnOnScreen && this.learnView.hasVisibleSubpage); - } - - private isDesktop(): boolean { - return this.desktopMq.matches; - } - - /** Mount the mobile single layout: one fresh ViewStack + revealed switcher. */ - private mountSingleLayout(): void { - const stack = new Adw.ViewStack(); - // Connect first (empty), THEN add pages — Adw.ViewStack.connectedCallback - // rebuilds from declared children and would otherwise wipe pre-adds. - this.mobileLayout.replaceChildren(stack); - for (const slot of this.slots) { - this.resetSlot(slot.element); - stack.add(slot.element, slot.name, slot.title, slot.icon); + private updateRunButtons(): void { + for (const { button, actions } of this.runButtons) { + if (actions.some((name) => this.shell.isActionEnabled(name))) button.removeAttribute("disabled"); + else button.setAttribute("disabled", ""); } - stack.visibleChildName = this.currentView; - this.mobileStack = stack; - - this.switcherBar.setStack(stack); - this.switcherBar.reveal = true; - this.sidebarToggle.hidden = true; - // Run controls are desktop-only (the GNOME twin shows a FAB on mobile; - // that MainButton lands in a later phase). Keep the title for context. - this.runControls.hidden = true; - - this.mobileLayout.hidden = false; - this.desktopLayout.hidden = true; - - if (this.currentView === ViewType.DEBUGGER) this.updateDebugger(); - } - - /** Mount the desktop three-column layout: Learn | Editor | console-over-debugger. */ - private mountThreeColumnLayout(): void { - this.homeSlot(this.learnSlot, this.sidebarColumn); - this.homeSlot(this.editorSlot, this.centerColumn); - this.homeSlot(this.gameConsoleSlot, this.rightTop); - this.homeSlot(this.debuggerSlot, this.rightBottom); - - this.switcherBar.setStack(null); - this.switcherBar.reveal = false; - this.mobileStack = null; - this.mobileLayout.replaceChildren(); - - this.sidebarToggle.hidden = false; - this.runControls.hidden = false; - this.applySidebarMode(); - - this.desktopLayout.hidden = false; - this.mobileLayout.hidden = true; - - this.updateDebugger(); - } - - /** Wide desktop docks the sidebar; medium desktop overlays it (collapsed). */ - private applySidebarMode(): void { - if (this.wideMq.matches) { - this.splitView.collapsed = false; - this.splitView.showSidebar = true; - } else { - this.splitView.collapsed = true; - this.splitView.showSidebar = false; - } - } - - /** Move a view slot into a desktop column, clearing any stack-mode leftovers. */ - private homeSlot(slot: HTMLDivElement, parent: HTMLElement): void { - this.resetSlot(slot); - parent.appendChild(slot); - } - - private resetSlot(slot: HTMLDivElement): void { - slot.hidden = false; - slot.classList.remove("adw-view-stack-child", "active-view"); } } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index b1d43690..80289f50 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -14,6 +14,10 @@ export * from "./learn/example-list-item.ts"; export * from "./learn/examples-list.ts"; export * from "./learn/learn.ts"; export * from "./main-button.ts"; +export * from "./main-window/main-window-controller.ts"; +export * from "./main-window/services.ts"; +export * from "./main-window/shell.ts"; +export * from "./main-window/window-behaviour.ts"; export * from "./toolbar.ts"; export * from "./gutter-renderer-mode.ts"; export * from "./source-view.ts"; diff --git a/packages/ui/src/main-window/main-window-controller.ts b/packages/ui/src/main-window/main-window-controller.ts new file mode 100644 index 00000000..40d4dc25 --- /dev/null +++ b/packages/ui/src/main-window/main-window-controller.ts @@ -0,0 +1,459 @@ +import { SimulatorState, formatMessage, num2hex } from "@learn6502/core"; +import { + type MainButtonState, + type MainView, + type SimulatorStack, + ViewType, + GameConsoleEventBridge, + createSimulatorStack, + debuggerController, + editorController, + gameConsoleController, + learnController, + mainStateController, +} from "@learn6502/common-ui"; + +import { _ } from "../translate.ts"; +import type { Debugger } from "../debugger/debugger.ts"; +import type { Editor } from "../editor/editor.ts"; +import type { GameConsole } from "../game-console/game-console.ts"; +import type { Learn } from "../learn/learn.ts"; +import type { MainButton } from "../main-button.ts"; +import type { MainWindowServices } from "./services.ts"; +import type { MainWindowShell, ShellAction } from "./shell.ts"; +import { WindowBehaviour } from "./window-behaviour.ts"; +import { persistWindowSize } from "./window-size.ts"; + +/** The screens the window shows; the platform builds them and the shell places them. */ +export interface MainWindowParts { + editor: Editor; + gameConsole: GameConsole; + learn: Learn; + debugger: Debugger; + /** Only its state follow-up is used, so a platform with a header toolbar instead of a floating button can stand in. */ + mainButton: Pick; + /** A platform that rebuilds its widgets around one process-wide simulator passes it; otherwise a fresh one is made. */ + simulatorStack?: SimulatorStack; +} + +/** Notification key to translated toast title. */ +const NOTIFICATION_TITLES: Record string> = { + // TRANSLATORS: Toast message title after successful assembly + "assembled-successfully": () => _("Assembled successfully"), + // TRANSLATORS: Toast message title after assembly failure + "assemble-failed": () => _("Assemble failed"), + // TRANSLATORS: Toast message title when simulator reports a failure + "simulator-failure": () => _("Simulator failure"), + // TRANSLATORS: Toast message title when labels processing fails + "labels-failure": () => _("Labels failure"), + // TRANSLATORS: Toast message title when the program has finished execution + "program-completed": () => _("Program completed"), +}; + +/** + * The main window's logic, written once: navigation, the run/step/assemble flow, file handling and the + * window's own state. The platform builds the screens and a `MainWindowShell` around them, and injects + * its services. + */ +export class MainWindowController implements MainView { + private readonly behaviour: WindowBehaviour; + private gameConsoleBridge: GameConsoleEventBridge | null = null; + + private askingAboutUnsavedChanges = false; + private codeToAssembleChanged = false; + private focusTimeoutId: ReturnType | null = null; + + private readonly actions = new Map(); + // The controllers outlive the window, so every subscription made on them is undone in `disconnect()` + private readonly unsubscribers: (() => void)[] = []; + + constructor( + private readonly shell: MainWindowShell, + private readonly parts: MainWindowParts, + private readonly services: MainWindowServices + ) { + this.behaviour = new WindowBehaviour({ + window: shell.window, + isRunning: () => this.state === SimulatorState.RUNNING, + pause: () => this.pauseGameConsole(), + onCloseRequest: () => (shell.emitsCloseRequest === false ? false : this.onCloseRequest()), + isDesktopLayout: () => this.shell.isDesktopLayout(), + isInsideConsole: (widget) => this.shell.isInsideConsole(widget), + }); + } + + /** Wire the window up; call once the shell's widgets exist. */ + init(): void { + const { gameConsole, debugger: debuggerView } = this.parts; + + // One simulator stack for the console that runs it and the debugger that shows it. + const simulatorStack = this.parts.simulatorStack ?? createSimulatorStack(); + gameConsole.setSimulatorStack(simulatorStack); + debuggerView.setSimulatorStack(simulatorStack); + + this.shell.onLayoutChanged(() => this.onLayoutChanged()); + this.shell.onActiveViewChanged((view, previous) => this.onActiveViewChanged(view, previous)); + this.parts.learn.events.on("subpage-changed", () => this.updateLearnBackButtonVisibility()); + this.parts.learn.events.on("share", () => this.submitExample()); + + this.setupActions(); + this.setupGameConsoleBridge(); + this.setupLearnListeners(); + this.setupEditorListeners(); + this.setupDebuggerListeners(); + this.setupMainStateListeners(); + + this.shell.mountLayout(); + this.onLayoutChanged(); + mainStateController.init(); + this.updateRunActions(this.state); + + this.behaviour.attach(); + persistWindowSize(this.shell.window, this.services.settings); + } + + // --- MainView --- + + public get state(): SimulatorState { + return this.parts.gameConsole.simulator.state; + } + + public get activeView(): ViewType { + return this.shell.activeView(); + } + + public navigateToView(viewType: ViewType): void { + // In desktop mode, all relevant views are visible. Skip switching the stack. + if (this.shell.isDesktopLayout()) return; + this.shell.showView(viewType); + } + + public runGameConsole(): void { + const targetView = mainStateController.getNavigationTarget("run"); + if (targetView && targetView !== this.activeView) this.navigateToView(targetView); + if (debuggerController.stepperEnabled) debuggerController.stepperEnabled = false; + + this.parts.gameConsole.run(); + // The console takes the focus so keyboard/gamepad input works at once; focusing again a moment + // later wins over the transient focus of the toolbar button that started the program. + this.behaviour.noteRun(); + this.parts.gameConsole.focus(); + if (this.focusTimeoutId !== null) clearTimeout(this.focusTimeoutId); + this.focusTimeoutId = setTimeout(() => { + this.parts.gameConsole.focus(); + this.focusTimeoutId = null; + }, 150); + } + + public pauseGameConsole(): void { + this.parts.gameConsole.stop(); + this.showToast( + // TRANSLATORS: Toast message title shown when pausing the game console + _("Program paused") + ); + } + + public reset(): void { + this.parts.gameConsole.reset(); + this.parts.debugger.reset(); + } + + public assembleGameConsole(): void { + this.parts.debugger.reset(); + this.navigateToView(ViewType.DEBUGGER); + + // Reset the code changed flag BEFORE assembling + mainStateController.setCodeChanged(false); + this.parts.gameConsole.assemble(editorController.code); + } + + public stepGameConsole(): void { + const targetView = mainStateController.getNavigationTarget("step"); + if (targetView && targetView !== this.activeView) this.navigateToView(targetView); + + // Enabling the stepper through the controller keeps the UI in sync + if (!debuggerController.stepperEnabled) debuggerController.stepperEnabled = true; + + this.parts.gameConsole.simulator.debugExecStep(); + this.updateDebugger(); + this.updateRunActions(this.state); + } + + public setEditorCode(code: string): void { + editorController.setCode(code); + this.navigateToView(ViewType.EDITOR); + + mainStateController.setCodeChanged(false); + this.unsavedChanges = false; + } + + // --- State --- + + private set unsavedChanges(unsavedChanges: boolean) { + this.services.files.setUnsavedChanges(unsavedChanges); + } + + private get unsavedChanges(): boolean { + return this.services.files.hasUnsavedChanges(); + } + + private showToast(title: string, timeout = 2): void { + this.services.notifier.show({ title, timeout }); + } + + private submitExample(): void { + this.services.submitExample(editorController.code, this.parts.gameConsole.memory); + } + + public showShareDialog(): void { + this.services.showShareDialog(editorController.code, this.parts.gameConsole.memory); + } + + // --- Listeners --- + + private setupLearnListeners(): void { + const onCopy = ({ code }: { code: string }) => { + this.setEditorCode(code); + this.shell.closeSidebarIfOverlay(); + // TRANSLATORS: Toast message title after copying code snippet into the editor + this.showToast(_("Code copied to editor")); + }; + learnController.on("copy", onCopy); + this.unsubscribers.push(() => learnController.off("copy", onCopy)); + } + + private setupEditorListeners(): void { + this.parts.editor.events.on("changed", () => { + this.unsavedChanges = true; + mainStateController.setCodeChanged(true); + }); + } + + private setupDebuggerListeners(): void { + const onCopyToClipboard = (code: string) => { + if (this.services.clipboard.copy(code)) { + // TRANSLATORS: Toast message title after copying to clipboard + this.showToast(_("Copied to clipboard")); + } else { + // TRANSLATORS: Toast message title when clipboard copy fails + this.showToast(_("Failed to copy to clipboard")); + } + }; + const onCopyToEditor = (code: string) => { + this.setEditorCode(code); + this.shell.closeSidebarIfOverlay(); + }; + const onStepperToggled = () => this.updateRunActions(this.state); + debuggerController.on("copyToClipboard", onCopyToClipboard); + debuggerController.on("copyToEditor", onCopyToEditor); + debuggerController.on("stepperToggled", onStepperToggled); + this.unsubscribers.push( + () => debuggerController.off("copyToClipboard", onCopyToClipboard), + () => debuggerController.off("copyToEditor", onCopyToEditor), + () => debuggerController.off("stepperToggled", onStepperToggled) + ); + } + + private setupMainStateListeners(): void { + const onStateChanged = () => this.updateRunActions(this.state); + const onCodeChanged = (changed: boolean) => { + this.codeToAssembleChanged = changed; + this.updateRunActions(this.state); + }; + mainStateController.events.on("state-changed", onStateChanged); + mainStateController.events.on("state-changed:code-changed", onCodeChanged); + this.unsubscribers.push( + () => mainStateController.events.off("state-changed", onStateChanged), + () => mainStateController.events.off("state-changed:code-changed", onCodeChanged) + ); + + // Keep the shared gameplay keys: the platform registers its keycodes, the log is shared + const onKeyPressed = (event: { keyCode: number }) => { + const view = this.activeView; + if (view === ViewType.GAME_CONSOLE || view === ViewType.DEBUGGER) { + this.parts.debugger.log( + // TRANSLATORS: Debugger log message when gamepad key is pressed + _("Gamepad key pressed:") + " $" + num2hex(event.keyCode).toUpperCase() + ); + } + }; + gameConsoleController.on("keyPressed", onKeyPressed); + this.unsubscribers.push(() => gameConsoleController.off("keyPressed", onKeyPressed)); + } + + private setupGameConsoleBridge(): void { + const { gameConsole, debugger: debuggerView } = this.parts; + this.gameConsoleBridge = new GameConsoleEventBridge({ + formatAndLog: (message, params) => debuggerView.log(formatMessage(_(message), params || [])), + updateDebugger: () => this.updateDebugger(), + updateAssemblerViews: () => { + debuggerView.updateHexdump(gameConsole.assembler); + debuggerView.updateDisassembled(gameConsole.assembler); + }, + updateUiState: () => this.updateRunActions(this.state), + showNotification: (key) => { + const title = NOTIFICATION_TITLES[key]; + this.showToast(title ? title() : key); + }, + }); + this.gameConsoleBridge.connect(); + } + + /** Stop listening to the controllers, which outlive the window. */ + public disconnect(): void { + this.gameConsoleBridge?.disconnect(); + this.gameConsoleBridge = null; + for (const unsubscribe of this.unsubscribers.splice(0)) unsubscribe(); + } + + // --- Actions --- + + private setupActions(): void { + const bindings: [string, () => unknown, string[]?][] = [ + ["assemble", () => this.assembleGameConsole()], + ["run-simulator", () => this.runGameConsole()], + ["resume-simulator", () => this.runGameConsole()], + ["pause-simulator", () => this.pauseGameConsole()], + ["reset-simulator", () => this.reset()], + ["step-simulator", () => this.stepGameConsole()], + ["share", () => this.showShareDialog()], + ["open-file", () => this.openFile(), ["o"]], + ["save-file", () => this.saveFile(), ["s"]], + ["save-as-file", () => this.saveAsFile(), ["s"]], + ["show-help", () => this.services.showHelp()], + ]; + for (const [name, handler, accels] of bindings) { + this.actions.set( + name, + this.shell.addAction(name, () => void handler(), accels) + ); + } + } + + private updateRunActions(state: SimulatorState): MainButtonState { + const enabledState = mainStateController.getActionEnabledState( + state, + editorController.hasCode, + this.codeToAssembleChanged + ); + + this.actions.get("assemble")?.setEnabled(enabledState.assemble); + this.actions.get("run-simulator")?.setEnabled(enabledState.run); + this.actions.get("resume-simulator")?.setEnabled(enabledState.resume); + this.actions.get("pause-simulator")?.setEnabled(enabledState.pause); + this.actions.get("reset-simulator")?.setEnabled(enabledState.reset); + this.actions.get("step-simulator")?.setEnabled(enabledState.step); + this.actions.get("share")?.setEnabled(enabledState.share); + + return this.parts.mainButton.updateFromSimulatorState(state); + } + + // --- Views, layout --- + + private onActiveViewChanged(view: ViewType, previous: ViewType): void { + this.updateLearnBackButtonVisibility(); + + // Switching away from the console or debugger pauses a running program + if ( + view !== previous && + (previous === ViewType.GAME_CONSOLE || previous === ViewType.DEBUGGER) && + this.state === SimulatorState.RUNNING + ) { + this.pauseGameConsole(); + } + + mainStateController.setViewType(view); + if (view === ViewType.DEBUGGER) this.updateDebugger(); + } + + private onLayoutChanged(): void { + this.updateLearnBackButtonVisibility(); + mainStateController.setViewType(this.activeView); + this.updateRunActions(this.state); + } + + private updateDebugger(): void { + // The debugger updates while visible: the active page on mobile, always on desktop + if (this.shell.isDesktopLayout() || this.activeView === ViewType.DEBUGGER) { + this.parts.debugger.update(this.parts.gameConsole.memory, this.parts.gameConsole.simulator); + } + } + + private updateLearnBackButtonVisibility(): void { + const isLearnVisible = this.activeView === ViewType.LEARN; + const hasSubpage = this.parts.learn.hasVisibleSubpage; + // On mobile the button belongs to the Learn tab only + this.shell.setLearnBackVisible(hasSubpage && (this.shell.isDesktopLayout() || isLearnVisible)); + } + + // --- Files, closing --- + + public async openFile(): Promise { + if (this.unsavedChanges) { + void this.askAboutUnsavedChanges("open"); + return; + } + + const result = await this.services.files.openFile(); + if (result) { + this.setEditorCode(result.content); + this.shell.setFileTitle(result.filename); + // TRANSLATORS: Toast message title after opening a file, includes filename + this.showToast(formatMessage(_("Opened %s"), [result.filename])); + } + } + + public async saveFile(): Promise { + if (this.services.files.hasCurrentFile()) { + return await this.services.files.saveFile(editorController.code); + } + return await this.saveAsFile(); + } + + public async saveAsFile(): Promise { + const { files } = this.services; + const success = await files.saveFileAs( + editorController.code, + files.hasCurrentFile() ? files.getCurrentFileName() : "untitled.asm" + ); + if (success) { + this.shell.setFileTitle(files.getCurrentFileName()); + // TRANSLATORS: Toast message title after saving file successfully, includes filename + this.showToast(formatMessage(_("Saved as %s"), [files.getCurrentFileName()])); + } + return success; + } + + private async askAboutUnsavedChanges(action: "open" | "close"): Promise { + if (this.askingAboutUnsavedChanges) return; + this.askingAboutUnsavedChanges = true; + let answer; + try { + answer = await this.shell.askUnsavedChanges(); + } finally { + this.askingAboutUnsavedChanges = false; + } + + if (answer === "save") { + if (!(await this.saveFile())) return; + } else if (answer === "discard") { + this.unsavedChanges = false; + } else { + return; + } + + if (action === "open") void this.openFile(); + else this.shell.closeWindow(); + } + + private onCloseRequest(): boolean { + if (this.unsavedChanges) { + void this.askAboutUnsavedChanges("close"); + return true; // Block the close; the dialog's answer decides + } + + this.parts.gameConsole.close(); + this.parts.debugger.close(); + return false; + } +} diff --git a/packages/ui/src/main-window/services.ts b/packages/ui/src/main-window/services.ts new file mode 100644 index 00000000..0cc21ca0 --- /dev/null +++ b/packages/ui/src/main-window/services.ts @@ -0,0 +1,44 @@ +import type { Memory } from "@learn6502/core"; + +/** What the main window needs from the platform to open and save the user's program. */ +export interface FileChooser { + openFile(): Promise<{ content: string; filename: string } | null>; + saveFile(content: string): Promise; + saveFileAs(content: string, suggestedName?: string): Promise; + getCurrentFileName(): string; + /** A file is attached to the editor, so "save" has a target and needs no dialog. */ + hasCurrentFile(): boolean; + hasUnsavedChanges(): boolean; + setUnsavedChanges(hasChanges: boolean): void; +} + +/** The persisted window geometry; GNOME backs it with GSettings. */ +export interface WindowSettings { + getInt(key: string): number; + setInt(key: string, value: number): void; + getBoolean(key: string): boolean; + setBoolean(key: string, value: boolean): void; +} + +export interface Clipboard { + /** Returns false when the platform has no clipboard to write to. */ + copy(text: string): boolean; +} + +export interface Notifier { + show(options: { title: string; timeout?: number }): void; +} + +/** Everything the main window asks of its platform; each app injects its own. */ +export interface MainWindowServices { + files: FileChooser; + settings: WindowSettings; + clipboard: Clipboard; + notifier: Notifier; + /** Open the help window. */ + showHelp(): void; + /** Share the program (the `share` action). */ + showShareDialog(code: string, memory: Memory): void; + /** Learn's "share your example": open the way to submit the program as a new example. */ + submitExample(code: string, memory: Memory): void; +} diff --git a/packages/ui/src/main-window/shell.ts b/packages/ui/src/main-window/shell.ts new file mode 100644 index 00000000..aaa9a878 --- /dev/null +++ b/packages/ui/src/main-window/shell.ts @@ -0,0 +1,45 @@ +import type Gtk from "@girs/gtk-4.0"; + +import type { ViewType } from "@learn6502/common-ui"; + +/** One of the window's `win` actions, as the buttons and the menu see it. */ +export interface ShellAction { + setEnabled(enabled: boolean): void; +} + +export type UnsavedChangesAnswer = "save" | "discard" | "cancel"; + +/** + * What the shared `MainWindowController` needs from the widgets that hold the four screens. Each + * platform implements it over its own toolkit (GTK, NativeScript, the DOM), so the controller + * names no widget API of its own. + */ +export interface MainWindowShell { + /** The window, for the behaviour that follows its focus and close request. */ + readonly window: Gtk.Window; + /** Set to false where nothing emits `close-request` yet; the unsaved-changes guard then stays unwired. */ + readonly emitsCloseRequest?: boolean; + + /** The screens sit side by side (wide window) instead of one at a time. */ + isDesktopLayout(): boolean; + /** The window moved between the two layouts; the shell has already placed the screens. */ + onLayoutChanged(handler: () => void): void; + /** Place the screens for the current layout. */ + mountLayout(): void; + + /** The screen on show; in the side-by-side layout the one the shell treats as current. */ + activeView(): ViewType; + showView(view: ViewType): void; + onActiveViewChanged(handler: (view: ViewType, previous: ViewType) => void): void; + + setLearnBackVisible(visible: boolean): void; + setFileTitle(filename: string): void; + /** Close the sidebar when it overlays the content, so copied code is visible at once. */ + closeSidebarIfOverlay(): void; + isInsideConsole(widget: Gtk.Widget | null): boolean; + + /** Register an action; `accels` are keyboard shortcuts where the platform has them. */ + addAction(name: string, activate: () => void, accels?: string[]): ShellAction; + askUnsavedChanges(): Promise; + closeWindow(): void; +} diff --git a/packages/ui/src/main-window/window-behaviour.ts b/packages/ui/src/main-window/window-behaviour.ts new file mode 100644 index 00000000..9f5f0901 --- /dev/null +++ b/packages/ui/src/main-window/window-behaviour.ts @@ -0,0 +1,52 @@ +import type Gtk from "@girs/gtk-4.0"; + +import { debounce } from "@learn6502/core"; + +import { watchFocusWidget } from "./window-focus.ts"; + +export interface WindowBehaviourOptions { + window: Gtk.Window; + /** The program is running, so losing the window's focus should pause it. */ + isRunning(): boolean; + pause(): void; + /** Decide a `close-request`: return true to block the close (e.g. to ask about unsaved changes). */ + onCloseRequest(): boolean; + /** Focus moving between widgets only pauses in the three-column layout, where the console shares the window. */ + isDesktopLayout(): boolean; + isInsideConsole(widget: Gtk.Widget | null): boolean; +} + +/** + * What the shared main window does with the window's own state, on every platform: pause the program + * when the window loses focus, guard closing, and (where the platform has them) follow the focused widget. + */ +export class WindowBehaviour { + private lastRunAtMs = 0; + + // Debounced so a transient focus loss (a menu popover) does not pause the program + private readonly onActiveChanged = debounce(() => { + if (!this.options.window.is_active && this.options.isRunning()) this.options.pause(); + }, 250); + + private readonly onFocusWidgetChanged = debounce(() => { + if (!this.options.isDesktopLayout()) return; + if (this.options.isInsideConsole(this.options.window.get_focus() ?? null)) return; + // Starting the program moves the focus off the toolbar: that must not pause it again + if (Date.now() - this.lastRunAtMs < 400) return; + if (this.options.isRunning()) this.options.pause(); + }, 150); + + constructor(private readonly options: WindowBehaviourOptions) {} + + attach(): void { + const { window } = this.options; + window.connect("close-request", () => this.options.onCloseRequest()); + window.connect("notify::is-active", () => this.onActiveChanged()); + watchFocusWidget(window, () => this.onFocusWidgetChanged()); + } + + /** The program just started; the focus is about to settle on the console. */ + noteRun(): void { + this.lastRunAtMs = Date.now(); + } +} diff --git a/packages/ui/src/main-window/window-focus.android.ts b/packages/ui/src/main-window/window-focus.android.ts new file mode 100644 index 00000000..49ed3907 --- /dev/null +++ b/packages/ui/src/main-window/window-focus.android.ts @@ -0,0 +1,4 @@ +import type Gtk from "@girs/gtk-4.0"; + +/** NativeScript refuses `notify::focus-widget`, and Android has no in-window focus to follow. */ +export function watchFocusWidget(_window: Gtk.Window, _handler: () => void): void {} diff --git a/packages/ui/src/main-window/window-focus.ts b/packages/ui/src/main-window/window-focus.ts new file mode 100644 index 00000000..8ce7ea28 --- /dev/null +++ b/packages/ui/src/main-window/window-focus.ts @@ -0,0 +1,6 @@ +import type Gtk from "@girs/gtk-4.0"; + +/** Call `handler` whenever the focused widget inside the window changes. */ +export function watchFocusWidget(window: Gtk.Window, handler: () => void): void { + window.connect("notify::focus-widget", handler); +} diff --git a/packages/ui/src/main-window/window-size.android.ts b/packages/ui/src/main-window/window-size.android.ts new file mode 100644 index 00000000..5457d827 --- /dev/null +++ b/packages/ui/src/main-window/window-size.android.ts @@ -0,0 +1,6 @@ +import type Gtk from "@girs/gtk-4.0"; + +import type { WindowSettings } from "./services.ts"; + +/** The NativeScript window is always maximized and refuses `unmaximize()`: there is no size to persist. */ +export function persistWindowSize(_window: Gtk.Window, _settings: WindowSettings): void {} diff --git a/packages/ui/src/main-window/window-size.ts b/packages/ui/src/main-window/window-size.ts new file mode 100644 index 00000000..f3ce6f8e --- /dev/null +++ b/packages/ui/src/main-window/window-size.ts @@ -0,0 +1,32 @@ +import type Gtk from "@girs/gtk-4.0"; + +import type { WindowSettings } from "./services.ts"; + +/** Restore the saved window geometry and keep saving it while the user resizes. */ +export function persistWindowSize(window: Gtk.Window, settings: WindowSettings): void { + try { + if (settings.getBoolean("is-maximized")) { + window.maximize(); + } else { + window.set_default_size(settings.getInt("window-width"), settings.getInt("window-height")); + } + } catch (error) { + console.warn("Could not load window size from settings:", error); + // Fall back to the Blueprint defaults + } + + const saveSize = () => { + if (window.is_maximized()) return; // A maximized window's size says nothing about the restored one + try { + const [width, height] = window.get_default_size(); + settings.setInt("window-width", width); + settings.setInt("window-height", height); + } catch (error) { + console.warn("Could not save window size to settings:", error); + } + }; + + window.connect("notify::default-width", saveSize); + window.connect("notify::default-height", saveSize); + window.connect("notify::maximized", () => settings.setBoolean("is-maximized", window.is_maximized())); +} diff --git a/packages/ui/src/main-window/window-size.web.ts b/packages/ui/src/main-window/window-size.web.ts new file mode 100644 index 00000000..bb6d8e86 --- /dev/null +++ b/packages/ui/src/main-window/window-size.web.ts @@ -0,0 +1,6 @@ +import type Gtk from "@girs/gtk-4.0"; + +import type { WindowSettings } from "./services.ts"; + +/** The browser tab owns its size. */ +export function persistWindowSize(_window: Gtk.Window, _settings: WindowSettings): void {}