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?