aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/render.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/shell/render.rs')
-rw-r--r--src/client/shell/render.rs313
1 files changed, 313 insertions, 0 deletions
diff --git a/src/client/shell/render.rs b/src/client/shell/render.rs
new file mode 100644
index 00000000..0e7f3908
--- /dev/null
+++ b/src/client/shell/render.rs
@@ -0,0 +1,313 @@
+use super::*;
+
+#[path = "../shell/overlays.rs"]
+mod overlays;
+#[path = "../shell/sidebar.rs"]
+mod sidebar;
+#[path = "../shell/tabs.rs"]
+mod tabs;
+
+pub(super) use super::agent_sidebar::{ordered_agent_pane_ids, render_agent_panel};
+pub(super) use overlays::{
+ client_navigator_rows, render_client_overlay, render_context_menu, render_global_menu,
+};
+pub(super) use sidebar::{render_collapsed_sidebar, render_sidebar, workspace_entries};
+pub(super) use tabs::{render_tab_bar, tab_bar_status_width};
+pub(super) fn render_mode_bar(
+ buffer: &mut Buffer,
+ pane_area: Rect,
+ mode: ClientShellMode,
+ copy_mode: Option<&ClientCopyModeState>,
+ endpoint_error: Option<&str>,
+ update_available: bool,
+ keybinds: &LiveKeybindConfig,
+ palette: &Palette,
+) -> Option<Rect> {
+ if (mode == ClientShellMode::Terminal && endpoint_error.is_none()) || pane_area.is_empty() {
+ return None;
+ }
+
+ let bar = Rect::new(
+ pane_area.x,
+ pane_area.y + pane_area.height.saturating_sub(1),
+ pane_area.width,
+ 1,
+ );
+ let base = Style::default().fg(palette.overlay0).bg(palette.panel_bg);
+ for x in bar.x..bar.x + bar.width {
+ buffer[(x, bar.y)].set_symbol(" ").set_style(base);
+ }
+
+ let key = Style::default()
+ .fg(palette.accent)
+ .bg(palette.panel_bg)
+ .add_modifier(Modifier::BOLD);
+ let mode_style = Style::default()
+ .fg(match palette.panel_bg {
+ ratatui::style::Color::Reset => palette.surface_dim,
+ color => color,
+ })
+ .bg(if mode == ClientShellMode::Resize {
+ palette.mauve
+ } else {
+ palette.accent
+ })
+ .add_modifier(Modifier::BOLD);
+ let prefix = crate::config::format_key_combo(keybinds.prefix);
+ let prefix_rhs = |bindings: &crate::config::ActionKeybinds| {
+ bindings
+ .prefix_rhs_label()
+ .unwrap_or_else(|| "unset".to_owned())
+ };
+
+ let mut segments = Vec::<(String, Style)>::new();
+ if let Some(error) = endpoint_error {
+ segments.extend([
+ (" ERROR ".to_owned(), mode_style),
+ (format!(" {error}"), base),
+ ]);
+ } else {
+ match mode {
+ ClientShellMode::Prefix => {
+ segments.extend([
+ (" PREFIX ".to_owned(), mode_style),
+ (" ".to_owned(), base),
+ ("esc".to_owned(), key),
+ (" cancel ".to_owned(), base),
+ (prefix, key),
+ (" send prefix ".to_owned(), base),
+ (prefix_rhs(&keybinds.keybinds.workspace_picker), key),
+ (" workspace nav ".to_owned(), base),
+ (prefix_rhs(&keybinds.keybinds.help), key),
+ (" keybinds".to_owned(), base),
+ ]);
+ }
+ ClientShellMode::Navigate => {
+ segments.extend([
+ (" NAVIGATE ".to_owned(), mode_style),
+ (" esc back ".to_owned(), base),
+ ("↑/↓".to_owned(), key),
+ (" workspace ".to_owned(), base),
+ ("tab".to_owned(), key),
+ (" pane ".to_owned(), base),
+ (prefix_rhs(&keybinds.keybinds.help), key),
+ (" keybinds".to_owned(), base),
+ ]);
+ }
+ ClientShellMode::Resize => {
+ segments.extend([
+ (" RESIZE ".to_owned(), mode_style),
+ (" ".to_owned(), base),
+ ("h/l".to_owned(), key),
+ (" width ".to_owned(), base),
+ ("j/k".to_owned(), key),
+ (" height ".to_owned(), base),
+ ("esc".to_owned(), key),
+ (" done".to_owned(), base),
+ ]);
+ }
+ ClientShellMode::Copy => {
+ let copy_mode = copy_mode?;
+ if let Some(prompt) = copy_mode.search_prompt.as_ref() {
+ let marker = match prompt.direction {
+ crate::api::schema::PaneCopySearchDirection::Forward => "/",
+ crate::api::schema::PaneCopySearchDirection::Backward => "?",
+ };
+ segments.extend([
+ (" COPY ".to_owned(), mode_style),
+ (" ".to_owned(), base),
+ (marker.to_owned(), key),
+ (
+ prompt.query.clone(),
+ Style::default().fg(palette.text).bg(palette.panel_bg),
+ ),
+ ("█".to_owned(), key),
+ (" enter search esc cancel".to_owned(), base),
+ ]);
+ } else {
+ let select = if copy_mode.selection.is_some() {
+ "selecting"
+ } else {
+ "select"
+ };
+ let match_status = copy_mode
+ .search_current_global
+ .map(|current| format!(" {}/{}", current + 1, copy_mode.search_total))
+ .or_else(|| (!copy_mode.search_query.is_empty()).then(|| " 0/0".to_owned()))
+ .unwrap_or_default();
+ let (exit_keys, exit_label) =
+ if copy_mode.search_query.is_empty() && copy_mode.selection.is_none() {
+ ("q/esc", " exit")
+ } else {
+ ("esc", " clear q exit")
+ };
+ segments.extend([
+ (" COPY ".to_owned(), mode_style),
+ (" ".to_owned(), base),
+ ("h/j/k/l w/b/e { }".to_owned(), key),
+ (" move ".to_owned(), base),
+ ("/ ?".to_owned(), key),
+ (" search ".to_owned(), base),
+ ("n/N".to_owned(), key),
+ (format!(" repeat{match_status} "), base),
+ ("v/space".to_owned(), key),
+ (format!(" {select} "), base),
+ ("y/enter".to_owned(), key),
+ (" copy ".to_owned(), base),
+ (exit_keys.to_owned(), key),
+ (exit_label.to_owned(), base),
+ ]);
+ }
+ }
+ ClientShellMode::Terminal => unreachable!(),
+ }
+ }
+
+ let mut x = bar.x;
+ let end = bar.x + bar.width;
+ for (text, style) in segments {
+ if x >= end {
+ break;
+ }
+ let remaining = end - x;
+ buffer.set_stringn(x, bar.y, &text, usize::from(remaining), style);
+ x = x.saturating_add(
+ u16::try_from(UnicodeWidthStr::width(text.as_str()))
+ .unwrap_or(u16::MAX)
+ .min(remaining),
+ );
+ }
+ if update_available && mode == ClientShellMode::Navigate {
+ let width = 13.min(bar.width);
+ let area = Rect::new(bar.right().saturating_sub(width), bar.y, width, 1);
+ buffer.set_style(area, Style::default().bg(palette.panel_bg));
+ put_right_text(
+ buffer,
+ area,
+ area.y,
+ " update ready",
+ Style::default()
+ .fg(palette.accent)
+ .bg(palette.panel_bg)
+ .add_modifier(Modifier::BOLD),
+ );
+ }
+ Some(bar)
+}
+
+pub(super) struct ShellRenderState<'a> {
+ pub(super) collapsed_groups: &'a HashSet<String>,
+ pub(super) workspace_scroll: &'a mut usize,
+ pub(super) agent_scroll: &'a mut usize,
+ pub(super) tab_scroll: &'a mut usize,
+ pub(super) reveal_focused_tab: &'a mut bool,
+ pub(super) sidebar_collapsed: bool,
+ pub(super) sidebar_section_split: f32,
+ pub(super) tab_drag_insert_index: Option<usize>,
+ pub(super) selected_workspace_id: Option<&'a str>,
+ pub(super) dragged_workspace_id: Option<&'a str>,
+ pub(super) workspace_drop_indicator_row: Option<u16>,
+}
+
+pub(super) fn render_shell(
+ buffer: &mut Buffer,
+ layout: ClientShellLayout,
+ snapshot: &ClientShellSnapshot,
+ config: &ClientShellConfig,
+ mut state: ShellRenderState<'_>,
+) -> ShellHitMap {
+ let mut hits = ShellHitMap::default();
+ if layout.mobile_header.height > 0 {
+ super::mobile::render_mobile_header(
+ buffer,
+ layout.mobile_header,
+ snapshot,
+ config,
+ &mut hits,
+ );
+ }
+ if layout.sidebar.width > 0 {
+ if state.sidebar_collapsed {
+ render_collapsed_sidebar(
+ buffer,
+ layout.sidebar,
+ snapshot,
+ config,
+ state.selected_workspace_id,
+ &mut hits,
+ );
+ } else {
+ render_sidebar(
+ buffer,
+ layout.sidebar,
+ snapshot,
+ config,
+ &mut state,
+ &mut hits,
+ );
+ }
+ }
+ if layout.tab_bar.height > 0 {
+ render_tab_bar(
+ buffer,
+ layout.tab_bar,
+ snapshot,
+ config,
+ state.tab_scroll,
+ state.reveal_focused_tab,
+ state.tab_drag_insert_index,
+ &mut hits,
+ );
+ }
+ if !config.mouse_capture {
+ hits.sidebar_divider = Rect::default();
+ hits.sidebar_section_divider = Rect::default();
+ hits.workspace_scrollbar = Rect::default();
+ hits.agent_scrollbar = Rect::default();
+ hits.agent_sort_toggle = Rect::default();
+ hits.new_workspace = Rect::default();
+ hits.workspaces.clear();
+ hits.agents.clear();
+ hits.tab_scroll_left = Rect::default();
+ hits.tab_scroll_right = Rect::default();
+ hits.new_tab = Rect::default();
+ hits.pane_splits.clear();
+ }
+ hits
+}
+
+fn put_right_text(buffer: &mut Buffer, area: Rect, y: u16, text: &str, style: Style) {
+ let width = display_width(text).min(area.width);
+ put_text(
+ buffer,
+ area.right().saturating_sub(width),
+ y,
+ width,
+ text,
+ style,
+ );
+}
+
+pub(super) fn put_segment(
+ buffer: &mut Buffer,
+ x: u16,
+ y: u16,
+ right: u16,
+ text: &str,
+ style: Style,
+) -> u16 {
+ let width = display_width(text).min(right.saturating_sub(x));
+ put_text(buffer, x, y, width, text, style);
+ x.saturating_add(width)
+}
+
+pub(super) fn put_text(buffer: &mut Buffer, x: u16, y: u16, width: u16, text: &str, style: Style) {
+ if width == 0 || y >= buffer.area.bottom() || x >= buffer.area.right() {
+ return;
+ }
+ buffer.set_stringn(x, y, text, width as usize, style);
+}
+
+pub(super) fn display_width(text: &str) -> u16 {
+ UnicodeWidthStr::width(text).min(u16::MAX as usize) as u16
+}