Skip to content

Commit f6d0ca8

Browse files
authored
Merge pull request #7 from oposs/fix/form-scroll-and-window-layout
fix(ui): reveal read-only form fields and stop window overlapping the footer
2 parents e4c645a + ce2e080 commit f6d0ca8

5 files changed

Lines changed: 356 additions & 13 deletions

File tree

CHANGES.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,21 @@ All notable changes to eDAPtor are documented here. The format follows
1212

1313
### Fixed
1414

15+
- **The main window no longer sits one row too low with its bottom edge overlaid
16+
by the footer.** The window's bounds were built in screen-absolute coordinates
17+
but applied as desktop-local ones, shifting the whole window down a row — a
18+
blank strip appeared under the menu bar and the window's bottom border landed on
19+
the status-line row. The window is now positioned in desktop-local space, so it
20+
lines up correctly.
21+
- **Read-only fields at the top or bottom of a long entry form can now be
22+
scrolled into view.** Read-only fields cannot take focus, and the entry form's
23+
scroll follows the focused field — so a run of read-only fields *below* the
24+
last editable field (or *above* the first) used to be stranded off-screen on a
25+
form taller than the pane: the last field was never visible. Up/Down on the
26+
first/last editable field now walks the viewport through that read-only
27+
head/tail one line at a time before focus moves on, and the reveal stays put
28+
instead of snapping back.
29+
1530
## 1.5.0 - 2026-07-24
1631

1732
### Changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/ui/app.rs

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1055,7 +1055,17 @@ fn init_desktop(r: Rect, state: Shared) -> Option<Box<dyn View>> {
10551055
r.b.y -= 1; // above status line
10561056
let mut desktop = Desktop::new(r, |br| Some(Desktop::init_background(br)));
10571057

1058-
let win_rect = Rect::new(r.a.x + 1, r.a.y, r.b.x - 1, r.b.y);
1058+
// The window is a desktop CHILD, so its bounds are in desktop-LOCAL
1059+
// coordinates (relative to the desktop's own origin at screen row `r.a.y`),
1060+
// NOT screen-absolute. The desktop's local extent is `(0, 0, width, height)`.
1061+
// Reusing the absolute `r.a.y` / `r.b.y` here treated row 1 as a local offset,
1062+
// shifting the whole window one row down: a blank row appeared under the menu
1063+
// and the window's bottom edge landed on the footer row. Build the rect in
1064+
// local space — fill the desktop, inset one column left/right (the former
1065+
// horizontal inset) — so it lines up before the pump flips it to fullscreen.
1066+
let win_w = r.b.x - r.a.x;
1067+
let win_h = r.b.y - r.a.y;
1068+
let win_rect = Rect::new(1, 0, win_w - 1, win_h);
10591069
let mut win = Window::new(win_rect, Some("edaptor".to_string()), 1);
10601070
// No drop shadow: as a desktop-filling frameless window it has nothing to cast
10611071
// onto, and the shadow would otherwise paint a one-cell strip over the desktop
@@ -1360,4 +1370,84 @@ mod tests {
13601370
"an empty attribute must not be sent"
13611371
);
13621372
}
1373+
1374+
/// The 80×24 headless screen rows (index 0 = menu row) of a freshly built
1375+
/// program, pumped a few times WITH events queued so no idle pass fires — i.e.
1376+
/// the pre-fullscreen bordered window the base `win_rect` governs.
1377+
#[cfg(test)]
1378+
fn bordered_screen_rows() -> Vec<String> {
1379+
use crate::ldap::worker::RawSubschema;
1380+
use crate::schema::SchemaModel;
1381+
use crate::ui::state::UiState;
1382+
use crate::workflows::structure::Structure;
1383+
let structure = Structure::build("dc=x", vec![]);
1384+
let schema = SchemaModel::from_raw(&RawSubschema::default());
1385+
let st = UiState::new_for_test(structure, schema, "dc=x".into(), Vec::new(), Vec::new());
1386+
let shared: Shared = std::rc::Rc::new(std::cell::RefCell::new(st));
1387+
1388+
let (backend, handle) = tv::HeadlessBackend::new(80, 24);
1389+
let mut prog = build_program(Box::new(backend), shared);
1390+
// Keep the event queue non-empty every pass so no idle pass fires the pump's
1391+
// fullscreen timer: this pins the bordered layout the base win_rect drives.
1392+
for _ in 0..5 {
1393+
handle.push_event(tv::Event::KeyDown(tv::KeyEvent::from(tv::Key::F(9))));
1394+
prog.pump_once();
1395+
}
1396+
// Extract the `text:` block of the snapshot: 24 rows each framed by `|…|`.
1397+
handle
1398+
.snapshot()
1399+
.lines()
1400+
.skip_while(|l| *l != "text:")
1401+
.skip(1)
1402+
.take_while(|l| l.starts_with('|'))
1403+
.map(|l| l.trim_start_matches('|').trim_end_matches('|').to_string())
1404+
.collect()
1405+
}
1406+
1407+
/// Regression: the main window must sit in desktop-LOCAL coordinates. A
1408+
/// screen-absolute `win_rect` shifted it one row down — leaving a blank
1409+
/// desktop row under the menu and pushing the window's bottom border onto the
1410+
/// footer row. Assert the window frame starts on row 1 (right under the menu)
1411+
/// and the footer alone occupies the last row.
1412+
#[test]
1413+
fn main_window_is_not_shifted_onto_the_footer() {
1414+
let rows = bordered_screen_rows();
1415+
assert_eq!(rows.len(), 24, "80x24 screen has 24 text rows");
1416+
1417+
// Row 0 is the menu bar.
1418+
assert!(
1419+
rows[0].contains("File"),
1420+
"row 0 is the menu bar: {:?}",
1421+
rows[0]
1422+
);
1423+
1424+
// Row 1 must be the window's top border (frame glyphs), NOT a blank desktop
1425+
// row. Before the fix this row was all background (`░`) + spaces.
1426+
assert!(
1427+
rows[1].contains('═') || rows[1].contains("edaptor"),
1428+
"row 1 must be the window top border, not a blank gap under the menu: {:?}",
1429+
rows[1]
1430+
);
1431+
1432+
// The last row is the footer/status line, and it must not be overlaid by
1433+
// the window's bottom border — no box-drawing frame glyphs on it.
1434+
let footer = &rows[23];
1435+
assert!(
1436+
footer.contains("Alt-N"),
1437+
"last row is the status line: {:?}",
1438+
footer
1439+
);
1440+
assert!(
1441+
!footer.contains('═') && !footer.contains('┘') && !footer.contains('╝'),
1442+
"the window bottom border must not overlap the footer row: {:?}",
1443+
footer
1444+
);
1445+
1446+
// The window's bottom border lives on the second-to-last row instead.
1447+
assert!(
1448+
rows[22].contains('┘') || rows[22].contains('═'),
1449+
"row 22 holds the window bottom border: {:?}",
1450+
rows[22]
1451+
);
1452+
}
13631453
}

