aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-03 16:58:16 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-03 16:58:16 +0300
commitf159ef69ffe59fd327a4a4f3c4a53aaea567b32a (patch)
tree829168c8d518ddb908ac744227a3241ce250f0f6 /src
parent282fb654649ea89cd9b22b1dcf43e435a18c7179 (diff)
refactor modal section spacing layout
Diffstat (limited to 'src')
-rw-r--r--src/app/input.rs55
-rw-r--r--src/ui.rs204
2 files changed, 146 insertions, 113 deletions
diff --git a/src/app/input.rs b/src/app/input.rs
index a1ad6d0c..7191a8fa 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -1234,15 +1234,7 @@ impl AppState {
if inner.height < 8 || inner.width < 4 {
return None;
}
- let rows = ratatui::layout::Layout::vertical([
- ratatui::layout::Constraint::Length(1),
- ratatui::layout::Constraint::Length(1),
- ratatui::layout::Constraint::Length(1),
- ratatui::layout::Constraint::Min(1),
- ratatui::layout::Constraint::Length(1),
- ])
- .areas::<5>(inner);
- Some(rows[3])
+ Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content)
}
fn release_notes_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
@@ -1334,12 +1326,10 @@ impl AppState {
let Some(inner) = self.onboarding_modal_inner(64, 16) else {
return;
};
- let button = crate::ui::onboarding_welcome_continue_rect(Rect::new(
- inner.x,
- inner.y + 10,
- inner.width,
- 1,
- ));
+ let actions = crate::ui::modal_stack_areas(inner, 2, 0, 1, 1)
+ .actions
+ .unwrap_or_default();
+ let button = crate::ui::onboarding_welcome_continue_rect(actions);
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
&& modal_action_from_buttons(
mouse.column,
@@ -1354,19 +1344,16 @@ impl AppState {
let Some(inner) = self.onboarding_modal_inner(56, 14) else {
return;
};
- let options_start_y = inner.y + 5;
- if mouse.row >= options_start_y && mouse.row < options_start_y + 4 {
+ let stack = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1);
+ if mouse.row >= stack.content.y && mouse.row < stack.content.y + 4 {
self.onboarding_list
- .select((mouse.row - options_start_y) as usize);
+ .select((mouse.row - stack.content.y) as usize);
return;
}
- let (back, save) = crate::ui::onboarding_notification_button_rects(Rect::new(
- inner.x,
- inner.y + 10,
- inner.width,
- 1,
- ));
+ let (back, save) = crate::ui::onboarding_notification_button_rects(
+ stack.actions.unwrap_or_default(),
+ );
if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
match modal_action_from_buttons(
mouse.column,
@@ -1466,14 +1453,7 @@ impl AppState {
if inner.height < 6 || inner.width < 4 {
return None;
}
- let rows = ratatui::layout::Layout::vertical([
- ratatui::layout::Constraint::Length(1),
- ratatui::layout::Constraint::Length(1),
- ratatui::layout::Constraint::Min(1),
- ratatui::layout::Constraint::Length(1),
- ])
- .areas::<4>(inner);
- Some(rows[2])
+ Some(crate::ui::modal_stack_areas(inner, 2, 1, 0, 1).content)
}
fn keybind_help_scroll_metrics(&self) -> Option<crate::pane::ScrollMetrics> {
@@ -1577,8 +1557,7 @@ impl AppState {
fn settings_content_rect(&self) -> Rect {
let inner = self.settings_inner_rect();
- let y = inner.y + 3;
- Rect::new(inner.x, y, inner.width, inner.y + inner.height - y)
+ crate::ui::modal_stack_areas(inner, 3, 2, 0, 1).content
}
fn settings_list_index_at(&self, col: u16, row: u16) -> Option<usize> {
@@ -2897,7 +2876,8 @@ mod tests {
app.state.onboarding_list.select(1);
let inner = app.state.onboarding_modal_inner(56, 14).unwrap();
- app.handle_mouse(mouse(MouseEventKind::Moved, inner.x + 2, inner.y + 7));
+ let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content;
+ app.handle_mouse(mouse(MouseEventKind::Moved, content.x + 2, content.y));
assert_eq!(app.state.onboarding_list.selected, 1);
}
@@ -2910,10 +2890,11 @@ mod tests {
app.state.onboarding_list.select(0);
let inner = app.state.onboarding_modal_inner(56, 14).unwrap();
+ let content = crate::ui::modal_stack_areas(inner, 3, 0, 1, 1).content;
app.handle_mouse(mouse(
MouseEventKind::Down(MouseButton::Left),
- inner.x + 2,
- inner.y + 7,
+ content.x + 2,
+ content.y + 2,
));
assert_eq!(app.state.onboarding_list.selected, 2);
diff --git a/src/ui.rs b/src/ui.rs
index c708c7fb..7ea3afd0 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -1121,6 +1121,71 @@ fn render_modal_header(frame: &mut Frame, area: Rect, title: &str, p: &Palette)
);
}
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct ModalStackAreas {
+ pub header: Rect,
+ pub content: Rect,
+ pub footer: Option<Rect>,
+ pub actions: Option<Rect>,
+}
+
+pub(crate) fn modal_stack_areas(
+ inner: Rect,
+ header_height: u16,
+ footer_height: u16,
+ actions_height: u16,
+ gap: u16,
+) -> ModalStackAreas {
+ #[derive(Clone, Copy)]
+ enum Slot {
+ Header,
+ Content,
+ Footer,
+ Actions,
+ }
+
+ let mut constraints = Vec::new();
+ let mut slots = Vec::new();
+ let mut push = |slot: Slot, constraint: Constraint| {
+ if !slots.is_empty() {
+ constraints.push(Constraint::Length(gap));
+ }
+ constraints.push(constraint);
+ slots.push(slot);
+ };
+
+ push(Slot::Header, Constraint::Length(header_height));
+ push(Slot::Content, Constraint::Min(0));
+ if footer_height > 0 {
+ push(Slot::Footer, Constraint::Length(footer_height));
+ }
+ if actions_height > 0 {
+ push(Slot::Actions, Constraint::Length(actions_height));
+ }
+
+ let areas = Layout::vertical(constraints).split(inner);
+ let mut header = Rect::default();
+ let mut content = Rect::default();
+ let mut footer = None;
+ let mut actions = None;
+
+ for (slot, area) in slots.into_iter().zip(areas.iter().step_by(2).copied()) {
+ match slot {
+ Slot::Header => header = area,
+ Slot::Content => content = area,
+ Slot::Footer => footer = Some(area),
+ Slot::Actions => actions = Some(area),
+ }
+ }
+
+ ModalStackAreas {
+ header,
+ content,
+ footer,
+ actions,
+ }
+}
+
fn render_onboarding_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
dim_background(frame, area);
@@ -1144,24 +1209,24 @@ fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
return;
}
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(1),
- Constraint::Length(1),
- ])
- .areas::<5>(inner);
+ let stack = modal_stack_areas(inner, 2, 1, 0, 1);
+ let header_rows =
+ Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
- render_modal_header(frame, rows[0], &format!("v{}", notes.version), &app.palette);
+ render_modal_header(
+ frame,
+ header_rows[0],
+ &format!("v{}", notes.version),
+ &app.palette,
+ );
frame.render_widget(
Paragraph::new(" what's new in this release")
.style(Style::default().fg(app.palette.overlay1)),
- rows[1],
+ header_rows[1],
);
render_action_button(
frame,
- release_notes_close_button_rect(rows[0]),
+ release_notes_close_button_rect(header_rows[0]),
Some("esc"),
"close",
Style::default()
@@ -1170,7 +1235,7 @@ fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
.add_modifier(Modifier::BOLD),
);
- let body_area = rows[3];
+ let body_area = stack.content;
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: app.release_notes_max_scroll().saturating_sub(notes.scroll) as usize,
max_offset_from_bottom: app.release_notes_max_scroll() as usize,
@@ -1216,7 +1281,7 @@ fn render_release_notes_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
Span::styled("close", Style::default().fg(app.palette.overlay0)),
Span::styled(" q / esc / enter ", Style::default().fg(app.palette.text)),
])),
- rows[4],
+ stack.footer.unwrap_or_default(),
);
}
@@ -1330,21 +1395,16 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) {
return;
}
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
+ let stack = modal_stack_areas(inner, 2, 0, 1, 1);
+ let header_rows =
+ Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
+ let content_rows = Layout::vertical([
+ Constraint::Length(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
- .areas::<12>(inner);
+ .areas::<4>(stack.content);
frame.render_widget(
Paragraph::new(" herdr").style(
@@ -1352,28 +1412,20 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) {
.fg(app.palette.text)
.add_modifier(Modifier::BOLD),
),
- rows[1],
+ header_rows[0],
);
frame.render_widget(
Paragraph::new(" terminal workspace manager for coding agents")
.style(Style::default().fg(app.palette.overlay0)),
- rows[2],
+ header_rows[1],
);
frame.render_widget(
- Paragraph::new(" this is a mouse-first terminal.")
- .style(Style::default().fg(app.palette.overlay1)),
- rows[4],
- );
- frame.render_widget(
- Paragraph::new(" click the sidebar to switch workspaces, drag pane")
- .style(Style::default().fg(app.palette.overlay1)),
- rows[5],
- );
- frame.render_widget(
- Paragraph::new(" borders to resize, right-click for context menus.")
- .style(Style::default().fg(app.palette.overlay1)),
- rows[6],
+ Paragraph::new(
+ " this is a mouse-first terminal.\n click the sidebar to switch workspaces, drag pane\n borders to resize, right-click for context menus.",
+ )
+ .style(Style::default().fg(app.palette.overlay1)),
+ content_rows[0],
);
let key_line = Line::from(vec![
@@ -1399,9 +1451,9 @@ fn render_onboarding_welcome(app: &AppState, frame: &mut Frame, area: Rect) {
Style::default().fg(app.palette.overlay1),
),
]);
- frame.render_widget(Paragraph::new(key_line), rows[8]);
+ frame.render_widget(Paragraph::new(key_line), content_rows[2]);
- let continue_rect = onboarding_welcome_continue_rect(rows[10]);
+ let continue_rect = onboarding_welcome_continue_rect(stack.actions.unwrap_or_default());
render_action_button(
frame,
continue_rect,
@@ -1423,32 +1475,32 @@ fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect
return;
}
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Length(1),
+ let stack = modal_stack_areas(inner, 3, 0, 1, 1);
+ let header_rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
+ ])
+ .areas::<3>(stack.header);
+ let option_rows = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
- .areas::<12>(inner);
+ .areas::<5>(stack.content);
- render_modal_header(frame, rows[0], "notification style", &app.palette);
+ render_modal_header(frame, header_rows[0], "notification style", &app.palette);
frame.render_widget(
Paragraph::new(" herdr watches background panes and can alert you")
.style(Style::default().fg(app.palette.overlay1)),
- rows[2],
+ header_rows[1],
);
frame.render_widget(
Paragraph::new(" when agents finish or need attention.")
.style(Style::default().fg(app.palette.overlay1)),
- rows[3],
+ header_rows[2],
);
let options = [
@@ -1470,11 +1522,12 @@ fn render_onboarding_notifications(app: &AppState, frame: &mut Frame, area: Rect
};
frame.render_widget(
Paragraph::new(format!(" {prefix} {}. {option}", idx + 1)).style(style),
- rows[idx + 5],
+ option_rows[idx],
);
}
- let (back_rect, save_rect) = onboarding_notification_button_rects(rows[10]);
+ let (back_rect, save_rect) =
+ onboarding_notification_button_rects(stack.actions.unwrap_or_default());
render_action_button(
frame,
back_rect,
@@ -1714,18 +1767,14 @@ fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
return;
}
- let rows = Layout::vertical([
- Constraint::Length(1),
- Constraint::Length(1),
- Constraint::Min(1),
- Constraint::Length(1),
- ])
- .areas::<4>(inner);
+ let stack = modal_stack_areas(inner, 2, 1, 0, 1);
+ let header_rows =
+ Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas::<2>(stack.header);
- render_modal_header(frame, rows[0], "keybinds", &app.palette);
+ render_modal_header(frame, header_rows[0], "keybinds", &app.palette);
render_action_button(
frame,
- release_notes_close_button_rect(rows[0]),
+ release_notes_close_button_rect(header_rows[0]),
Some("esc"),
"close",
Style::default()
@@ -1736,10 +1785,10 @@ fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
frame.render_widget(
Paragraph::new(" available commands and configured shortcuts")
.style(Style::default().fg(app.palette.overlay1)),
- rows[1],
+ header_rows[1],
);
- let body_area = rows[2];
+ let body_area = stack.content;
let metrics = crate::pane::ScrollMetrics {
offset_from_bottom: app
.keybind_help_max_scroll()
@@ -1790,7 +1839,7 @@ fn render_keybind_help_overlay(app: &AppState, frame: &mut Frame) {
Span::styled("close", Style::default().fg(app.palette.overlay0)),
Span::styled(" q / esc / enter ", Style::default().fg(app.palette.text)),
])),
- rows[3],
+ stack.footer.unwrap_or_default(),
);
}
@@ -2128,7 +2177,13 @@ fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
return;
}
- let mut y = inner.y;
+ let stack = modal_stack_areas(inner, 3, 2, 0, 1);
+ let header_rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ ])
+ .areas::<3>(stack.header);
// Title
frame.render_widget(
@@ -2136,9 +2191,8 @@ fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
" settings",
Style::default().fg(p.text).add_modifier(Modifier::BOLD),
)])),
- Rect::new(inner.x, y, inner.width, 1),
+ header_rows[0],
);
- y += 1;
// Tab bar
let tabs = Tabs::new(SettingsSection::ALL.iter().map(|s| s.label()))
@@ -2157,19 +2211,17 @@ fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
)
.divider(" ")
.padding(" ", " ");
- frame.render_widget(tabs, Rect::new(inner.x, y, inner.width, 1));
- y += 1;
+ frame.render_widget(tabs, header_rows[1]);
// Separator
let sep = "─".repeat(inner.width as usize);
frame.render_widget(
Paragraph::new(Span::styled(&sep, Style::default().fg(p.surface0))),
- Rect::new(inner.x, y, inner.width, 1),
+ header_rows[2],
);
- y += 1;
// Section content
- let content_area = Rect::new(inner.x, y, inner.width, inner.y + inner.height - y);
+ let content_area = stack.content;
match app.settings.section {
SettingsSection::Theme => {
@@ -2200,8 +2252,9 @@ fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
}
// Footer buttons + hints
- let footer_y = inner.y + inner.height - 1;
- if footer_y > y {
+ if let Some(footer_area) = stack.footer {
+ let footer_rows = Layout::vertical([Constraint::Length(1), Constraint::Length(1)])
+ .areas::<2>(footer_area);
let (apply_rect, close_rect) = settings_button_rects(inner);
render_action_button(
frame,
@@ -2224,7 +2277,6 @@ fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
.add_modifier(Modifier::BOLD),
);
- let hint_area = Rect::new(inner.x, footer_y.saturating_sub(1), inner.width, 1);
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ↑↓", Style::default().fg(p.overlay0)),
@@ -2232,7 +2284,7 @@ fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
Span::styled("tab", Style::default().fg(p.overlay0)),
Span::styled(" section", Style::default().fg(p.overlay1)),
])),
- hint_area,
+ footer_rows[0],
);
}
}