From de18bc118ffa56e6a04f49ee1d881ae487f3fa0e Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:04:06 -0500 Subject: [PATCH 1/2] web: keyboard inset instead of a resize; fields scroll above it The soft keyboard now reaches the app as a bottom inset (virtualKeyboard geometrychange, or visualViewport on iOS) rather than a canvas resize. The root lays out above a spring-followed inset, and a focused field scrolls itself above it when it gains focus, the inset moves or it grows. The hidden input is clamped above the keyboard so the browser does not pan the page. Agent-Team: linux Worker: claude Co-Authored-By: Claude Sonnet 5.5 --- build/web.zig | 1 + core/core.zig | 2 + core/keyboard.zig | 103 ++++++++++++++++++++++++++++++ core/platform.zig | 9 +++ core/sizing_tests.zig | 2 + scripts/test-web-text-input.sh | 2 + src/backend/backend_web.zig | 6 ++ src/editor/Editor.zig | 3 +- web/AGENTS.md | 5 ++ web/fizzy-worker.js | 5 ++ web/fizzy.js | 33 +++++++++- web/tests/keyboard-inset.test.mjs | 50 +++++++++++++++ web/tests/pointer-input.test.mjs | 2 +- 13 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 core/keyboard.zig create mode 100644 web/tests/keyboard-inset.test.mjs diff --git a/build/web.zig b/build/web.zig index 25da70dfe..824a1de11 100644 --- a/build/web.zig +++ b/build/web.zig @@ -173,6 +173,7 @@ pub fn build( "FizzyWebKeyBuffer", "FizzyWebKeyBound", "FizzyWebSetFormFactor", + "FizzyWebSetKeyboardInset", "FizzyWebImageAlloc", "FizzyWebImageReady", "FizzyWebImageFailed", diff --git a/core/core.zig b/core/core.zig index b8b9cc1d1..bd4cc23ba 100644 --- a/core/core.zig +++ b/core/core.zig @@ -33,6 +33,8 @@ pub const water_surface = @import("gfx/water_surface.zig"); pub const math = @import("math/math.zig"); pub const fs = @import("fs.zig"); pub const platform = @import("platform.zig"); +/// The on-screen keyboard: the root lays out above its inset, a focused field scrolls above it. +pub const keyboard = @import("keyboard.zig"); pub const paths = @import("paths.zig"); /// Resolves the user's real login-shell `PATH` — see `shell_env.zig`. diff --git a/core/keyboard.zig b/core/keyboard.zig new file mode 100644 index 000000000..c6d23c0c9 --- /dev/null +++ b/core/keyboard.zig @@ -0,0 +1,103 @@ +//! The on-screen keyboard, as the app lays out around it. The web backend writes how far the +//! keyboard covers the window (`platform.keyboard_inset`) and the canvas keeps its size, so +//! nothing clears and re-lays out when it opens: the root takes the inset off the bottom of its +//! rect (`inset`), and a focused field scrolls itself above it (`scrollIntoView`). +const std = @import("std"); +const dvui = @import("dvui"); +const platform = @import("platform.zig"); +const Spring = @import("Spring.zig"); +const ScrollArea = @import("widgets/ScrollArea.zig"); + +/// Quick and without overshoot: the layout follows the keyboard, it does not bounce on it. +const tune: Spring.Tune = .{ .hz = 8, .playful_damping = 1 }; +const max_step_s: f32 = 0.05; +const spring_key = "_keyboard_inset"; + +/// The bottom inset to lay out with this frame, in points: the keyboard's, followed by a spring so +/// the layout slides with it (reduce-motion snaps). Call once per frame, from the root, and give +/// the result to its bottom padding. 0 when there is no keyboard. +pub fn inset() f32 { + const win = dvui.currentWindow(); + const spring = dvui.dataGetPtrDefault(null, win.data().id, spring_key, Spring, .{}); + const target: dvui.Point.Physical = .{ .y = platform.keyboardInset() }; + if (spring.step(target, @min(dvui.secondsSinceLastFrame(), max_step_s), tune)) dvui.refresh(null, @src(), win.data().id); + return spring.pos.y; +} + +/// The inset the root laid out with this frame, 0 before it has asked. +fn laidOut() f32 { + const spring = dvui.dataGetPtr(null, dvui.currentWindow().data().id, spring_key, Spring) orelse return 0; + return spring.pos.y; +} + +/// What was true when the field last scrolled itself in. `again` is frames still owed: the area +/// clamps a scroll to the content it measured last frame, so one that comes with the content's +/// growth lands a frame short, and is asked for again. +const Seen = struct { inset: f32 = 0, h: f32 = 0, focused: bool = false, again: u8 = 0 }; + +/// Scroll the enclosing scroll area to show the field `wd` once it has been laid out, when it has +/// just gained focus, the inset moved, or it grew a line. Otherwise leaves the scroll alone, so a +/// person who scrolled away is not pulled back every frame. +pub fn scrollIntoView(wd: *dvui.WidgetData, focused: bool) void { + const seen = dvui.dataGetPtrDefault(null, wd.id, "_keyboard_seen", Seen, .{}); + var now: Seen = .{ .inset = laidOut(), .h = wd.rect.h, .focused = focused, .again = seen.again -| 1 }; + defer seen.* = now; + if (!focused) return; + if (!seen.focused or seen.inset != now.inset or seen.h != now.h) now.again = 2; + if (now.again == 0) return; + dvui.scrollTo(.{ .screen_rect = wd.borderRectScale().r }); +} + +var t_lines: f32 = 1; +var t_field_bottom: f32 = 0; + +fn fieldFrame() !dvui.App.Result { + var root = dvui.box(@src(), .{}, .{ .expand = .both, .padding = .{ .h = inset() } }); + defer root.deinit(); + var scroll = ScrollArea.init(@src(), .{ .horizontal = .none, .vertical_bar = .hide }, .{ .expand = .both }); + defer scroll.deinit(); + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 100, .h = 1000 } }); + var field = dvui.box(@src(), .{}, .{ .min_size_content = .{ .w = 100, .h = 20 * t_lines } }); + scrollIntoView(field.data(), true); + const r = field.data().borderRectScale(); + t_field_bottom = (r.r.y + r.r.h) / r.s; + field.deinit(); + return .ok; +} + +test "a focused field ends above the keyboard, and stays there when it grows" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + defer platform.keyboard_inset = 0; + // Laid out once first, so the area knows its content when the keyboard opens. + try dvui.testing.settle(fieldFrame); + platform.keyboard_inset = 200; + try dvui.testing.settle(fieldFrame); + try std.testing.expect(t_field_bottom <= 400 - 200 + 0.5); + try std.testing.expect(t_field_bottom > 400 - 200 - 60); + t_lines = 4; + try dvui.testing.settle(fieldFrame); + try std.testing.expect(t_field_bottom <= 400 - 200 + 0.5); + try std.testing.expect(t_field_bottom > 400 - 200 - 60); +} + +var t_root_h: f32 = 0; + +fn rootFrame() !dvui.App.Result { + var root = dvui.box(@src(), .{}, .{ .expand = .both, .padding = .{ .h = inset() } }); + defer root.deinit(); + t_root_h = root.data().contentRect().h; + return .ok; +} + +test "no keyboard leaves the root's rect as it was" { + var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); + defer t.deinit(); + platform.keyboard_inset = 0; + try dvui.testing.settle(rootFrame); + try std.testing.expectEqual(@as(f32, 400), t_root_h); + platform.keyboard_inset = 150; + defer platform.keyboard_inset = 0; + try dvui.testing.settle(rootFrame); + try std.testing.expectEqual(@as(f32, 250), t_root_h); +} diff --git a/core/platform.zig b/core/platform.zig index 52cd3c0cb..8f0e520e4 100644 --- a/core/platform.zig +++ b/core/platform.zig @@ -35,6 +35,15 @@ pub var cached_form_factor: FormFactor = .desktop; /// This visit's `?form=` on web, never stored; null elsewhere. pub var cached_form_override: ?FormFactor = null; +/// How far the on-screen keyboard covers the bottom of the window, in points; 0 when there is +/// none. The web backend writes it (the canvas keeps its size under a keyboard), and the app lays +/// out above it. Read it with `keyboardInset()`. +pub var keyboard_inset: f32 = 0; + +pub inline fn keyboardInset() f32 { + return keyboard_inset; +} + /// The person's Layout setting, written by the editor each frame (held while a demo plays). pub var cached_layout_mode: form_factor.LayoutMode = .auto; diff --git a/core/sizing_tests.zig b/core/sizing_tests.zig index 81b6677b4..4901906a2 100644 --- a/core/sizing_tests.zig +++ b/core/sizing_tests.zig @@ -38,4 +38,6 @@ test { _ = @import("widgets/FloatingTooltipWidget.zig"); // One motion clock: a late frame holds motion, and real time still counts it. _ = @import("motion_clock_tests.zig"); + // The keyboard inset: the root ends above it and a focused field scrolls above it. + _ = @import("keyboard.zig"); } diff --git a/scripts/test-web-text-input.sh b/scripts/test-web-text-input.sh index 3378d9012..f8b17ffb5 100755 --- a/scripts/test-web-text-input.sh +++ b/scripts/test-web-text-input.sh @@ -6,12 +6,14 @@ set -eu cd "$(dirname "$0")/.." if [ "${1:-}" = --embed-only ]; then + node web/tests/keyboard-inset.test.mjs exec node web/tests/embed.test.mjs fi js=zig-out/web/web.js [ -f "$js" ] || { echo "test-web-text-input: no $js; run zig build web" >&2; exit 1; } if command -v node >/dev/null; then node web/tests/pointer-input.test.mjs + node web/tests/keyboard-inset.test.mjs node web/tests/text-input.test.js "$PWD/$js" exec node web/tests/embed.test.mjs fi diff --git a/src/backend/backend_web.zig b/src/backend/backend_web.zig index 25b078b27..db08b8f32 100644 --- a/src/backend/backend_web.zig +++ b/src/backend/backend_web.zig @@ -195,6 +195,12 @@ export fn FizzyWebSetFormFactor(coarse: bool, w_css: f32, h_css: f32, override: return @backingInt(platform.formFactor()); } +/// Called by the page (through the worker) when the on-screen keyboard opens, moves or closes: +/// how far it covers the bottom of the page, in points. The canvas is not resized for it. +export fn FizzyWebSetKeyboardInset(bottom: f32) void { + fizzy.core.platform.keyboard_inset = bottom; +} + /// Symmetric with the native API: nothing to install on web because the JS bootstrap wires /// the wheel listener at page load. pub fn installTrackpadGestureMonitor() void {} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index d42bb2fc6..3914f6ff8 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -4839,7 +4839,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // person's, or a tape's while one drives (`Host.setInputAuthor`). Plugins' frame hooks // outside it act for themselves, or are nobody's. editor.app.host.setInputAuthor(editor.demo.author() orelse .person); - var layout_root = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .background = false }); + // The canvas keeps its size under the on-screen keyboard; the layout ends above it. + var layout_root = dvui.box(@src(), .{ .dir = .vertical }, .{ .expand = .both, .background = false, .padding = .{ .h = fizzy.core.keyboard.inset() } }); for (editor.app.host.plugins.items) |plugin| plugin.tickActiveDocument(layout_root.data().id); editor.flushQueuedNativeMenuActions(); editor.flushQueuedNativeMenuItems(); diff --git a/web/AGENTS.md b/web/AGENTS.md index 22db89e61..23e908f78 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -45,6 +45,11 @@ The page and its JavaScript (`index.html`, the app's `fizzy.js`, workers). The Z whenever the measurement changes: on a device that shows the wrong shape, that line says which number failed the rule (`coarse` = `(pointer: coarse)`, `hover` = `(hover: hover)`; w/h are `screen.*`). The main-thread loop (Firefox, Safari, `?worker=0`) sends the same message. +- **The keyboard is an inset, never a resize.** `keyboardInset` (`fizzy.js`) posts `{type: "inset"}` + from `virtualKeyboard.geometrychange` (Chrome on Android, with `overlaysContent`) or the + `visualViewport` (iOS), the worker writes `core.platform.keyboard_inset`, and the root lays out + above it (`core.keyboard`). A page that sets `interactive-widget=resizes-content` makes the + browser resize the canvas for the keyboard, which clears it for a frame: leave it out. - **The heap never shrinks.** The wasm allocator rounds blocks over 64 KB up to a power-of-two class and reuses a freed block only within its class, so a large image can step memory from tens to hundreds of MB. Before blaming memory for a failed open, read the logged error. diff --git a/web/fizzy-worker.js b/web/fizzy-worker.js index f43c1b6c7..931434647 100644 --- a/web/fizzy-worker.js +++ b/web/fizzy-worker.js @@ -718,6 +718,11 @@ onmessage = (e) => { applyForm(); if (dvuiApp) dvuiApp.requestRender(); break; + case "inset": + // The keyboard's height: laid out above by the app, the canvas is not resized. + if (wasmInstance && wasmInstance.exports.FizzyWebSetKeyboardInset) wasmInstance.exports.FizzyWebSetKeyboardInset(m.bottom); + if (dvuiApp) dvuiApp.requestRender(); + break; case "prefs": env.prefs = m.prefs; if (dvuiApp) dvuiApp.requestRender(); diff --git a/web/fizzy.js b/web/fizzy.js index 709c96ca0..20f0fc7c6 100644 --- a/web/fizzy.js +++ b/web/fizzy.js @@ -57,6 +57,33 @@ export function pointerInput(canvas, send, clockOrigin = performance.timeOrigin, } } +// The on-screen keyboard's height over the page's bottom edge, posted as `{type: "inset", bottom}` +// and never as a `size`: the canvas keeps its size, so nothing clears or re-lays out, and the app +// lays out above the keyboard itself. Chrome on Android (a secure context) has the VirtualKeyboard +// API: with `overlaysContent` the browser neither resizes nor pans for the keyboard. Elsewhere +// (iOS Safari) the visual viewport says how much of the layout viewport the keyboard covers. +// Returns the current inset reader for the hidden input to stay above. +export function keyboardInset(win, send) { + let bottom = 0; + const post = (next) => { + next = Math.max(0, Math.round(next)); + if (next === bottom) return; + bottom = next; + send({ type: "inset", bottom }); + }; + const vk = win.navigator?.virtualKeyboard; + if (vk) { + vk.overlaysContent = true; + vk.addEventListener("geometrychange", () => post(vk.boundingRect.height)); + } else if (win.visualViewport) { + const vv = win.visualViewport; + const update = () => post(win.innerHeight - (vv.height + vv.offsetTop)); + vv.addEventListener("resize", update); + vv.addEventListener("scroll", update); + } + return () => bottom; +} + import { Dvui, TextInput } from "./web.js?cache-buster=TEMPLATE_HASH_WITH_PADDING__ITS_64_BYTES_LONG_THE_SAME_AS_SHA256"; // The name this build files what it keeps in the browser under: its own files, the plugins it @@ -1029,7 +1056,10 @@ export class Fizzy { } const rect = canvas.getBoundingClientRect(); const left = window.scrollX + rect.left + textInputRect[0]; - const top = window.scrollY + rect.top + textInputRect[1]; + // Above the keyboard: the browser scrolls the page to keep a focused element visible, and + // an input at the caret under the keyboard is what made the page jump. + const above = Math.max(0, canvas.clientHeight - insetBottom() - textInputRect[3]); + const top = window.scrollY + rect.top + Math.min(textInputRect[1], above); hidden.style.left = left + "px"; hidden.style.top = top + "px"; hidden.style.width = Math.max(0, Math.min(textInputRect[2], canvas.clientWidth - left)) + "px"; @@ -1056,6 +1086,7 @@ export class Fizzy { baseURI: document.baseURI, }, [offscreen])); + const insetBottom = keyboardInset(window, post); new ResizeObserver(() => post({ type: "size", size: size() })).observe(canvas); window.addEventListener("resize", () => post({ type: "size", size: size() })); window.addEventListener("orientationchange", () => post({ type: "size", size: size() })); diff --git a/web/tests/keyboard-inset.test.mjs b/web/tests/keyboard-inset.test.mjs new file mode 100644 index 000000000..1fff45ca5 --- /dev/null +++ b/web/tests/keyboard-inset.test.mjs @@ -0,0 +1,50 @@ +// The on-screen keyboard reaches the app as a bottom inset, never as a resize: the canvas keeps +// its size, so nothing clears or re-lays out. Run: `node web/tests/keyboard-inset.test.mjs`. +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import assert from 'node:assert'; + +const source = readFileSync(new URL('../fizzy.js', import.meta.url), 'utf8'); +const fn = source.slice(source.indexOf('export function keyboardInset'), source.indexOf('import { Dvui')); +const { keyboardInset } = runInNewContext(fn.replace('export function', 'function') + '\n({ keyboardInset })'); + +class Target { + listeners = {}; + addEventListener(type, fn) { this.listeners[type] = fn; } + fire(type) { this.listeners[type](); } +} + +// Chrome on Android: the VirtualKeyboard API, with the browser told not to resize or pan. +{ + const vk = Object.assign(new Target(), { overlaysContent: false, boundingRect: { height: 0 } }); + const sent = []; + const bottom = keyboardInset({ navigator: { virtualKeyboard: vk }, innerHeight: 800 }, (m) => sent.push(m)); + assert.equal(vk.overlaysContent, true); + vk.boundingRect = { height: 301.4 }; + vk.fire('geometrychange'); + vk.fire('geometrychange'); + assert.deepEqual(sent, [{ type: 'inset', bottom: 301 }]); + assert.equal(bottom(), 301); + vk.boundingRect = { height: 0 }; + vk.fire('geometrychange'); + assert.deepEqual(sent.at(-1), { type: 'inset', bottom: 0 }); + assert.ok(sent.every((m) => m.type !== 'size')); +} + +// iOS Safari: the visual viewport shrinks (and may scroll) over the layout viewport. +{ + const vv = Object.assign(new Target(), { height: 800, offsetTop: 0 }); + const win = { navigator: {}, innerHeight: 800, visualViewport: vv }; + const sent = []; + keyboardInset(win, (m) => sent.push(m)); + vv.height = 500; + vv.fire('resize'); + vv.offsetTop = 40; + vv.fire('scroll'); + vv.height = 900; + vv.offsetTop = 0; + vv.fire('resize'); + assert.deepEqual(sent, [{ type: 'inset', bottom: 300 }, { type: 'inset', bottom: 260 }, { type: 'inset', bottom: 0 }]); + assert.ok(sent.every((m) => m.type !== 'size')); +} +console.log('keyboard-inset: ok'); diff --git a/web/tests/pointer-input.test.mjs b/web/tests/pointer-input.test.mjs index 752481518..9a76681cc 100644 --- a/web/tests/pointer-input.test.mjs +++ b/web/tests/pointer-input.test.mjs @@ -4,7 +4,7 @@ import assert from 'node:assert/strict'; // Exercise the page's actual input adapter without loading the wasm/browser runtime. const source = readFileSync(new URL('../fizzy.js', import.meta.url), 'utf8'); -const adapter = source.slice(source.indexOf('export function pointerInput'), source.indexOf('import { Dvui')); +const adapter = source.slice(source.indexOf('export function pointerInput'), source.indexOf('// The on-screen keyboard')); const { pointerInput } = runInNewContext(adapter.replace('export function', 'function') + '\n({ pointerInput })'); const listeners = {}; const sent = []; From 4ebcf481fd3a9e0170c090945d3d88e8e3d5fbf1 Mon Sep 17 00:00:00 2001 From: "fizzy-linux[bot]" <346940526+fizzy-linux[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:02:09 -0500 Subject: [PATCH 2/2] linux: work-666 (codex) Agent-Team: linux Worker: codex --- app/settings/PluginSettingsPane.zig | 4 +- core/keyboard.zig | 15 ++++-- core/platform.zig | 6 +-- core/widgets.zig | 12 ++++- plugins/workbench/src/files.zig | 2 +- src/editor/CommandPalette.zig | 2 +- src/editor/dialogs/WebSaveAs.zig | 2 +- web/AGENTS.md | 5 +- web/fizzy.js | 74 ++++++++++++++++++++++++----- web/tests/embed.test.mjs | 39 +++++++++++++++ web/tests/keyboard-inset.test.mjs | 21 ++++++++ 11 files changed, 153 insertions(+), 29 deletions(-) diff --git a/app/settings/PluginSettingsPane.zig b/app/settings/PluginSettingsPane.zig index a1be6a935..c0ad25169 100644 --- a/app/settings/PluginSettingsPane.zig +++ b/app/settings/PluginSettingsPane.zig @@ -136,7 +136,7 @@ fn drawZon(schema: *const settings.SettingsSchema, field_index: usize, id_extra: access.persist(value, schema.owner); } } - entry.deinit(); + core.widgets.textEntryDeinit(&entry); } /// Text entry for a `[]const u8` setting. Committed on Enter only — the entry re-seeds itself @@ -170,7 +170,7 @@ fn drawString(schema: *const settings.SettingsSchema, field: settings.Setting, f access.setString(value, field_index, entry.getText()); access.persist(value, schema.owner); } - entry.deinit(); + core.widgets.textEntryDeinit(&entry); } fn drawIntChoices(schema: *const settings.SettingsSchema, field: settings.Setting, choices: []const i64, field_index: usize, id_extra: usize) !void { diff --git a/core/keyboard.zig b/core/keyboard.zig index c6d23c0c9..cb1885d33 100644 --- a/core/keyboard.zig +++ b/core/keyboard.zig @@ -19,8 +19,8 @@ const spring_key = "_keyboard_inset"; pub fn inset() f32 { const win = dvui.currentWindow(); const spring = dvui.dataGetPtrDefault(null, win.data().id, spring_key, Spring, .{}); - const target: dvui.Point.Physical = .{ .y = platform.keyboardInset() }; - if (spring.step(target, @min(dvui.secondsSinceLastFrame(), max_step_s), tune)) dvui.refresh(null, @src(), win.data().id); + // Spring stores an unscaled pair; here its coordinates are logical layout points. + if (spring.step(.{ .y = platform.keyboard_inset }, @min(dvui.secondsSinceLastFrame(), max_step_s), tune)) dvui.refresh(null, @src(), win.data().id); return spring.pos.y; } @@ -48,7 +48,9 @@ pub fn scrollIntoView(wd: *dvui.WidgetData, focused: bool) void { dvui.scrollTo(.{ .screen_rect = wd.borderRectScale().r }); } +/// Written by the field test on the test UI thread. var t_lines: f32 = 1; +/// Written by fieldFrame on the test UI thread. var t_field_bottom: f32 = 0; fn fieldFrame() !dvui.App.Result { @@ -57,11 +59,12 @@ fn fieldFrame() !dvui.App.Result { var scroll = ScrollArea.init(@src(), .{ .horizontal = .none, .vertical_bar = .hide }, .{ .expand = .both }); defer scroll.deinit(); _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = 100, .h = 1000 } }); - var field = dvui.box(@src(), .{}, .{ .min_size_content = .{ .w = 100, .h = 20 * t_lines } }); - scrollIntoView(field.data(), true); + const field = dvui.textEntry(@src(), .{ .multiline = true }, .{ .min_size_content = .{ .w = 100, .h = 20 * t_lines } }); + dvui.focusWidget(field.data().id, null, null); + _ = @import("widgets.zig").textEntryMenu(field); const r = field.data().borderRectScale(); t_field_bottom = (r.r.y + r.r.h) / r.s; - field.deinit(); + @import("widgets.zig").textEntryDeinit(field); return .ok; } @@ -69,6 +72,7 @@ test "a focused field ends above the keyboard, and stays there when it grows" { var t = try dvui.testing.init(.{ .window_size = .{ .w = 300, .h = 400 } }); defer t.deinit(); defer platform.keyboard_inset = 0; + t_lines = 1; // Laid out once first, so the area knows its content when the keyboard opens. try dvui.testing.settle(fieldFrame); platform.keyboard_inset = 200; @@ -81,6 +85,7 @@ test "a focused field ends above the keyboard, and stays there when it grows" { try std.testing.expect(t_field_bottom > 400 - 200 - 60); } +/// Written by rootFrame on the test UI thread. var t_root_h: f32 = 0; fn rootFrame() !dvui.App.Result { diff --git a/core/platform.zig b/core/platform.zig index 8f0e520e4..b5e1a134f 100644 --- a/core/platform.zig +++ b/core/platform.zig @@ -37,13 +37,9 @@ pub var cached_form_override: ?FormFactor = null; /// How far the on-screen keyboard covers the bottom of the window, in points; 0 when there is /// none. The web backend writes it (the canvas keeps its size under a keyboard), and the app lays -/// out above it. Read it with `keyboardInset()`. +/// out above it. Written on the UI thread. pub var keyboard_inset: f32 = 0; -pub inline fn keyboardInset() f32 { - return keyboard_inset; -} - /// The person's Layout setting, written by the editor each frame (held while a demo plays). pub var cached_layout_mode: form_factor.LayoutMode = .auto; diff --git a/core/widgets.zig b/core/widgets.zig index b86f0286d..c8773046e 100644 --- a/core/widgets.zig +++ b/core/widgets.zig @@ -214,11 +214,21 @@ pub fn filterRow(src: std.lang.SourceLocation, placeholder: []const u8, opts: dv .expand = .horizontal, .background = false, }); - defer entry.deinit(); + defer textEntryDeinit(entry); _ = textEntryMenu(entry); return entry.getText(); } +/// Finish a text field and scroll its enclosing area on focus, growth or keyboard movement. +/// Replaces the entry's `deinit`; the pointer is invalid afterwards. +pub fn textEntryDeinit(te: *dvui.TextEntryWidget) void { + var wd = te.data().*; + const focused = wd.id == dvui.focusedWidgetId(); + te.deinit(); + // The entry's own scroll area must be closed before scrolling the enclosing area. + @import("keyboard.zig").scrollIntoView(&wd, focused); +} + /// A context menu's surface, opened at the point the pointer was pressed. Every right-click /// menu in the app goes through here: the file tree's rows, the docking widget's tabs, and /// whatever a plugin contributes into one — so "what a menu looks like" is answered once, diff --git a/plugins/workbench/src/files.zig b/plugins/workbench/src/files.zig index 494060995..4daca7918 100644 --- a/plugins/workbench/src/files.zig +++ b/plugins/workbench/src/files.zig @@ -543,7 +543,7 @@ pub fn editableLabel(id_extra: usize, label: []const u8, color: dvui.Color, kind .id_extra = id_extra, .font = font, }); - defer te.deinit(); + defer core.widgets.textEntryDeinit(te); _ = core.widgets.textEntryMenu(te); // Text edit should handle any click events, so if we find one unhandled after the text edit diff --git a/src/editor/CommandPalette.zig b/src/editor/CommandPalette.zig index f5e307442..fa3f0120c 100644 --- a/src/editor/CommandPalette.zig +++ b/src/editor/CommandPalette.zig @@ -789,7 +789,7 @@ pub fn draw(self: *CommandPalette, editor: *Editor) void { entry.textLayout.selection.moveCursor(std.math.maxInt(usize), false); } _ = core.widgets.textEntryMenu(entry); - entry.deinit(); + core.widgets.textEntryDeinit(entry); } if (asked) |a| self.drawAskedHint(a, text_color); diff --git a/src/editor/dialogs/WebSaveAs.zig b/src/editor/dialogs/WebSaveAs.zig index b0cbfdf32..87f68f9a3 100644 --- a/src/editor/dialogs/WebSaveAs.zig +++ b/src/editor/dialogs/WebSaveAs.zig @@ -114,7 +114,7 @@ pub fn dialog(id: dvui.Id) fizzy.core.dialogs.Error!bool { const placeholder = if (current_kind == .export_to) "filename" else "filename.fiz"; const te = dvui.textEntry(@src(), .{ .placeholder = placeholder }, .{ .expand = .horizontal }); - defer te.deinit(); + defer fizzy.core.widgets.textEntryDeinit(te); _ = fizzy.core.widgets.textEntryMenu(te); if (dvui.firstFrame(te.data().id)) { diff --git a/web/AGENTS.md b/web/AGENTS.md index 23e908f78..ffa012ef0 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -47,9 +47,10 @@ The page and its JavaScript (`index.html`, the app's `fizzy.js`, workers). The Z `screen.*`). The main-thread loop (Firefox, Safari, `?worker=0`) sends the same message. - **The keyboard is an inset, never a resize.** `keyboardInset` (`fizzy.js`) posts `{type: "inset"}` from `virtualKeyboard.geometrychange` (Chrome on Android, with `overlaysContent`) or the - `visualViewport` (iOS), the worker writes `core.platform.keyboard_inset`, and the root lays out + `visualViewport` (iOS), both execution paths write `core.platform.keyboard_inset`, and the root lays out above it (`core.keyboard`). A page that sets `interactive-widget=resizes-content` makes the - browser resize the canvas for the keyboard, which clears it for a frame: leave it out. + browser resize the canvas for the keyboard: the focused copy preserves its CSS height until + the keyboard closes. Prefer leaving that directive out. - **The heap never shrinks.** The wasm allocator rounds blocks over 64 KB up to a power-of-two class and reuses a freed block only within its class, so a large image can step memory from tens to hundreds of MB. Before blaming memory for a failed open, read the logged error. diff --git a/web/fizzy.js b/web/fizzy.js index 20f0fc7c6..07d977fee 100644 --- a/web/fizzy.js +++ b/web/fizzy.js @@ -63,21 +63,49 @@ export function pointerInput(canvas, send, clockOrigin = performance.timeOrigin, // API: with `overlaysContent` the browser neither resizes nor pans for the keyboard. Elsewhere // (iOS Safari) the visual viewport says how much of the layout viewport the keyboard covers. // Returns the current inset reader for the hidden input to stay above. -export function keyboardInset(win, send) { +export function keyboardInset(win, send, canvas = null, owns = () => true) { let bottom = 0; + let rest = null; + let savedHeight = null; + const rect = () => canvas?.getBoundingClientRect() ?? { bottom: win.innerHeight, height: win.innerHeight }; const post = (next) => { - next = Math.max(0, Math.round(next)); + next = owns() ? Math.max(0, Math.round(next)) : 0; if (next === bottom) return; bottom = next; send({ type: "inset", bottom }); }; const vk = win.navigator?.virtualKeyboard; + const vv = win.visualViewport; + const update = () => { + const current = rect(); + const base = rest ?? current; + const edge = vk ? (vk.boundingRect.height ? vk.boundingRect.y ?? win.innerHeight - vk.boundingRect.height : Infinity) + : vv ? vv.height + vv.offsetTop : win.innerHeight; + const next = owns() ? Math.max(0, base.bottom - edge) : 0; + // resizes-content pages shrink the CSS canvas as well. Keep the focused canvas's layout + // height until the keyboard closes, so both dvui loops see the same backing-store size. + if (canvas && next > 0 && current.height < base.height) { + if (savedHeight === null) savedHeight = canvas.style.height ?? ""; + canvas.style.height = `${base.height}px`; + } else if (savedHeight !== null && next === 0) { + canvas.style.height = savedHeight; + savedHeight = null; + } + const closed = bottom > 0 && next === 0; + post(next); + if (closed) rest = null; + }; + win.addEventListener?.("focusin", () => { + if (owns() && bottom === 0) rest = rect(); + update(); + }); + win.addEventListener?.("focusout", () => win.setTimeout?.(update, 0)); + win.addEventListener?.("resize", update); + win.addEventListener?.("orientationchange", () => { rest = null; update(); }); if (vk) { vk.overlaysContent = true; - vk.addEventListener("geometrychange", () => post(vk.boundingRect.height)); - } else if (win.visualViewport) { - const vv = win.visualViewport; - const update = () => post(win.innerHeight - (vv.height + vv.offsetTop)); + vk.addEventListener("geometrychange", update); + } else if (vv) { vv.addEventListener("resize", update); vv.addEventListener("scroll", update); } @@ -1062,8 +1090,8 @@ export class Fizzy { const top = window.scrollY + rect.top + Math.min(textInputRect[1], above); hidden.style.left = left + "px"; hidden.style.top = top + "px"; - hidden.style.width = Math.max(0, Math.min(textInputRect[2], canvas.clientWidth - left)) + "px"; - hidden.style.height = Math.max(0, Math.min(textInputRect[2], canvas.clientHeight - top)) + "px"; + hidden.style.width = Math.max(0, Math.min(textInputRect[2], canvas.clientWidth - textInputRect[0])) + "px"; + hidden.style.height = Math.max(0, Math.min(textInputRect[3], canvas.clientHeight - Math.min(textInputRect[1], above))) + "px"; focusQuietly(hidden); } @@ -1086,9 +1114,20 @@ export class Fizzy { baseURI: document.baseURI, }, [offscreen])); - const insetBottom = keyboardInset(window, post); - new ResizeObserver(() => post({ type: "size", size: size() })).observe(canvas); - window.addEventListener("resize", () => post({ type: "size", size: size() })); + const insetBottom = keyboardInset(window, (m) => { + post(m); + if (ownsKey({ target: document.activeElement })) oskCheck(); + }, canvas, + () => ownsKey({ target: document.activeElement })); + let lastSize = size(); + const resize = () => { + const next = size(); + if (next.w === lastSize.w && next.h === lastSize.h && next.dpr === lastSize.dpr) return; + lastSize = next; + post({ type: "size", size: next }); + }; + new ResizeObserver(resize).observe(canvas); + window.addEventListener("resize", resize); window.addEventListener("orientationchange", () => post({ type: "size", size: size() })); for (const q of ["(pointer: coarse)", "(hover: none)"]) { window.matchMedia(q).addEventListener("change", () => post({ type: "size", size: size() })); @@ -1336,6 +1375,19 @@ export class Fizzy { // Late-bind the app reference so the capture-phase wheel listener above can // forward pinch deltas into the trackpad accumulator and kick a render. dvuiApp = app; + const insetBottom = keyboardInset(window, (m) => { + app.instance.exports.FizzyWebSetKeyboardInset?.(m.bottom); + app.requestRender(); + if (ownsKey({ target: document.activeElement })) app.oskCheck(); + }, dvuiCanvas, () => ownsKey({ target: document.activeElement })); + const oskCheck = app.oskCheck.bind(app); + app.oskCheck = () => { + const r = app.textInputRect; + if (!r?.length) return oskCheck(); + const y = r[1]; + r[1] = Math.min(y, Math.max(0, dvuiCanvas.clientHeight - insetBottom() - r[3])); + try { oskCheck(); } finally { r[1] = y; } + }; // Registered before the stoppers below: a stopper on the same element and phase would // end propagation to pointerInput's touch listeners, which cancel the touch's default. pointerInput(dvuiCanvas, (m) => { diff --git a/web/tests/embed.test.mjs b/web/tests/embed.test.mjs index b49501e2d..a626bebe1 100644 --- a/web/tests/embed.test.mjs +++ b/web/tests/embed.test.mjs @@ -25,6 +25,7 @@ export class Dvui { setCanvas(canvas) { this.canvas = canvas; this.gl = { drawingBufferWidth: 100, drawingBufferHeight: 100 }; } run() { this.hidden_input = globalThis.document.createElement("input"); } requestRender() {} + oskCheck() { globalThis.oskChecks++; } } `); @@ -47,6 +48,7 @@ class Canvas extends Element { getContext() { return {}; } setPointerCapture() {} } +globalThis.oskChecks = 0; const fullscreenStack = []; const document = Object.assign(new Target(), { fullscreenElement: null, @@ -57,6 +59,7 @@ const document = Object.assign(new Target(), { exitFullscreen() { fullscreenStack.pop(); document.fullscreenElement = fullscreenStack.at(-1) ?? null; document.dispatch("fullscreenchange", {}); return Promise.resolve(); }, }); const window = Object.assign(new Target(), { + visualViewport: Object.assign(new Target(), { height: 100, offsetTop: 0 }), devicePixelRatio: 1, scrollX: 0, scrollY: 0, localStorage: new Map(), parent: null, dispatchEvent() {}, matchMedia: () => ({ matches: false, addEventListener() {} }), }); @@ -72,17 +75,20 @@ let fetches = 0; let compiles = 0; const bound = []; const toggles = []; +const insets = []; const memory = new WebAssembly.Memory({ initial: 1 }); globalThis.fetch = (url) => { fetches++; return Promise.resolve({ url }); }; WebAssembly.compileStreaming = async () => { compiles++; return { fake: true }; }; WebAssembly.instantiate = async (module, imports) => { const id = bound.length; bound.push([]); + insets.push([]); toggles.push(imports.fizzy.fizzy_web_toggle_fullscreen); return { exports: { memory: { buffer: memory.buffer }, add_event() {}, + FizzyWebSetKeyboardInset: (bottom) => insets[id].push(bottom), FizzyWebKeyBuffer: () => 0, FizzyWebKeyBound: (len, mods) => { bound[id].push([len, mods]); return 1; }, }, @@ -104,6 +110,24 @@ const mount = async (parent) => { const a = await mount(new Element()); const b = await mount(new Element()); +document.activeElement = a; +window.dispatch("focusin", { target: a }); +window.visualViewport.height = 60; +window.visualViewport.dispatch("resize", {}); +assert.deepEqual(insets, [[40], []], "only the focused main-thread embed receives the inset"); +window.visualViewport.height = 100; +window.visualViewport.dispatch("resize", {}); +assert.deepEqual(insets, [[40, 0], []], "closing the keyboard clears the main-thread inset"); +window.visualViewport.height = 60; +window.visualViewport.dispatch("resize", {}); +const checksBeforeBlur = globalThis.oskChecks; +document.activeElement = new Element(); +window.dispatch("focusin", { target: document.activeElement }); +assert.equal(globalThis.oskChecks, checksBeforeBlur, "clearing an inactive embed never refocuses its input"); +assert.equal(insets[0].at(-1), 0); +window.visualViewport.height = 100; + + assert.equal(compiles, 1, "two copies share one compile"); assert.equal(fetches, 1, "two copies share one download"); @@ -148,6 +172,7 @@ TextInput.prototype.clear = () => { filterClears++; }; Canvas.prototype.transferControlToOffscreen = function () { return {}; }; globalThis.OffscreenCanvas = class {}; globalThis.ResizeObserver = class { observe() {} }; +const workerMessages = []; globalThis.Worker = class { set onmessage(handler) { this.handler = handler; @@ -155,6 +180,7 @@ globalThis.Worker = class { earlyTextInputs++; } postMessage(message) { + workerMessages.push(message); if (message.type === "init") this.handler({ data: { type: "ready" } }); } }; @@ -169,3 +195,16 @@ assert.equal(document.body.children[0].style.left, "10px", "early text input pos workerCanvas.dispatch("pointerdown", { pointerId: 1, pointerType: "mouse", buttons: 1, clientX: 0, clientY: 0, timeStamp: 0 }); assert.equal(filterClears, 2, "pointer input still clears the filter"); console.log("embed.test: worker startup and early text input ok"); + +document.activeElement = document.body.children[0]; +window.dispatch("focusin", { target: document.activeElement }); +window.visualViewport.height = 60; +window.visualViewport.dispatch("resize", {}); +window.dispatch("resize", {}); +assert.equal(workerMessages.at(-1).type, "inset", "a keyboard-only resize never posts a worker size"); +assert.equal(workerMessages.at(-1).bottom, 40); +assert.equal(document.activeElement.style.top, "20px", "the input stays above the keyboard"); +window.visualViewport.height = 100; +window.visualViewport.dispatch("resize", {}); +assert.equal(workerMessages.at(-1).bottom, 0); +console.log("embed.test: worker keyboard inset and resize suppression ok"); diff --git a/web/tests/keyboard-inset.test.mjs b/web/tests/keyboard-inset.test.mjs index 1fff45ca5..d3e8f006d 100644 --- a/web/tests/keyboard-inset.test.mjs +++ b/web/tests/keyboard-inset.test.mjs @@ -47,4 +47,25 @@ class Target { assert.deepEqual(sent, [{ type: 'inset', bottom: 300 }, { type: 'inset', bottom: 260 }, { type: 'inset', bottom: 0 }]); assert.ok(sent.every((m) => m.type !== 'size')); } +// A resizes-content page must retain the focused canvas's height; embeds measure overlap. +{ + const vv = Object.assign(new Target(), { height: 800, offsetTop: 0 }); + const win = Object.assign(new Target(), { navigator: {}, innerHeight: 800, visualViewport: vv }); + let height = 600; + let owned = true; + const canvas = { style: { height: '100%' }, getBoundingClientRect: () => ({ bottom: 100 + height, height }) }; + const sent = []; + keyboardInset(win, (m) => sent.push(m), canvas, () => owned); + win.fire('focusin'); + height = 300; + vv.height = 500; + win.innerHeight = 500; + win.fire('resize'); + assert.equal(canvas.style.height, '600px'); + assert.equal(sent.at(-1).bottom, 200, 'overlap uses the original canvas bottom, not the window bottom'); + owned = false; + vv.fire('resize'); + assert.equal(sent.at(-1).bottom, 0, 'another focused copy clears the inset'); + assert.equal(canvas.style.height, '100%', 'the original CSS height is restored'); +} console.log('keyboard-inset: ok');