Skip to content

Commit b746b46

Browse files
committed
feat: add snapped plot hover probe
Keep plot controls stable by moving hover coordinates into a dedicated readout row above the status line. Snap hover positions to the nearest visible data point and render a subtle vertical guide with highlighted samples in hover-specific cached frames, leaving normal no-hover cache and prefetch behavior untouched.
1 parent ab3c9ea commit b746b46

10 files changed

Lines changed: 388 additions & 84 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ matching `## [X.Y.Z]` section before the release tag is pushed.
1010

1111
### Added
1212

13-
- Add read-only plot hover readouts that show the hovered x value and nearest
14-
visible point coordinates in the status bar without changing the viewport.
13+
- Add read-only plot hover readouts that snap to the nearest visible point,
14+
show a separate coordinate row above the controls, and draw a subtle vertical
15+
hover line with highlighted snapped points.
1516

1617
## [0.2.2] - 2026-06-18
1718

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@ Common controls:
4949
- `0`: fit to terminal
5050
- arrow keys: pan
5151
- `m`: toggle metadata or plot summary overlay
52-
- mouse hover over plots: show the nearest visible point coordinates
52+
- mouse hover over plots: show a snapped vertical readout and nearest visible
53+
point coordinates
5354
- left mouse drag: pan image inputs
5455

5556
Most users do not need to pass `--protocol`. The default is `auto`:
@@ -236,9 +237,9 @@ Plot data:
236237
during idle time, and avoids full-screen clears for image protocol frames.
237238
Background preloads never replace the currently visible frame, so rapid
238239
zooming and panning keep axis labels and chart body in sync.
239-
- Plot mouse hover is read-only. Moving the mouse over the chart body updates
240-
the status bar with the hover `x` value and nearest visible point coordinates
241-
without changing the viewport.
240+
- Plot mouse hover is read-only. Moving the mouse over the chart body snaps to
241+
the nearest visible point, draws a subtle vertical readout line, and shows a
242+
separate coordinate row above the controls without changing the viewport.
242243
- Pixel-protocol plot viewing keeps chart chrome as real terminal text and
243244
sends only the plot body through the image protocol. This keeps file names,
244245
legends, axis labels, and controls crisp while reducing image payload size.

docs/architecture.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,9 +120,13 @@ future same-zoom pan frames, then composite those marks over the current
120120
grid/frame layer so axis labels and grid lines stay correct.
121121

122122
Mouse input in the plot viewer is read-only. Hover events remember the latest
123-
terminal cell and update only the status chrome with a data-space x value plus
124-
nearest visible point coordinates; they do not pan, zoom, or invalidate the
125-
current plot image.
123+
terminal cell, snap to the nearest visible plot point, and render a separate
124+
coordinate readout row above the controls. Rasterized plot frames include a
125+
subtle vertical hover line and highlighted snapped points in the chart body;
126+
the block fallback receives the same overlay before it is converted to terminal
127+
text. Hover does not pan or zoom, but it does create a hover-specific plot
128+
frame so the foreground image can reflect the current readout without
129+
contaminating the normal no-hover frame cache or prefetch path.
126130

127131
Kitty plot frames use zlib-compressed raw RGBA direct-data payloads so terminal
128132
updates avoid PNG decode work while still working when the terminal process

