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/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..cb1885d33 --- /dev/null +++ b/core/keyboard.zig @@ -0,0 +1,108 @@ +//! 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, .{}); + // 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; +} + +/// 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 }); +} + +/// 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 { + 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 } }); + 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; + @import("widgets.zig").textEntryDeinit(field); + 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; + 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; + 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); +} + +/// Written by rootFrame on the test UI thread. +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..b5e1a134f 100644 --- a/core/platform.zig +++ b/core/platform.zig @@ -35,6 +35,11 @@ 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. Written on the UI thread. +pub var keyboard_inset: f32 = 0; + /// 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/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/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/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/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/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 22db89e61..ffa012ef0 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -45,6 +45,12 @@ 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), 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: 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-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..07d977fee 100644 --- a/web/fizzy.js +++ b/web/fizzy.js @@ -57,6 +57,61 @@ 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, 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 = 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", update); + } else if (vv) { + 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,11 +1084,14 @@ 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"; - 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); } @@ -1056,8 +1114,20 @@ export class Fizzy { baseURI: document.baseURI, }, [offscreen])); - 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() })); @@ -1305,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 new file mode 100644 index 000000000..d3e8f006d --- /dev/null +++ b/web/tests/keyboard-inset.test.mjs @@ -0,0 +1,71 @@ +// 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')); +} +// 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'); 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 = [];