aboutsummaryrefslogtreecommitdiff
path: root/src/ui/panes.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-21 16:30:04 +0300
committerGitHub <noreply@github.com>2026-04-21 16:30:04 +0300
commit3397e1ce2922c4ae9b814e8110e81de7d201de81 (patch)
treec457a7cf0af271dea1a412474fad5a33c1b60be8 /src/ui/panes.rs
parent2818ff492d45d17fd4f9b15478d703c6c664a8c0 (diff)
feat: add persistent server-client sessions
Make herdr persistent by default. Launching herdr now starts or reattaches to a background session server. Clients can detach and reattach while panes and agent processes keep running. Session mode now supports multi-client attach, auto-detect startup, and a thin-client/headless-server split. This also refactors the large app, ui, input, pane, config, workspace, and persistence modules into smaller focused submodules, while preserving behavior and colocating tests with the code they exercise. Upgrade notes: - persistence mode is now the default - in-app quit detaches the current client instead of stopping the server - use `herdr server stop` to stop the background session - use `--no-session` for the old single-process behavior - default socket paths now live under the config directory
Diffstat (limited to 'src/ui/panes.rs')
-rw-r--r--src/ui/panes.rs252
1 files changed, 252 insertions, 0 deletions
diff --git a/src/ui/panes.rs b/src/ui/panes.rs
new file mode 100644
index 00000000..7e1490db
--- /dev/null
+++ b/src/ui/panes.rs
@@ -0,0 +1,252 @@
+use ratatui::{
+ layout::Rect,
+ style::{Color, Modifier, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Paragraph},
+ Frame,
+};
+
+use super::scrollbar::{render_pane_scrollbar, should_show_scrollbar};
+use super::widgets::panel_contrast_fg;
+use crate::app::state::Palette;
+use crate::app::{AppState, Mode};
+use crate::layout::PaneInfo;
+
+/// Compute pane layout info and optionally resize pane runtimes to match.
+pub(super) fn compute_pane_infos(app: &AppState, area: Rect, resize_panes: bool) -> Vec<PaneInfo> {
+ let Some(ws_idx) = app.active else {
+ return Vec::new();
+ };
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ return Vec::new();
+ };
+
+ let multi_pane = ws.layout.pane_count() > 1;
+ let terminal_active = app.mode == Mode::Terminal;
+
+ if ws.zoomed {
+ let focused_id = ws.layout.focused();
+ let inner_rect = area;
+ let mut scrollbar_rect = None;
+ if let Some(rt) = ws.runtimes.get(&focused_id) {
+ if rt
+ .scroll_metrics()
+ .is_some_and(|metrics| should_show_scrollbar(metrics) && area.width > 0)
+ {
+ scrollbar_rect = Some(Rect::new(
+ area.x + area.width.saturating_sub(1),
+ area.y,
+ 1,
+ area.height,
+ ));
+ }
+ if resize_panes {
+ rt.resize(inner_rect.height, inner_rect.width);
+ }
+ }
+ return vec![PaneInfo {
+ id: focused_id,
+ rect: area,
+ inner_rect,
+ scrollbar_rect,
+ is_focused: true,
+ }];
+ }
+
+ let mut pane_infos = ws.layout.panes(area);
+
+ for info in &mut pane_infos {
+ let pane_inner = if multi_pane {
+ let border_set = if info.is_focused && terminal_active {
+ ratatui::symbols::border::THICK
+ } else {
+ ratatui::symbols::border::PLAIN
+ };
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_set(border_set);
+ block.inner(info.rect)
+ } else {
+ area
+ };
+
+ let inner_rect = pane_inner;
+ let mut scrollbar_rect = None;
+ if let Some(rt) = ws.runtimes.get(&info.id) {
+ if rt
+ .scroll_metrics()
+ .is_some_and(|metrics| should_show_scrollbar(metrics) && pane_inner.width > 0)
+ {
+ scrollbar_rect = Some(Rect::new(
+ pane_inner.x + pane_inner.width.saturating_sub(1),
+ pane_inner.y,
+ 1,
+ pane_inner.height,
+ ));
+ }
+ if resize_panes {
+ rt.resize(inner_rect.height, inner_rect.width);
+ }
+ }
+
+ info.inner_rect = inner_rect;
+ info.scrollbar_rect = scrollbar_rect;
+ }
+
+ pane_infos
+}
+
+pub(super) fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) {
+ let Some(ws_idx) = app.active else {
+ render_empty(app, frame, area);
+ return;
+ };
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ render_empty(app, frame, area);
+ return;
+ };
+
+ let multi_pane = ws.layout.pane_count() > 1;
+ let terminal_active = app.mode == Mode::Terminal;
+
+ for info in &app.view.pane_infos {
+ if let Some(rt) = ws.runtimes.get(&info.id) {
+ if multi_pane {
+ let (border_style, border_set) = if info.is_focused && terminal_active {
+ (
+ Style::default().fg(app.palette.accent),
+ ratatui::symbols::border::THICK,
+ )
+ } else if info.is_focused {
+ (
+ Style::default().fg(app.palette.accent),
+ ratatui::symbols::border::PLAIN,
+ )
+ } else {
+ (
+ Style::default().fg(app.palette.overlay0),
+ ratatui::symbols::border::PLAIN,
+ )
+ };
+
+ let block = Block::default()
+ .borders(Borders::ALL)
+ .border_style(border_style)
+ .border_set(border_set);
+ frame.render_widget(block, info.rect);
+ }
+
+ rt.render(frame, info.inner_rect, info.is_focused && terminal_active);
+ render_pane_scrollbar(app, frame, info, rt);
+
+ let should_dim = !info.is_focused && multi_pane && !terminal_active;
+ if should_dim {
+ let inner = info.inner_rect;
+ let buf = frame.buffer_mut();
+ for y in inner.y..inner.y + inner.height {
+ for x in inner.x..inner.x + inner.width {
+ let cell = &mut buf[(x, y)];
+ let style = cell.style();
+ let fg = style.fg.unwrap_or(Color::White);
+ let dimmed_fg = dim_color(fg);
+ cell.set_style(style.fg(dimmed_fg));
+ }
+ }
+ }
+
+ render_selection_highlight(
+ &app.selection,
+ frame,
+ info.id,
+ info.inner_rect,
+ rt.scroll_metrics(),
+ &app.palette,
+ );
+ }
+ }
+}
+
+/// Render selection highlight for a pane by inverting fg/bg colors.
+/// Reduce a color's brightness by blending it toward black.
+fn dim_color(color: Color) -> Color {
+ match color {
+ Color::Rgb(r, g, b) => Color::Rgb(r / 3, g / 3, b / 3),
+ Color::White => Color::DarkGray,
+ Color::Gray => Color::DarkGray,
+ Color::DarkGray => Color::Rgb(30, 30, 30),
+ Color::Red => Color::Rgb(60, 0, 0),
+ Color::Green => Color::Rgb(0, 60, 0),
+ Color::Yellow => Color::Rgb(60, 60, 0),
+ Color::Blue => Color::Rgb(0, 0, 60),
+ Color::Magenta => Color::Rgb(60, 0, 60),
+ Color::Cyan => Color::Rgb(0, 60, 60),
+ Color::LightRed => Color::Rgb(80, 30, 30),
+ Color::LightGreen => Color::Rgb(30, 80, 30),
+ Color::LightYellow => Color::Rgb(80, 80, 30),
+ Color::LightBlue => Color::Rgb(30, 30, 80),
+ Color::LightMagenta => Color::Rgb(80, 30, 80),
+ Color::LightCyan => Color::Rgb(30, 80, 80),
+ _ => Color::DarkGray,
+ }
+}
+
+fn render_selection_highlight(
+ selection: &Option<crate::selection::Selection>,
+ frame: &mut Frame,
+ pane_id: crate::layout::PaneId,
+ inner: Rect,
+ scroll_metrics: Option<crate::pane::ScrollMetrics>,
+ p: &Palette,
+) {
+ if let Some(sel) = selection {
+ if sel.is_visible() && sel.pane_id == pane_id {
+ let buf = frame.buffer_mut();
+ for y in 0..inner.height {
+ for x in 0..inner.width {
+ if sel.contains(y, x, scroll_metrics) {
+ let cell = &mut buf[(inner.x + x, inner.y + y)];
+ cell.set_style(Style::default().fg(panel_contrast_fg(p)).bg(p.blue));
+ }
+ }
+ }
+ }
+ }
+}
+
+fn render_empty(app: &AppState, frame: &mut Frame, area: Rect) {
+ let p = &app.palette;
+ let lines = vec![
+ Line::from(""),
+ Line::from(""),
+ Line::from(Span::styled(
+ " No workspaces yet",
+ Style::default().fg(p.overlay0),
+ )),
+ Line::from(""),
+ Line::from(Span::styled(
+ " A workspace is one project context.",
+ Style::default().fg(p.overlay1),
+ )),
+ Line::from(Span::styled(
+ " Its root pane (top-left) sets the default repo or folder name.",
+ Style::default().fg(p.overlay1),
+ )),
+ Line::from(""),
+ Line::from(vec![
+ Span::styled(" Press ", Style::default().fg(p.overlay0)),
+ Span::styled(
+ format!("{}", app.keybinds.new_workspace_label),
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD),
+ ),
+ Span::styled(" to create one", Style::default().fg(p.overlay0)),
+ ]),
+ ];
+ frame.render_widget(
+ Paragraph::new(lines).block(
+ Block::default()
+ .borders(Borders::ALL)
+ .border_style(Style::default().fg(p.surface_dim)),
+ ),
+ area,
+ );
+}