From a70b523e9acedec99ae1d426e1bc00f7eeb6e78e Mon Sep 17 00:00:00 2001 From: foxnne Date: Fri, 16 Oct 2026 09:37:05 -0500 Subject: [PATCH] chat: a click away from selected text clears it A selection in the transcript stayed when the person clicked empty space, another message or the message box, so two or three messages could show a selection at once and none went away. Now the selection goes when focus leaves the text it is in, as in a document. dvui keeps a text layout's selection after it loses focus. The chat tracks which widget inside the transcript holds focus (`lastFocusedIdInFrameSince` around the scroll area) and, when that changes, tells the old text to select nothing with `Window.addEventTextSelect` at the top of the next frame (the plugin's `beginFrame`, before any widget runs, as dvui asks of added events). dvui scrolls a text's cursor into view when it is given a selection, so that frame keeps the viewport where it was put. --- plugin.zig | 1 + src/View.zig | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/plugin.zig b/plugin.zig index 0009d97..8a724c5 100644 --- a/plugin.zig +++ b/plugin.zig @@ -96,6 +96,7 @@ fn drawExplorer(_: ?*anyopaque) anyerror!dvui.App.Result { fn beginFrame(_: *anyopaque) void { AcpClient.frame(); Explorer.frame(sdk.host()); + for (chats.values()) |c| View.beginFrame(c); } fn deinit(_: *anyopaque) void { diff --git a/src/View.zig b/src/View.zig index f3fcc9a..9cea159 100644 --- a/src/View.zig +++ b/src/View.zig @@ -44,6 +44,13 @@ pub const State = struct { /// How much of each arriving text block is shown, by `partKey`: it runs to catch up with what /// has streamed in, so text arrives smoothly rather than in the agent's bursts. reveal: std.AutoHashMapUnmanaged(u64, f32) = .empty, + /// The widget in the transcript that has focus: the text the person last pressed in (a + /// paragraph, a note, a tool's output), or the scroll area after a click on empty space. + focused: ?dvui.Id = null, + /// A text that lost focus, whose selection goes at the top of the next frame (`beginFrame`). + deselect: ?dvui.Id = null, + /// `beginFrame` cleared a selection this frame. + deselected: bool = false, const Kept = struct { version: u32 = std.math.maxInt(u32), @@ -138,6 +145,7 @@ fn drawTranscript(c: *Chat) void { st.scroll.viewport.y = @max(0, topOf(st, st.anchor_message) + st.anchor_offset); } st.set_y = st.scroll.viewport.y; + const focus_before = dvui.lastFocusedIdInFrame(); var scroll = dvui.scrollArea(@src(), .{ .scroll_info = &st.scroll, .vertical_bar = .auto_overlay, .horizontal_bar = .hide }, .{ .expand = .both }); const width = if (st.scroll.viewport.w > 0) st.scroll.viewport.w else scroll.data().contentRect().w; @@ -170,6 +178,7 @@ fn drawTranscript(c: *Chat) void { if (c.busy and !streaming(c)) drawTyping(c, width); } scroll.deinit(); + trackFocus(st, focus_before); // Following stops the moment the person scrolls up from where it was put (a wheel, a drag: // a trackpad's first step is a pixel or two, which a looser test snapped back to the bottom, @@ -210,6 +219,34 @@ fn drawTranscript(c: *Chat) void { } } +/// A selection is one text's (dvui selects within one text layout, and markdown lays out each +/// paragraph as its own), so the one the person made goes when they press anywhere else: in +/// another message, on empty space, in the message box, elsewhere in fizzy. dvui keeps a text's +/// selection when it loses focus, so the chat clears it, as a document's goes when you click away. +fn trackFocus(st: *State, before: dvui.Id) void { + const now = dvui.lastFocusedIdInFrameSince(before); + if (st.focused) |old| if (now == null or now.? != old) { + st.deselect = old; + dvui.refresh(null, @src(), null); + }; + st.focused = now; + // Told to select nothing, a text scrolls its cursor into view (dvui does for any selection + // it is given): the reader stays where they were. + if (st.deselected) { + st.deselected = false; + st.scroll.viewport.y = st.set_y; + } +} + +/// The top of a frame: tell the text that lost focus to select nothing. Events go in before any +/// widget runs, so this is the frame's start rather than where the chat draws. +pub fn beginFrame(c: *Chat) void { + const id = c.view.deselect orelse return; + c.view.deselect = null; + _ = dvui.currentWindow().addEventTextSelect(.{ .start = 0, .end = 0, .target_id = id }) catch return; + c.view.deselected = true; +} + fn topOf(st: *const State, message: u32) f32 { var y: f32 = 10; for (st.kept.items[0..@min(message, st.kept.items.len)]) |k| y += k.h;