src/tui.rs

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,11 @@ impl TerminalSession {
108108
self.stdout,
109109
MoveTo(0, 0),
110110
Clear(ClearType::All),
111-
Print(content_to_lines(content, size))
111+
Print(content_to_lines(
112+
content,
113+
size.content_height().max(1),
114+
size.width
115+
))
112116
)
113117
.context("drawing terminal frame")?;
114118
if size.height > 0 {
@@ -118,6 +122,45 @@ impl TerminalSession {
118122
Ok(())
119123
}
120124

125+
pub(crate) fn draw_frame_with_readout(
126+
&mut self,
127+
content: &str,
128+
readout: &ChromeLine,
129+
status: &str,
130+
) -> Result<()> {
131+
let size = self.size()?;
132+
let content_rows = size.height.saturating_sub(2).max(1);
133+
queue!(
134+
self.stdout,
135+
MoveTo(0, 0),
136+
Clear(ClearType::All),
137+
Print(content_to_lines(content, content_rows, size.width))
138+
)
139+
.context("drawing terminal frame with readout")?;
140+
if size.height > 1 {
141+
draw_chrome_line(
142+
&mut self.stdout,
143+
size.height.saturating_sub(2),
144+
size.width.max(1),
145+
PLOT_CHROME_BG,
146+
Color::Rgb {
147+
r: 41,
148+
g: 54,
149+
b: 57,
150+
},
151+
readout,
152+
)
153+
.context("drawing terminal frame readout")?;
154+
}
155+
if size.height > 0 {
156+
self.draw_status_line(size, status)?;
157+
}
158+
self.stdout
159+
.flush()
160+
.context("flushing terminal frame with readout")?;
161+
Ok(())
162+
}
163+
121164
pub(crate) fn draw_protocol_frame(&mut self, payload: &str, status: &str) -> Result<()> {
122165
let size = self.size()?;
123166
queue!(self.stdout, MoveTo(0, 0), Print(payload)).context("drawing protocol payload")?;
@@ -139,6 +182,7 @@ impl TerminalSession {
139182
self.draw_plot_dynamic_chrome(size, &frame)?;
140183
self.draw_plot_axis_labels(&frame)?;
141184
if size.height > 0 {
185+
self.draw_plot_readout_line(size, &frame)?;
142186
self.draw_plot_status_line(size, &frame.chrome.dynamic_layer.status)?;
143187
}
144188
queue!(
@@ -202,6 +246,17 @@ impl TerminalSession {
202246
.context("painting plot x-axis row")?;
203247
}
204248

249+
if frame.chrome.dynamic_layer.readout_row < size.height.saturating_sub(1) {
250+
paint_row(
251+
&mut self.stdout,
252+
0,
253+
frame.chrome.dynamic_layer.readout_row,
254+
size.width,
255+
PLOT_CHROME_BG,
256+
)
257+
.context("painting plot readout row")?;
258+
}
259+
205260
Ok(())
206261
}
207262

@@ -334,6 +389,30 @@ impl TerminalSession {
334389
Ok(())
335390
}
336391

392+
fn draw_plot_readout_line(
393+
&mut self,
394+
size: TerminalSize,
395+
frame: &PlotProtocolFrame<'_>,
396+
) -> Result<()> {
397+
if frame.chrome.dynamic_layer.readout_row >= size.height.saturating_sub(1) {
398+
return Ok(());
399+
}
400+
draw_chrome_line(
401+
&mut self.stdout,
402+
frame.chrome.dynamic_layer.readout_row,
403+
size.width.max(1),
404+
PLOT_CHROME_BG,
405+
Color::Rgb {
406+
r: 41,
407+
g: 54,
408+
b: 57,
409+
},
410+
&frame.chrome.dynamic_layer.readout,
411+
)
412+
.context("drawing plot readout line")?;
413+
Ok(())
414+
}
415+
337416
pub(crate) fn stop(&mut self) -> Result<()> {
338417
if !self.active {
339418
return Ok(());
@@ -373,17 +452,17 @@ impl Drop for TerminalSession {
373452
}
374453
}
375454

376-
fn content_to_lines(content: &str, size: TerminalSize) -> String {
377-
let width = usize::from(size.width.max(1));
378-
let content_rows = usize::from(size.content_height().max(1));
455+
fn content_to_lines(content: &str, content_rows: u16, width: u16) -> String {
456+
let width = usize::from(width.max(1));
457+
let content_rows = usize::from(content_rows.max(1));
379458
let mut output = String::new();
380459
for (row, line) in content.lines().take(content_rows).enumerate() {
381460
if line.contains('\u{1b}') {
382461
output.push_str(line);
383462
} else {
384463
output.push_str(&trim_to_width(line, width));
385464
}
386-
if row + 1 < content_rows || size.height > 1 {
465+
if row + 1 < content_rows {
387466
output.push_str("\r\n");
388467
}
389468
}
@@ -421,13 +500,7 @@ mod tests {
421500

422501
#[test]
423502
fn frame_content_uses_crlf_for_raw_terminal_mode() {
424-
let output = content_to_lines(
425-
"alpha\nbeta",
426-
TerminalSize {
427-
width: 8,
428-
height: 3,
429-
},
430-
);
503+
let output = content_to_lines("alpha\nbeta", 2, 8);
431504

432505
assert!(output.contains("alpha \r\nbeta"));
433506
assert!(!output.contains("alpha \nbeta"));

src/tui/plot_frame.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ pub(crate) struct DynamicPlotChrome {
3333
pub(crate) header: ChromeLine,
3434
pub(crate) y_labels: Vec<PlotAxisLabel>,
3535
pub(crate) x_labels: Vec<PlotAxisLabel>,
36+
pub(crate) readout_row: u16,
37+
pub(crate) readout: ChromeLine,
3638
pub(crate) status: ChromeLine,
3739
}
3840

0 commit comments

Comments
 (0)