From 5310e3dbf6eec65a3aa247a820cb05ec64885504 Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Mon, 14 Sep 2026 11:19:01 +0200 Subject: [PATCH] ui: mobile screens use a tab bar instead of split --- css/ui.css | 92 ++++++++++++++++++++++----- index.html | 9 ++- src/docs/index.md | 11 ++++ src/ide/layout.ts | 155 ++++++++++++++++++++++++++++++++++++++++++++++ src/ide/ui.ts | 71 +++------------------ 5 files changed, 258 insertions(+), 80 deletions(-) create mode 100644 src/ide/layout.ts diff --git a/css/ui.css b/css/ui.css index 44e7e134..ff143e88 100644 --- a/css/ui.css +++ b/css/ui.css @@ -57,33 +57,97 @@ white-space: -moz-pre-wrap; /* Mozilla, since 1999 */ pointer-events: none; } +/* body is a column: top bar, notebook, status bar. controls_top is allowed + to grow (its inline controls wrap on narrow screens) and the notebook + takes whatever is left, so no height math is needed. */ +body { + display:flex; + flex-direction:column; + height:100vh; + height:100dvh; +} #controls_top { - position:absolute; + flex:0 0 auto; + min-height:3em; padding:0.5em; - height:3em; width:100%; background-color:#999; } +/* contain the floated logo/menus so wrapped content counts toward the height */ +#controls_top::after { + content:""; + display:table; + clear:both; +} #notebook { - position:absolute; - top:3em; - bottom:1.8em; - left:0; - right:0; + position:relative; + flex:1 1 auto; + min-height:0; background-color:#666; overflow:hidden; display:flex; flex-direction:row; } +/* small screens: the three panes become tabs, one visible at a time */ +#mobiletabs { + display:none; + flex:0 0 auto; + background-color:#444; + border-bottom:1px solid #555; +} +body.tab-layout #notebook { + flex-direction:column; +} +body.tab-layout #mobiletabs { + display:flex; +} +/* save horizontal room for the wrapped controls */ +body.tab-layout .logo-gradient { + display:none; +} +#mobiletabs .mobiletab { + flex:1 1 0; + background:none; + border:none; + border-right:1px solid #555; + color:#ccc; + padding:0.6em 0.5em; + font-size:1em; + white-space:nowrap; + overflow:hidden; + text-overflow:ellipsis; +} +#mobiletabs .mobiletab:last-child { + border-right:none; +} +#mobiletabs .mobiletab.active { + background-color:#666; + color:#fff; + font-weight:bold; +} +body.tab-layout #notebook > #sidebar:not(.active), +body.tab-layout #notebook > #workspace:not(.active), +body.tab-layout #notebook > #emulator:not(.active) { + display:none; +} +body.tab-layout #notebook > #sidebar.active, +body.tab-layout #notebook > #workspace.active, +body.tab-layout #notebook > #emulator.active { + /* fill the notebook: flex-grow takes the height, width overrides the + calc(% - gutter) width that Split.js sets inline */ + flex:1 1 auto; + width:100% !important; +} +body.tab-layout #notebook > .gutter { + display:none; +} #workspace { background-color:#333; overflow:hidden; } #shortcuts_bar { - position:absolute; - bottom:0; - left:0; - right:0; + position:relative; + flex:0 0 auto; height:2em; line-height:2em; font-size:0.9em; @@ -143,16 +207,10 @@ #shortcuts_bar { display:none; } - #notebook { - bottom:0; - } } body.statusbar-hidden #shortcuts_bar { display:none; } -body.statusbar-hidden #notebook { - bottom:0; -} div.editor { font-size: 1.25rem; height: 100%; diff --git a/index.html b/index.html index 7fc92bde..61fe74be 100644 --- a/index.html +++ b/index.html @@ -274,9 +274,13 @@ body { -
-
+ +
+ + + +
+
diff --git a/src/docs/index.md b/src/docs/index.md index 1330c156..97f60cf9 100644 --- a/src/docs/index.md +++ b/src/docs/index.md @@ -110,6 +110,17 @@ tools appear: | [Breakpoints](breakpoints.md) | Add and edit breakpoints | | [Asset Editor](asseteditor.md) | Edit bitmaps, tilemaps, and palettes embedded in source | +## Small screens + +On narrow screens (phones, or any window under 760px wide) the three +panes — **Files**, **Editor**, and **Emulator** — are shown one at a +time with a tab bar at the top instead of side by side, starting on the +**Emulator** tab. Tap a tab to switch panes; the emulator keeps running +while you edit. The top control bar wraps onto multiple lines as needed, +and the 8bitworkshop logo is hidden to make room. Rotating the device or +resizing the window back above the breakpoint restores the side-by-side +layout and your previous pane sizes. + ## Keyboard Shortcuts `mod` below is **Ctrl** on Windows/Linux and **Cmd** on macOS. diff --git a/src/ide/layout.ts b/src/ide/layout.ts new file mode 100644 index 00000000..7491add8 --- /dev/null +++ b/src/ide/layout.ts @@ -0,0 +1,155 @@ +// Pane layout for the IDE. +// +// On wide screens the sidebar, editor and emulator sit side by side as three +// resizable panes (Split.js). On small screens they become a tab bar instead, +// showing one pane at a time; switching tabs just moves the splits +// programmatically with setSizes() (hence the zero minSize in tab mode). + +import Split = require('split.js'); +import { isMobileDevice } from "./views/baseviews"; + +declare var $: JQueryStatic; // use browser jquery + +// panes, in Split.js order +const PANES = ['#sidebar', '#workspace', '#emulator']; +// split sizes for each tab (collapse the other two) +const TAB_SIZES = { + sidebar: [100, 0, 0], + editor: [0, 100, 0], + emulator: [0, 0, 100], +}; + +// ui.ts injects these so this module doesn't have to import (and cycle with) it +export interface LayoutHooks { + getPlatformId(): string; + isEmbed: boolean; + hasLocalStorage: boolean; + resizePlatform(): void; + resizeWindows(): void; +} + +var hooks: LayoutHooks = null; +// Split.js instance; kept around so tabs can move the splits +var notebookSplit: any = null; +// true when the panes are shown as tabs (one at a time) instead of side by side +var tabMode = false; +var activeTab = 'emulator'; +// the side-by-side sizes to restore when we leave tab mode +var desktopSizes: number[] = null; +var splitStorageName: string = null; +// live query (isMobileDevice in baseviews is computed once at load and +// wouldn't react to rotating the device or resizing the window) +var mobileMediaQuery = (typeof window !== 'undefined' && window.matchMedia) + ? window.matchMedia("only screen and (max-width: 760px)") : null; +var wired = false; + +function isTabLayout(): boolean { + return mobileMediaQuery ? mobileMediaQuery.matches : isMobileDevice; +} + +function createSplit(sizes: number[]) { + if (notebookSplit) { + notebookSplit.destroy(); + notebookSplit = null; + } + notebookSplit = Split(PANES, { + sizes: sizes, + // tab mode collapses two panes to 0, so the desktop minSize can't apply + minSize: tabMode ? [0, 0, 0] : [0, 250, 250], + onDrag: () => { + hooks.resizePlatform(); + }, + onDragEnd: () => { + if (!tabMode && hooks.hasLocalStorage) localStorage.setItem(splitStorageName, JSON.stringify(notebookSplit.getSizes())) + hooks.resizeWindows(); + }, + }); +} + +function applyTabSizes() { + notebookSplit.setSizes(TAB_SIZES[activeTab] || TAB_SIZES.editor); +} + +function setActiveTabClass(tab: string) { + activeTab = TAB_SIZES[tab] ? tab : 'editor'; + $("#mobiletabs .mobiletab").removeClass("active"); + $("#mobiletabs .mobiletab[data-tab='" + activeTab + "']").addClass("active"); + $("#sidebar,#workspace,#emulator").removeClass("active"); + var sel = activeTab === 'sidebar' ? '#sidebar' : activeTab === 'emulator' ? '#emulator' : '#workspace'; + $(sel).addClass("active"); +} + +function setTabMode(on: boolean) { + tabMode = on; + if (typeof document === 'undefined') return; + $("body").toggleClass("tab-layout", on); +} + +export function showTab(tab: string) { + setActiveTabClass(tab); + if (!tabMode || !notebookSplit) return; + applyTabSizes(); + // the revealed pane was 0px wide; let it relayout once it's visible + setTimeout(() => { + if (activeTab === 'emulator') hooks.resizePlatform(); + if (activeTab === 'editor') hooks.resizeWindows(); + }, 0); +} + +function updateSplitLayout() { + var wantTabs = isTabLayout(); + if (wantTabs === tabMode) return; + if (wantTabs) { + // entering tab mode: remember the side-by-side sizes, recreate with 0 mins + if (notebookSplit) desktopSizes = notebookSplit.getSizes(); + setTabMode(true); + createSplit(desktopSizes || [0, 55, 45]); + showTab(activeTab); + } else { + // leaving tab mode: restore the side-by-side panes and their sizes + setTabMode(false); + $("#sidebar,#workspace,#emulator").removeClass("active"); + createSplit(desktopSizes || [12, 44, 44]); + hooks.resizePlatform(); + hooks.resizeWindows(); + } +} + +export function setupSplits(h: LayoutHooks) { + hooks = h; + var platform_id = hooks.getPlatformId(); + splitStorageName = 'workspace-split3-' + platform_id; + if (hooks.isEmbed) splitStorageName = 'embed-' + splitStorageName; + var sizes; + if (platform_id.startsWith('vcs')) + sizes = [0, 50, 50]; + else if (hooks.isEmbed || isMobileDevice) + sizes = [0, 55, 45]; + else + sizes = [12, 44, 44]; + var sizesStr = hooks.hasLocalStorage && localStorage.getItem(splitStorageName); + if (sizesStr) { + try { + sizes = JSON.parse(sizesStr); + } catch (e) { console.log(e); } + } + if (isTabLayout()) desktopSizes = sizes; // these came from localStorage; keep them for desktop + setTabMode(isTabLayout()); + createSplit(sizes); + if (tabMode) { + applyTabSizes(); + setActiveTabClass(activeTab); + } else { + $("#sidebar,#workspace,#emulator").removeClass("active"); + } + if (!wired) { + wired = true; + $("#mobiletabs").on("click", ".mobiletab", function () { + showTab($(this).attr("data-tab")); + }); + if (mobileMediaQuery) { + if (mobileMediaQuery.addEventListener) mobileMediaQuery.addEventListener("change", updateSplitLayout); + else if (mobileMediaQuery.addListener) mobileMediaQuery.addListener(updateSplitLayout); + } + } +} diff --git a/src/ide/ui.ts b/src/ide/ui.ts index e7bf23ea..e4140b70 100644 --- a/src/ide/ui.ts +++ b/src/ide/ui.ts @@ -33,10 +33,11 @@ import { DisassemblerView, HeaderView, ListingView, PC_LINE_LOOKAHEAD, SourceEdi import { HELP_TOPICS, HelpView, helpTopicForView } from "./views/helpview"; import { CallStackView, DebugBrowserView } from "./views/treeviews"; import { ProjectWindows } from "./windows"; +import { setupSplits, showTab } from "./layout"; +export { setupSplits, showTab }; import { bpStore, resolveBreakpoints } from "./breakpoints"; import { CondFn } from "./breakcond"; import { findListingLocation as findListingLocationPure } from "./search/listinglocation"; -import Split = require('split.js'); import DOMPurify = require("dompurify"); // external libs (TODO) @@ -2068,27 +2069,6 @@ function setupReplaySlider() { uitoolbar.add('mod+shift+e', 'Start/Stop Replay Recording', 'glyphicon-record', _toggleRecording).prop('id', 'dbg_record'); } - -function isLandscape() { - try { - var object = window.screen['orientation'] || window.screen['msOrientation'] || window.screen['mozOrientation'] || null; - if (object) { - if (object.type.indexOf('landscape') !== -1) { return true; } - if (object.type.indexOf('portrait') !== -1) { return false; } - } - if ('orientation' in window) { - var value = window.orientation; - if (value === 0 || value === 180) { - return false; - } else if (value === 90 || value === 270) { - return true; - } - } - } catch (e) { } - // fallback to comparing width to height - return window.innerWidth > window.innerHeight; -} - async function showWelcomeMessage() { if (userPrefs.shouldCompleteTour()) { await loadScript('lib/bootstrap-tourist.js'); @@ -2134,20 +2114,12 @@ async function showWelcomeMessage() { content: "Pull right to expose the sidebar. It lets you switch between source files, view assembly listings, and use other tools like Disassembler, Memory Browser, and Asset Editor." } ]; - if (!isLandscape()) { - steps.unshift({ - element: "#controls_top", - placement: 'bottom', - title: "Portrait mode detected", - content: "This site works best on desktop browsers. For best results, rotate your device to landscape orientation." - }); - } if (isProductionHost()) { steps.unshift({ element: "#dropdownMenuButton", placement: 'right', title: "Cookie Consent", - content: 'Before we start, we should tell you that this website stores cookies and other data in your browser. You can review our privacy policy.' + content: 'Before we start, we should tell you that this website stores persistent data in your browser. Review our privacy policy.' }); steps.push({ element: "#booksMenuButton", @@ -2419,35 +2391,6 @@ function revealTopBar() { setTimeout(() => { $("#controls_dynamic").css('visibility', 'inherit'); }, 250); } -export function setupSplits() { - var splitName = 'workspace-split3-' + platform_id; - if (isEmbed) splitName = 'embed-' + splitName; - var sizes; - if (platform_id.startsWith('vcs')) - sizes = [0, 50, 50]; - else if (isEmbed || isMobileDevice) - sizes = [0, 55, 45]; - else - sizes = [12, 44, 44]; - var sizesStr = hasLocalStorage && localStorage.getItem(splitName); - if (sizesStr) { - try { - sizes = JSON.parse(sizesStr); - } catch (e) { console.log(e); } - } - var split = Split(['#sidebar', '#workspace', '#emulator'], { - sizes: sizes, - minSize: [0, 250, 250], - onDrag: () => { - if (platform && platform.resize) platform.resize(); - }, - onDragEnd: () => { - if (hasLocalStorage) localStorage.setItem(splitName, JSON.stringify(split.getSizes())) - if (projectWindows) projectWindows.resize(); - }, - }); -} - function loadImportedURL(url: string) { // TODO: zip file? const ignore = parseBool(qs.ignore) || isEmbed; @@ -2570,7 +2513,13 @@ export async function startUI() { delete qs.repo; // continue without repo } getPlatformAndRepo(); - setupSplits(); + setupSplits({ + getPlatformId: () => platform_id, + isEmbed: isEmbed, + hasLocalStorage: hasLocalStorage, + resizePlatform: () => { if (platform && platform.resize) platform.resize(); }, + resizeWindows: () => { if (projectWindows) projectWindows.resize(); }, + }); // get store ID, repo id or platform id store_id = repo_id || getBasePlatform(platform_id); // are we embedded?