src/ui/panes/form.rs

Lines changed: 168 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,13 @@ impl FormPane {
786786
self.group.focus_child(scroll_id, ctx);
787787
if let Some(sg) = self.scroll_mut() {
788788
sg.focus_child(id, ctx);
789+
// Actively snap the landed field into view. The per-event settle
790+
// (region-aware) would otherwise leave a first/last focusable field
791+
// off-screen on open when it sits below a tall read-only head (its
792+
// whole region is a scroll fixed point), so land it explicitly.
793+
if let Some(logical) = sg.logical_of(id) {
794+
sg.ensure_visible(logical, ctx);
795+
}
789796
}
790797
// Always land with the caret homed to the start of the value (not the
791798
// select-all-to-end block Turbo Vision leaves on focus). This covers a
@@ -860,12 +867,22 @@ impl FormPane {
860867
None => ids.len() - 1,
861868
};
862869
let next_id = ids[next];
870+
let moved = cur != Some(next_id);
863871
if let Some(sg) = self.scroll_mut() {
864872
sg.focus_child(next_id, ctx);
865-
// Scroll immediately so the newly focused field — and thus the
866-
// hardware cursor — is on screen this frame, not one pump tick later.
867-
if let Some(logical) = sg.logical_of(next_id) {
868-
sg.ensure_visible(logical, ctx);
873+
if moved {
874+
// Focus actually advanced: snap the newly focused field fully into
875+
// view this frame (so the hardware cursor is on screen now, not one
876+
// pump tick later).
877+
if let Some(logical) = sg.logical_of(next_id) {
878+
sg.ensure_visible(logical, ctx);
879+
}
880+
} else {
881+
// Clamped at an end (Down on the last / Up on the first focusable
882+
// field): don't re-snap to the bare field rect — that would yank a
883+
// deliberately scrolled-in read-only head/tail back off screen. Use
884+
// the region-aware settle, which leaves an in-band scroll alone.
885+
sg.ensure_focused_visible(ctx);
869886
}
870887
}
871888
// Enter the field with the caret at the start, not a select-all block.
@@ -1374,8 +1391,21 @@ impl View for FormPane {
13741391
self.group.handle_event(ev, ctx);
13751392
}
13761393
} else if nav {
1394+
// Plain (non-list, non-launch) focused field. If it is the FIRST or
1395+
// LAST focusable field and the form has a non-focusable read-only
1396+
// head/tail, Up/Down first walks the viewport through that hidden
1397+
// region (read-only cells never take focus, so this is the only way to
1398+
// bring a trailing/leading read-only field into view); only once the
1399+
// region's near edge is visible does focus advance to the prev/next
1400+
// field.
13771401
let down = matches!(ev, Event::KeyDown(k) if k.key == Key::Down);
1378-
self.focus_field(if down { 1 } else { -1 }, ctx);
1402+
let scrolled = self
1403+
.scroll_mut()
1404+
.map(|sg| sg.scroll_focus_region_edge(down, 1, ctx))
1405+
.unwrap_or(false);
1406+
if !scrolled {
1407+
self.focus_field(if down { 1 } else { -1 }, ctx);
1408+
}
13791409
ev.clear();
13801410
} else if is_keydown && self.focused_is_launch_view() {
13811411
// Action key on a read-only launch/list block: route it to the focused
@@ -2610,6 +2640,139 @@ mod tests {
26102640
);
26112641
}
26122642

2643+
#[test]
2644+
fn trailing_readonly_tail_scrolls_into_view() {
2645+
// Reported bug: read-only fields can never take focus, and the form's
2646+
// scroll anchors on the focused field — so a run of read-only fields
2647+
// BELOW the last focusable field (past the last visible row of a long
2648+
// form) can never be scrolled into view. Pressing Down on the last
2649+
// focusable field must walk the viewport through that trailing read-only
2650+
// tail one line at a time, instead of clamping in place.
2651+
let mut fields = vec![ef("cn", "z", true)]; // the only focusable field
2652+
for i in 0..10 {
2653+
fields.push(ef(&format!("ro{i}"), &format!("v{i}"), false));
2654+
}
2655+
let (_shared, mut pane) = build_pane_with_form(fields);
2656+
// 6-row viewport (rows 2..8); content is 11 rows → max_top 5.
2657+
<FormPane as View>::change_bounds(&mut pane, Rect::new(0, 0, 80, 8));
2658+
let mut out = VecDeque::new();
2659+
let mut timers = tv::timer::TimerQueue::new();
2660+
let mut deferred = Vec::new();
2661+
let mut tick = Event::Broadcast {
2662+
command: REFRESH,
2663+
source: None,
2664+
};
2665+
pane.handle_event(
2666+
&mut tick,
2667+
&mut headless_ctx(&mut out, &mut timers, &mut deferred),
2668+
);
2669+
2670+
assert_eq!(
2671+
pane.focusable_value_ids().len(),
2672+
1,
2673+
"cn is the only focusable field; the ro* tail is read-only"
2674+
);
2675+
let max_top = pane.scroll_mut().unwrap().max_top();
2676+
assert_eq!(max_top, 5, "content (11) overflows the 6-row viewport");
2677+
2678+
// Down past the last focusable field scrolls the read-only tail into view
2679+
// one line at a time (before the fix this did nothing).
2680+
for expected_top in 1..=max_top {
2681+
let mut d = Event::KeyDown(tv::KeyEvent::from(tv::Key::Down));
2682+
pane.handle_event(
2683+
&mut d,
2684+
&mut headless_ctx(&mut out, &mut timers, &mut deferred),
2685+
);
2686+
assert_eq!(
2687+
pane.scroll_mut().unwrap().top_for_test(),
2688+
expected_top,
2689+
"Down scrolls the read-only tail into view one line at a time"
2690+
);
2691+
}
2692+
2693+
// A passive event (a REFRESH tick) must NOT yank the scroll back to the
2694+
// focused field: the tail stays revealed.
2695+
let mut tick2 = Event::Broadcast {
2696+
command: REFRESH,
2697+
source: None,
2698+
};
2699+
pane.handle_event(
2700+
&mut tick2,
2701+
&mut headless_ctx(&mut out, &mut timers, &mut deferred),
2702+
);
2703+
assert_eq!(
2704+
pane.scroll_mut().unwrap().top_for_test(),
2705+
max_top,
2706+
"the revealed tail is stable across passive re-anchor events"
2707+
);
2708+
2709+
// At the bottom, further Down is a no-op (nothing left to reveal).
2710+
let mut d = Event::KeyDown(tv::KeyEvent::from(tv::Key::Down));
2711+
pane.handle_event(
2712+
&mut d,
2713+
&mut headless_ctx(&mut out, &mut timers, &mut deferred),
2714+
);
2715+
assert_eq!(pane.scroll_mut().unwrap().top_for_test(), max_top);
2716+
}
2717+
2718+
#[test]
2719+
fn leading_readonly_head_scrolls_into_view() {
2720+
// Symmetric to the trailing tail: a run of read-only fields ABOVE the
2721+
// first focusable field. On open the focused field must be visible (the
2722+
// scroll snaps to show it, head partly above), and Up must then walk the
2723+
// viewport up through the read-only head one line at a time.
2724+
let mut fields: Vec<EditField> = (0..10)
2725+
.map(|i| ef(&format!("ro{i}"), &format!("v{i}"), false))
2726+
.collect();
2727+
fields.push(ef("cn", "z", true)); // the only focusable field, at the bottom
2728+
let (_shared, mut pane) = build_pane_with_form(fields);
2729+
// 6-row viewport (rows 2..8); content is 11 rows → max_top 5.
2730+
<FormPane as View>::change_bounds(&mut pane, Rect::new(0, 0, 80, 8));
2731+
let mut out = VecDeque::new();
2732+
let mut timers = tv::timer::TimerQueue::new();
2733+
let mut deferred = Vec::new();
2734+
let mut tick = Event::Broadcast {
2735+
command: REFRESH,
2736+
source: None,
2737+
};
2738+
pane.handle_event(
2739+
&mut tick,
2740+
&mut headless_ctx(&mut out, &mut timers, &mut deferred),
2741+
);
2742+
2743+
assert_eq!(pane.focusable_value_ids().len(), 1, "only cn is focusable");
2744+
// cn is the last content row (row 10); to show it the 6-row viewport must
2745+
// scroll to top=5 (rows 5..11) on open — not sit at top=0 hiding it.
2746+
assert_eq!(
2747+
pane.scroll_mut().unwrap().top_for_test(),
2748+
5,
2749+
"the focused field is snapped into view on open, not hidden by the head"
2750+
);
2751+
2752+
// Up past the first focusable field walks the read-only head into view one
2753+
// line at a time (top counts back down to 0).
2754+
for expected_top in (0..=4).rev() {
2755+
let mut u = Event::KeyDown(tv::KeyEvent::from(tv::Key::Up));
2756+
pane.handle_event(
2757+
&mut u,
2758+
&mut headless_ctx(&mut out, &mut timers, &mut deferred),
2759+
);
2760+
assert_eq!(
2761+
pane.scroll_mut().unwrap().top_for_test(),
2762+
expected_top,
2763+
"Up scrolls the read-only head into view one line at a time"
2764+
);
2765+
}
2766+
2767+
// At the top, further Up is a no-op.
2768+
let mut u = Event::KeyDown(tv::KeyEvent::from(tv::Key::Up));
2769+
pane.handle_event(
2770+
&mut u,
2771+
&mut headless_ctx(&mut out, &mut timers, &mut deferred),
2772+
);
2773+
assert_eq!(pane.scroll_mut().unwrap().top_for_test(), 0);
2774+
}
2775+
26132776
#[test]
26142777
fn short_launch_block_advances_focus_immediately() {
26152778
// A Launch block that already fits the viewport must behave exactly as

0 commit comments

Comments
 (0)