Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/settings/PluginSettingsPane.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions build/web.zig
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,7 @@ pub fn build(
"FizzyWebKeyBuffer",
"FizzyWebKeyBound",
"FizzyWebSetFormFactor",
"FizzyWebSetKeyboardInset",
"FizzyWebImageAlloc",
"FizzyWebImageReady",
"FizzyWebImageFailed",
Expand Down
2 changes: 2 additions & 0 deletions core/core.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
108 changes: 108 additions & 0 deletions core/keyboard.zig
Original file line number Diff line number Diff line change
@@ -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);
}
5 changes: 5 additions & 0 deletions core/platform.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 2 additions & 0 deletions core/sizing_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
12 changes: 11 additions & 1 deletion core/widgets.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion plugins/workbench/src/files.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions scripts/test-web-text-input.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/backend/backend_web.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down
2 changes: 1 addition & 1 deletion src/editor/CommandPalette.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/editor/Editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/editor/dialogs/WebSaveAs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
6 changes: 6 additions & 0 deletions web/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions web/fizzy-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
93 changes: 88 additions & 5 deletions web/fizzy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}

Expand All @@ -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() }));
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading