feat(tui): allow half-width right rail

This commit is contained in:
Felipe Coury
2026-07-07 18:37:29 -03:00
parent a9ee7fec09
commit c8638b9ce8
5 changed files with 125 additions and 17 deletions
+10 -5
View File
@@ -28,9 +28,12 @@ const SIDEBAR_MIN_WIDTH: u16 = 24;
const SIDEBAR_MAX_WIDTH: u16 = 48;
const SUMMARY_DEFAULT_WIDTH: u16 = 34;
const SUMMARY_MIN_WIDTH: u16 = 30;
const SUMMARY_MAX_WIDTH: u16 = 48;
const PANEL_SCROLL_ROWS: u16 = 3;
fn right_rail_max_width(frame_width: u16) -> u16 {
frame_width / 2
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum OwnedScreenPanelPresentation {
Docked,
@@ -504,10 +507,12 @@ impl OwnedScreenFrameState {
OwnedScreenPanel::Sidebar => column
.saturating_sub(area.x)
.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH),
OwnedScreenPanel::Summary => area
.right()
.saturating_sub(column.saturating_add(PANEL_DIVIDER_WIDTH))
.clamp(SUMMARY_MIN_WIDTH, SUMMARY_MAX_WIDTH),
OwnedScreenPanel::Summary => {
let max_width = right_rail_max_width(area.width);
area.right()
.saturating_sub(column.saturating_add(PANEL_DIVIDER_WIDTH))
.clamp(SUMMARY_MIN_WIDTH.min(max_width), max_width)
}
};
self.panel_state_mut(panel).width = width;
}
@@ -32,6 +32,12 @@ impl OwnedScreenFrameState {
}
let hard_center_width = OwnedScreenLayout::minimum_width(has_side);
let preferred_center_width = PREFERRED_CENTER_WIDTH.max(hard_center_width);
let sidebar_width = self.sidebar.width;
let summary_width = self.summary.width.min(right_rail_max_width(area.width));
let panel_width = |panel| match panel {
OwnedScreenPanel::Sidebar => sidebar_width,
OwnedScreenPanel::Summary => summary_width,
};
let mut remaining_width = area.width;
let mut sidebar_docked = false;
let mut summary_docked = false;
@@ -48,31 +54,31 @@ impl OwnedScreenFrameState {
if self.preference(panel) != OwnedScreenPanelPreference::Shown {
continue;
}
let panel_width = self.panel_state(panel).width + PANEL_DIVIDER_WIDTH;
if remaining_width >= hard_center_width.saturating_add(panel_width) {
let required_width = panel_width(panel) + PANEL_DIVIDER_WIDTH;
if remaining_width >= hard_center_width.saturating_add(required_width) {
match panel {
OwnedScreenPanel::Sidebar => sidebar_docked = true,
OwnedScreenPanel::Summary => summary_docked = true,
}
remaining_width = remaining_width.saturating_sub(panel_width);
remaining_width = remaining_width.saturating_sub(required_width);
}
}
for panel in [OwnedScreenPanel::Sidebar, OwnedScreenPanel::Summary] {
if self.preference(panel) != OwnedScreenPanelPreference::Auto {
continue;
}
let panel_width = self.panel_state(panel).width + PANEL_DIVIDER_WIDTH;
if remaining_width >= preferred_center_width.saturating_add(panel_width) {
let required_width = panel_width(panel) + PANEL_DIVIDER_WIDTH;
if remaining_width >= preferred_center_width.saturating_add(required_width) {
match panel {
OwnedScreenPanel::Sidebar => sidebar_docked = true,
OwnedScreenPanel::Summary => summary_docked = true,
}
remaining_width = remaining_width.saturating_sub(panel_width);
remaining_width = remaining_width.saturating_sub(required_width);
}
}
let mut center = area;
let (sidebar, sidebar_divider) = if sidebar_docked {
let panel_area = Rect::new(center.x, center.y, self.sidebar.width, center.height);
let panel_area = Rect::new(center.x, center.y, sidebar_width, center.height);
let divider = Rect::new(
panel_area.right(),
center.y,
@@ -82,7 +88,7 @@ impl OwnedScreenFrameState {
center.x = divider.right();
center.width = center
.width
.saturating_sub(self.sidebar.width + PANEL_DIVIDER_WIDTH);
.saturating_sub(sidebar_width + PANEL_DIVIDER_WIDTH);
(
Some(OwnedScreenPanelLayout {
area: panel_area,
@@ -98,16 +104,15 @@ impl OwnedScreenFrameState {
let divider = Rect::new(
center
.right()
.saturating_sub(self.summary.width + PANEL_DIVIDER_WIDTH),
.saturating_sub(summary_width + PANEL_DIVIDER_WIDTH),
center.y,
PANEL_DIVIDER_WIDTH,
center.height,
);
let panel_area =
Rect::new(divider.right(), center.y, self.summary.width, center.height);
let panel_area = Rect::new(divider.right(), center.y, summary_width, center.height);
center.width = center
.width
.saturating_sub(self.summary.width + PANEL_DIVIDER_WIDTH);
.saturating_sub(summary_width + PANEL_DIVIDER_WIDTH);
(
Some(OwnedScreenPanelLayout {
area: panel_area,
@@ -227,6 +227,41 @@ fn divider_drag_resizes_only_the_target_panel() {
assert_eq!(state.focus(), OwnedScreenFrameFocus::Conversation);
}
#[test]
fn right_rail_resize_is_capped_at_half_the_frame_width() {
let mut state = OwnedScreenFrameState::default();
let frame_area = area(/*width*/ 144);
let initial = state.layout(frame_area, /*has_side*/ false);
let divider = initial.summary_divider.expect("summary divider");
assert!(state.handle_mouse_primary(MousePrimaryEvent {
kind: MousePrimaryEventKind::Press,
column: divider.x,
row: divider.y,
modifiers: KeyModifiers::NONE,
}));
assert!(state.handle_mouse_primary(MousePrimaryEvent {
kind: MousePrimaryEventKind::Drag,
column: frame_area.x,
row: divider.y,
modifiers: KeyModifiers::NONE,
}));
assert!(state.handle_mouse_primary(MousePrimaryEvent {
kind: MousePrimaryEventKind::Release,
column: frame_area.x,
row: divider.y,
modifiers: KeyModifiers::NONE,
}));
let resized = state.layout(frame_area, /*has_side*/ false);
assert_eq!(resized.summary.expect("summary rail").area.width, 72);
let narrower = state.layout(area(/*width*/ 120), /*has_side*/ false);
assert_eq!(narrower.summary.expect("summary rail").area.width, 60);
let restored = state.layout(frame_area, /*has_side*/ false);
assert_eq!(restored.summary.expect("summary rail").area.width, 72);
}
#[test]
fn resizing_auto_panel_keeps_it_visible_at_the_auto_breakpoint() {
let mut state = OwnedScreenFrameState::default();
@@ -363,6 +363,53 @@ async fn owned_screen_browser_renders_in_wide_right_rail() {
);
}
#[tokio::test]
async fn owned_screen_browser_renders_in_half_width_right_rail() {
let mut app = app_with_owned_parent().await;
seed_pane(
&mut app,
PaneSlot::Parent,
"draft sentinel",
&["committed response"],
);
app.owned_screen_frame
.select_right_rail_content(OwnedScreenRightRailContent::Browser);
let frame_area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 144, /*height*/ 12,
);
let layout = app
.owned_screen_frame
.layout(frame_area, /*has_side*/ false);
let divider = layout.summary_divider.expect("browser divider");
assert!(
app.owned_screen_frame
.handle_mouse_primary(primary_press(divider.x, divider.y))
);
assert!(app.owned_screen_frame.handle_mouse_primary(primary_event(
MousePrimaryEventKind::Drag,
frame_area.x,
divider.y,
)));
assert!(app.owned_screen_frame.handle_mouse_primary(primary_event(
MousePrimaryEventKind::Release,
frame_area.x,
divider.y,
)));
let browser = browser_view(/*human_control*/ false, Some((1, 2)));
let terminal = render_frame_app_with_browser(
&mut app,
frame_area.width,
frame_area.height,
Some(&browser),
);
assert_snapshot!(
"owned_screen_browser_renders_in_half_width_right_rail",
terminal.backend()
);
}
#[tokio::test]
async fn owned_screen_browser_preserves_narrow_overlay_chrome() {
let mut app = app_with_owned_parent().await;
@@ -0,0 +1,16 @@
---
source: tui/src/app/owned_screen_tests.rs
expression: terminal.backend()
---
"committed response ┃ Summary | Browser "
" ┃ [<] [>] [R] Example running "
" ┃ URL https://example.com "
" ┃Codex browser panel "
" ┃interactive page "
" ┃ready "
" ┃ "
" ┃ "
" ┃ "
"› draft sentinel ┃ "
" ┃ "
" gpt-5.5 default · /tmp/project ┃ Esc hide click page take control /browser close stop "