Skip to content

Commit a70a021

Browse files
calliclesclaude
andauthored
Keep the majority answer visible below a height-capped globe (#28)
* Keep the majority answer visible below a height-capped globe The globe's height cap reserved a fixed 4 rows for the info box — enough for the legend, not for the majority-answer block. On terminals both wide and tall the globe grew to that cap and the resolved values were clipped out of view. The reserve now follows what the info box will actually render: legend only while idle, plus blank + heading + one row per majority value (capped at four) once a round settles. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop changelog entry for a bug that never shipped The clipped majority-answer panel was introduced by the unreleased globe work, so no released version had it and users have nothing to note. Record that exception in the AGENTS.md changelog step. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Raise the majority-value row cap from 4 to 20 Four rows truncated realistic multi-value answers (TXT records, larger round-robin pools) for no good reason: the globe's own size floor already protects it on short terminals, so the reserve can afford the full list far more often. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent a66fefb commit a70a021

3 files changed

Lines changed: 76 additions & 11 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ methods rather than mutating state inline.
6565

6666
7. **Update `CHANGELOG.md`.** Add an entry under `[Unreleased]` in the
6767
matching Keep-a-Changelog section (`Added`/`Changed`/`Fixed`), written
68-
for users, ending with a link to the PR.
68+
for users, ending with a link to the PR. Exception: fixing a bug that was
69+
itself introduced in `[Unreleased]` gets no entry — no released version
70+
ever had the bug, so there is nothing for users to note. If the fix
71+
changes behavior that an existing `[Unreleased]` entry describes, amend
72+
that entry instead.
6973

7074
8. **Refresh the demo if visuals changed.** The README GIF is recorded with
7175
[vhs](https://github.com/charmbracelet/vhs) from `demo/demo.tape`.

src/globe.rs

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,6 @@ pub const MAP_LAT_SPAN: f64 = 127.0;
4040
/// filling available height is what keeps the continents recognizable.
4141
pub const MAP_ASPECT: f64 = MAP_LAT_SPAN / MAP_LON_SPAN * 2.0 / 4.0;
4242
pub const MAP_MAX_WIDTH: u16 = 170;
43-
/// Panel rows kept below the globe for the legend/majority-answer box.
44-
const INFO_RESERVE: u16 = 4;
4543

4644
/// Map panel dimensions and canvas zoom, all interpolated by the morph so
4745
/// the panel itself reshapes with the transition.
@@ -66,21 +64,24 @@ impl PanelGeom {
6664
}
6765

6866
/// Size the map panel at morph parameter `t`, given the columns available to
69-
/// it and the body height. The flat endpoint is the classic wide panel; the
67+
/// it, the body height, and the rows the info box below the globe currently
68+
/// needs (`info_rows`). The flat endpoint is the classic wide panel; the
7069
/// globe endpoint is a square dot grid (2 braille dots per cell horizontally,
7170
/// 4 vertically → cell height ≈ half the width) just big enough for the disc,
7271
/// so the globe earns its keep on narrow terminals instead of floating small
7372
/// inside a map-shaped canvas. Between the endpoints everything lerps:
7473
/// width, height, and zoom animate together with the coastline morph.
75-
pub fn panel_geometry(avail_width: u16, body_height: u16, t: f64) -> PanelGeom {
74+
pub fn panel_geometry(avail_width: u16, body_height: u16, t: f64, info_rows: u16) -> PanelGeom {
7675
// Floors keep the geometry sane (and division-safe) on degenerate
7776
// terminal sizes; ceilings are lifted to the floor so clamp can't panic.
7877
let flat_w = avail_width.clamp(6, MAP_MAX_WIDTH);
7978
let flat_h =
8079
((f64::from(flat_w - 2) * MAP_ASPECT).round() as u16 + 2).clamp(4, body_height.max(4));
8180
// Square dot grid for the globe, height-capped so the info box below
82-
// keeps its rows on wide-but-short terminals.
83-
let globe_h = ((flat_w - 2) / 2 + 2).clamp(4, body_height.saturating_sub(INFO_RESERVE).max(4));
81+
// keeps its rows: on terminals both wide and tall the globe would
82+
// otherwise grow past body_height − info_rows and clip the majority
83+
// answer out of view.
84+
let globe_h = ((flat_w - 2) / 2 + 2).clamp(4, body_height.saturating_sub(info_rows).max(4));
8485
let globe_w = (2 * (globe_h - 2) + 2).clamp(6, flat_w);
8586
// Degrees per dot equal in x and y ⇒ round limb, whatever the clamps did.
8687
let globe_span = MAP_LAT_SPAN * f64::from(globe_w - 2) / (2.0 * f64::from(globe_h - 2));
@@ -314,7 +315,7 @@ mod tests {
314315

315316
#[test]
316317
fn flat_geometry_matches_the_classic_panel() {
317-
let g = panel_geometry(80, 50, 0.0);
318+
let g = panel_geometry(80, 50, 0.0, 4);
318319
assert_eq!(g.width, 80);
319320
assert_eq!(g.height, (78.0 * MAP_ASPECT).round() as u16 + 2);
320321
assert!((g.x_span - MAP_LON_SPAN).abs() < EPS);
@@ -324,7 +325,7 @@ mod tests {
324325

325326
#[test]
326327
fn globe_geometry_is_a_square_dot_grid_that_fits_the_disc() {
327-
let g = panel_geometry(80, 50, 1.0);
328+
let g = panel_geometry(80, 50, 1.0, 4);
328329
// 2 dots/cell wide × 4 tall: square grid means height ≈ width/2.
329330
assert_eq!(g.height, (g.width - 2) / 2 + 2);
330331
// Zoomed so the 127° lat span fills the panel: the disc (2×RADIUS
@@ -336,7 +337,7 @@ mod tests {
336337
fn short_terminals_shrink_the_globe_panel_and_keep_it_round() {
337338
// Height-capped: the panel narrows to stay square instead of leaving
338339
// the globe floating in a wide canvas.
339-
let g = panel_geometry(160, 30, 1.0);
340+
let g = panel_geometry(160, 30, 1.0, 4);
340341
assert!(g.height <= 30 - 4);
341342
assert_eq!(g.width, 2 * (g.height - 2) + 2);
342343
// Round limb invariant: degrees per dot equal in x and y.
@@ -345,10 +346,24 @@ mod tests {
345346
assert!((per_dot_x - per_dot_y).abs() < EPS);
346347
}
347348

349+
#[test]
350+
fn info_rows_shrink_a_height_capped_globe() {
351+
// Wide and tall enough that only the height cap binds: every extra
352+
// info row comes straight out of the globe, which stays square.
353+
let legend_only = panel_geometry(160, 40, 1.0, 4);
354+
let with_answers = panel_geometry(160, 40, 1.0, 9);
355+
assert_eq!(legend_only.height, 40 - 4);
356+
assert_eq!(with_answers.height, 40 - 9);
357+
assert_eq!(with_answers.width, 2 * (with_answers.height - 2) + 2);
358+
// Width-capped globes are untouched: they already leave the rows.
359+
let small = panel_geometry(40, 40, 1.0, 4);
360+
assert_eq!(small.height, panel_geometry(40, 40, 1.0, 9).height);
361+
}
362+
348363
#[test]
349364
fn geometry_survives_degenerate_sizes() {
350365
for (w, h) in [(0, 0), (1, 1), (6, 4), (7, 50), (300, 2)] {
351-
let g = panel_geometry(w, h, 1.0);
366+
let g = panel_geometry(w, h, 1.0, 9);
352367
assert!(g.width >= 6 && g.height >= 4);
353368
assert!(g.x_span.is_finite() && g.x_span > 0.0);
354369
}

src/ui.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
4545
body.width.saturating_sub(TABLE_WIDTH),
4646
body.height,
4747
app.globe.t(Instant::now()),
48+
info_rows(app, &summary, complete),
4849
);
4950
let min_width = if app.globe.target() {
5051
MIN_WIDTH_FOR_GLOBE
@@ -77,6 +78,24 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
7778
draw_footer(frame, app, &summary, advisory, footer);
7879
}
7980

81+
/// Rows to reserve below the globe for the info box, mirroring what
82+
/// `draw_map_info` will render: borders plus the legend (which wraps to two
83+
/// lines on narrow panels), plus the majority-answer block once a round has
84+
/// settled. Passing this into the panel geometry keeps a height-capped globe
85+
/// from growing over the answers on terminals both wide and tall.
86+
fn info_rows(app: &App, summary: &Summary, complete: bool) -> u16 {
87+
let legend = 4; // two borders + the legend's up-to-two wrapped lines
88+
if complete && !summary.majority_values.is_empty() {
89+
// Blank + heading + one row per value, capped so a many-valued
90+
// record (TXT, round-robin pools) doesn't crush the globe.
91+
legend + 2 + summary.majority_values.len().min(20) as u16
92+
} else if app.queried.is_some() && app.in_flight() {
93+
legend + 2 // blank + "waiting for all resolvers…"
94+
} else {
95+
legend
96+
}
97+
}
98+
8099
/// One-line "lower your TTL before migrating" hint, shown once a round has
81100
/// settled with full agreement (the planning phase — mid-migration the advice
82101
/// comes too late) and the zone's TTL is long.
@@ -625,3 +644,30 @@ fn draw_footer(
625644
frame.render_widget(Paragraph::new(keys), keys_area);
626645
}
627646
}
647+
648+
#[cfg(test)]
649+
mod tests {
650+
use super::*;
651+
652+
#[test]
653+
fn info_rows_track_what_the_info_box_will_show() {
654+
let mut app = App::new("example.com".into());
655+
let mut summary = app.summary();
656+
657+
// Nothing queried yet: just borders + legend.
658+
assert_eq!(info_rows(&app, &summary, false), 4);
659+
660+
// Mid-round: the "waiting for all resolvers…" note needs two rows.
661+
app.begin_query().unwrap();
662+
assert_eq!(info_rows(&app, &summary, false), 6);
663+
664+
// Settled round: blank + heading + one row per majority value…
665+
app.rows = vec![RowState::Idle; app.rows.len()];
666+
summary.majority_values = vec!["192.0.2.1".into(), "192.0.2.2".into()];
667+
assert_eq!(info_rows(&app, &summary, true), 8);
668+
669+
// …capped so a many-valued record doesn't crush the globe.
670+
summary.majority_values = vec!["v".into(); 30];
671+
assert_eq!(info_rows(&app, &summary, true), 26);
672+
}
673+
}

0 commit comments

Comments
 (0)