feat(layout): fullscreen + interactive pane resizing
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run
CI / rust client (hh) (macos-latest) (push) Waiting to run
CI / rust client (hh) (ubuntu-latest) (push) Waiting to run
CI / rust coverage (push) Waiting to run
CI / python server (3.10) (push) Waiting to run
CI / python server (3.11) (push) Waiting to run
CI / python server (3.12) (push) Waiting to run
CI / headless e2e smoke (push) Waiting to run
CI / dependency audit (push) Waiting to run
CI / secret scanning (push) Waiting to run
Add a window-management layer so the chat, roster and sandbox-terminal panes can be resized live and fullscreened, with named presets for recall. - New layout.rs: single source of truth for the body split (pty_pct, roster_width) and a Zoom state (Normal/Term/Chat), persisted to layouts/<slug>.toml like themes. Both ui::draw and app::sbx_dims read from it, so resizing just mutates state and clears announced_dims — the per-tick loop re-syncs and broadcasts the new PTY grid. - F4 cycles terminal/chat fullscreen (F-keys aren't forwarded to the shell, so nothing is stolen from in-shell apps). - Interactive editing: click a pane (or F5 to cycle terminal -> chat -> roster) to select it; arrows then resize it live, Esc/Enter finishes. The selected pane gets a bold accent border and an ✎ title marker. ui::pane_at hit-tests against the same body_areas rects draw() paints. - /layout slimmed to presets only (save/load/list/rm/reset); the old numeric pty/chat/roster and full/chatfull/normal verbs are replaced by the interactive flow. - Help menu updated: LAYOUT cluster + KEYS line document F4, click/F5, arrows and Esc. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+212
-6
@@ -1,6 +1,7 @@
|
|||||||
//! TUI application state, network event model, and the async run loop.
|
//! TUI application state, network event model, and the async run loop.
|
||||||
|
|
||||||
use crate::ft;
|
use crate::ft;
|
||||||
|
use crate::layout::Layout;
|
||||||
use crate::net::{self, Session};
|
use crate::net::{self, Session};
|
||||||
use crate::sbx;
|
use crate::sbx;
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
@@ -10,7 +11,7 @@ use base64::engine::general_purpose::STANDARD;
|
|||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
use crossterm::event::{
|
use crossterm::event::{
|
||||||
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind,
|
DisableMouseCapture, EnableMouseCapture, Event, EventStream, KeyCode, KeyEventKind,
|
||||||
KeyModifiers, MouseEventKind,
|
KeyModifiers, MouseButton, MouseEventKind,
|
||||||
};
|
};
|
||||||
use crossterm::execute;
|
use crossterm::execute;
|
||||||
use crossterm::terminal::{
|
use crossterm::terminal::{
|
||||||
@@ -159,6 +160,15 @@ pub struct VboxPicker {
|
|||||||
pub selected: usize,
|
pub selected: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A resizable region of the window, for interactive layout editing. Selected by
|
||||||
|
/// clicking it (or cycling with F5); once selected, arrow keys resize it.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Pane {
|
||||||
|
Chat,
|
||||||
|
Roster,
|
||||||
|
Terminal,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub me: String,
|
pub me: String,
|
||||||
pub lines: Vec<ChatLine>,
|
pub lines: Vec<ChatLine>,
|
||||||
@@ -211,6 +221,13 @@ pub struct App {
|
|||||||
/// Tracked so the broker Ready handler can re-grant its drive and `/ai stop`
|
/// Tracked so the broker Ready handler can re-grant its drive and `/ai stop`
|
||||||
/// can revoke the right ACL entry.
|
/// can revoke the right ACL entry.
|
||||||
pub agent_name: Option<String>,
|
pub agent_name: Option<String>,
|
||||||
|
/// Window layout: chat/terminal split, roster width, fullscreen zoom. Read
|
||||||
|
/// by `ui::draw` and `sbx_dims`; mutated by F4/F5 + interactive editing.
|
||||||
|
pub layout: Layout,
|
||||||
|
/// Interactive layout editing: the pane currently selected for resizing
|
||||||
|
/// (click it or cycle with F5), or None when not editing. When set, arrow
|
||||||
|
/// keys resize this pane instead of scrolling.
|
||||||
|
pub focused_pane: Option<Pane>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -244,9 +261,25 @@ impl App {
|
|||||||
spin: 0,
|
spin: 0,
|
||||||
agent_sbx_allow: false,
|
agent_sbx_allow: false,
|
||||||
agent_name: None,
|
agent_name: None,
|
||||||
|
layout: Layout::default(),
|
||||||
|
focused_pane: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cycle the interactive-edit selection: Terminal → Chat → Roster → off.
|
||||||
|
/// Bound to F5; clicking a pane jumps straight to it instead. The Terminal
|
||||||
|
/// step is skipped when no sandbox is up (nothing to resize there).
|
||||||
|
fn cycle_focus(&mut self) {
|
||||||
|
let has_term = self.sandbox.is_some();
|
||||||
|
self.focused_pane = match self.focused_pane {
|
||||||
|
None if has_term => Some(Pane::Terminal),
|
||||||
|
None => Some(Pane::Chat),
|
||||||
|
Some(Pane::Terminal) => Some(Pane::Chat),
|
||||||
|
Some(Pane::Chat) => Some(Pane::Roster),
|
||||||
|
Some(Pane::Roster) => None,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Append a chat line. Holds the viewport steady if scrolled up, and caps
|
/// Append a chat line. Holds the viewport steady if scrolled up, and caps
|
||||||
/// the in-memory backlog.
|
/// the in-memory backlog.
|
||||||
fn push_line(&mut self, l: ChatLine) {
|
fn push_line(&mut self, l: ChatLine) {
|
||||||
@@ -463,9 +496,32 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sbx_dims(term_w: u16, term_h: u16) -> (u16, u16) {
|
/// Human label for a resizable pane (used in layout-editing hints).
|
||||||
|
fn pane_label(p: Pane) -> &'static str {
|
||||||
|
match p {
|
||||||
|
Pane::Chat => "chat",
|
||||||
|
Pane::Roster => "roster",
|
||||||
|
Pane::Terminal => "terminal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join saved-preset names for display, or "none" when there are none.
|
||||||
|
fn once_or_none(names: Vec<String>) -> String {
|
||||||
|
if names.is_empty() {
|
||||||
|
"none".to_string()
|
||||||
|
} else {
|
||||||
|
names.join(" · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PTY grid (rows, cols) for the sandbox terminal. `pty_pct` is the terminal's
|
||||||
|
/// share of the body height (see `layout::Layout`); it must match the split
|
||||||
|
/// `ui::draw` paints, so they stay in lock-step via `app.layout`. Width is the
|
||||||
|
/// full body width — the terminal pane always spans the frame, only its height
|
||||||
|
/// changes with the split.
|
||||||
|
fn sbx_dims(term_w: u16, term_h: u16, pty_pct: u16) -> (u16, u16) {
|
||||||
let body_h = term_h.saturating_sub(4);
|
let body_h = term_h.saturating_sub(4);
|
||||||
let sbx_h = (body_h as u32 * 55 / 100) as u16;
|
let sbx_h = (body_h as u32 * pty_pct as u32 / 100) as u16;
|
||||||
(
|
(
|
||||||
sbx_h.saturating_sub(2).max(1),
|
sbx_h.saturating_sub(2).max(1),
|
||||||
term_w.saturating_sub(2).max(1),
|
term_w.saturating_sub(2).max(1),
|
||||||
@@ -842,6 +898,10 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
|
|
||||||
let mut app = App::new(session.username.clone());
|
let mut app = App::new(session.username.clone());
|
||||||
app.password = params.password.clone();
|
app.password = params.password.clone();
|
||||||
|
// Seed the roster width from the active vestment so a `--theme` with a wider
|
||||||
|
// roster is honoured; from here on the live layout owns it (so /theme swaps
|
||||||
|
// don't stomp a width the user has set with /layout).
|
||||||
|
app.layout.roster_width = theme.roster_width;
|
||||||
let mut events = EventStream::new();
|
let mut events = EventStream::new();
|
||||||
let mut tick = tokio::time::interval(Duration::from_millis(50));
|
let mut tick = tokio::time::interval(Duration::from_millis(50));
|
||||||
|
|
||||||
@@ -863,7 +923,7 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
|
|
||||||
if broker.is_some() {
|
if broker.is_some() {
|
||||||
if let Ok(sz) = term.size() {
|
if let Ok(sz) = term.size() {
|
||||||
let dims = sbx_dims(sz.width, sz.height);
|
let dims = sbx_dims(sz.width, sz.height, app.layout.effective_pty_pct());
|
||||||
if announced_dims != Some(dims) {
|
if announced_dims != Some(dims) {
|
||||||
announced_dims = Some(dims);
|
announced_dims = Some(dims);
|
||||||
if let Some(sb) = &broker {
|
if let Some(sb) = &broker {
|
||||||
@@ -1043,6 +1103,64 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
} else {
|
} else {
|
||||||
app.sys("you don't have drive permission — the owner can /grant you");
|
app.sys("you don't have drive permission — the owner can /grant you");
|
||||||
}
|
}
|
||||||
|
} else if k.code == KeyCode::F(4) {
|
||||||
|
// Fullscreen the terminal (cycle terminal → chat → split).
|
||||||
|
// Caught before the drive branch so it works mid-session;
|
||||||
|
// F-keys aren't forwarded to the shell anyway (key_to_pty).
|
||||||
|
if app.sandbox.is_some() {
|
||||||
|
app.layout.cycle_zoom();
|
||||||
|
announced_dims = None; // re-sync PTY to the new height
|
||||||
|
app.sys(format!("⛧ {}", app.layout.describe()));
|
||||||
|
} else {
|
||||||
|
app.sys("no sandbox to fullscreen — /sbx launch first");
|
||||||
|
}
|
||||||
|
} else if k.code == KeyCode::F(5) {
|
||||||
|
// Enter/cycle interactive layout editing: pick a pane to
|
||||||
|
// resize (Terminal → Chat → Roster → off). Clicking a pane
|
||||||
|
// selects it directly; arrows then resize it.
|
||||||
|
app.cycle_focus();
|
||||||
|
match app.focused_pane {
|
||||||
|
Some(p) => app.sys(format!(
|
||||||
|
"⛧ editing {} — arrows resize · Esc done",
|
||||||
|
pane_label(p)
|
||||||
|
)),
|
||||||
|
None => app.sys("⛧ layout editing off"),
|
||||||
|
}
|
||||||
|
} else if app.focused_pane.is_some() && !app.driving {
|
||||||
|
// Interactive layout editing: a pane is selected (via click
|
||||||
|
// or F5). Arrows resize it live; Esc/Enter finishes. Sits
|
||||||
|
// before the driving branch but is gated on !driving so the
|
||||||
|
// shell still gets its keys while you hold the PTY.
|
||||||
|
let pane = app.focused_pane.unwrap();
|
||||||
|
match (pane, k.code) {
|
||||||
|
(_, KeyCode::Esc) | (_, KeyCode::Enter) => {
|
||||||
|
app.focused_pane = None;
|
||||||
|
app.sys(format!("⛧ {}", app.layout.describe()));
|
||||||
|
}
|
||||||
|
// Terminal taller / chat shorter (and the mirror image
|
||||||
|
// when the chat pane is the one selected).
|
||||||
|
(Pane::Terminal, KeyCode::Up) | (Pane::Chat, KeyCode::Down) => {
|
||||||
|
app.layout.grow_pty(3);
|
||||||
|
announced_dims = None;
|
||||||
|
app.sys(format!("⛧ {}", app.layout.describe()));
|
||||||
|
}
|
||||||
|
(Pane::Terminal, KeyCode::Down) | (Pane::Chat, KeyCode::Up) => {
|
||||||
|
app.layout.shrink_pty(3);
|
||||||
|
announced_dims = None;
|
||||||
|
app.sys(format!("⛧ {}", app.layout.describe()));
|
||||||
|
}
|
||||||
|
(Pane::Roster, KeyCode::Left) => {
|
||||||
|
app.layout.roster_width =
|
||||||
|
app.layout.roster_width.saturating_sub(2);
|
||||||
|
app.sys(format!("⛧ {}", app.layout.describe()));
|
||||||
|
}
|
||||||
|
(Pane::Roster, KeyCode::Right) => {
|
||||||
|
app.layout.roster_width =
|
||||||
|
(app.layout.roster_width + 2).min(Layout::MAX_ROSTER);
|
||||||
|
app.sys(format!("⛧ {}", app.layout.describe()));
|
||||||
|
}
|
||||||
|
_ => {} // ignore other keys while editing
|
||||||
|
}
|
||||||
} else if app.driving {
|
} else if app.driving {
|
||||||
// Esc is NOT a release key here — vim & friends need it,
|
// Esc is NOT a release key here — vim & friends need it,
|
||||||
// so it's forwarded to the PTY like any other key (see
|
// so it's forwarded to the PTY like any other key (see
|
||||||
@@ -1127,6 +1245,27 @@ pub async fn run(params: net::ConnParams, mut session: Session, mut theme: Theme
|
|||||||
app.chat_scroll = app.chat_scroll.saturating_sub(3);
|
app.chat_scroll = app.chat_scroll.saturating_sub(3);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Left-click a pane to select it for interactive resize
|
||||||
|
// (then arrows adjust it; Esc finishes). Skipped while
|
||||||
|
// driving the shell or with an overlay up, so clicks there
|
||||||
|
// don't hijack the terminal/help/picker.
|
||||||
|
MouseEventKind::Down(MouseButton::Left)
|
||||||
|
if !app.driving && !app.show_help && app.vbox_picker.is_none() =>
|
||||||
|
{
|
||||||
|
if let Ok(sz) = term.size() {
|
||||||
|
if let Some(p) =
|
||||||
|
ui::pane_at(sz.width, sz.height, &app, m.column, m.row)
|
||||||
|
{
|
||||||
|
app.focused_pane = Some(p);
|
||||||
|
app.sys(format!(
|
||||||
|
"⛧ editing {} — arrows resize · Esc done",
|
||||||
|
pane_label(p)
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
app.focused_pane = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1516,6 +1655,73 @@ fn handle_command(
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if let Some(rest) = line.strip_prefix("/layout") {
|
||||||
|
// Named layout presets only — live resizing is interactive now (F4
|
||||||
|
// fullscreen, F5 or click a pane then arrows). `/layout` keeps just the
|
||||||
|
// memorable verbs for saving/recalling arrangements, plus `reset`.
|
||||||
|
// Loading a preset clears announced_dims so the run loop re-syncs (and
|
||||||
|
// broadcasts) the live PTY size on the next tick.
|
||||||
|
let rest = rest.trim();
|
||||||
|
let (cmd, arg) = rest
|
||||||
|
.split_once(char::is_whitespace)
|
||||||
|
.map(|(c, a)| (c, a.trim()))
|
||||||
|
.unwrap_or((rest, ""));
|
||||||
|
let mut resized = false;
|
||||||
|
match cmd {
|
||||||
|
"" => {
|
||||||
|
app.sys(format!("⛧ layout: {}", app.layout.describe()));
|
||||||
|
app.sys(" resize live: F4 fullscreen · F5 or click a pane, then arrows · Esc done");
|
||||||
|
app.sys(format!(
|
||||||
|
" presets: {} — /layout save <name> · load <name> · rm <name>",
|
||||||
|
once_or_none(Layout::available())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
"reset" | "default" => {
|
||||||
|
let roster = app.layout.roster_width;
|
||||||
|
app.layout = Layout::default();
|
||||||
|
app.layout.roster_width = roster; // keep your roster choice on reset
|
||||||
|
resized = true;
|
||||||
|
app.sys(format!("⛧ layout reset — {}", app.layout.describe()));
|
||||||
|
}
|
||||||
|
"save" if !arg.is_empty() => match app.layout.save(arg) {
|
||||||
|
Ok(slug) => app.sys(format!(
|
||||||
|
"⛧ saved layout '{slug}' — re-apply anytime with /layout load {slug}"
|
||||||
|
)),
|
||||||
|
Err(e) => app.err(format!("couldn't save layout: {e}")),
|
||||||
|
},
|
||||||
|
"load" | "apply" if !arg.is_empty() => match Layout::by_name(arg) {
|
||||||
|
Ok(l) => {
|
||||||
|
app.layout = l;
|
||||||
|
resized = true;
|
||||||
|
app.sys(format!("⛧ loaded layout '{arg}' — {}", app.layout.describe()));
|
||||||
|
}
|
||||||
|
Err(_) => app.err(format!(
|
||||||
|
"no saved layout '{arg}' — saved: {}",
|
||||||
|
once_or_none(Layout::available())
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
"list" | "presets" | "ls" => {
|
||||||
|
app.sys(format!("⛧ saved layouts: {}", once_or_none(Layout::available())));
|
||||||
|
}
|
||||||
|
"rm" | "delete" | "del" if !arg.is_empty() => match Layout::remove(arg) {
|
||||||
|
Ok(()) => app.sys(format!("⛧ deleted layout '{arg}'")),
|
||||||
|
Err(e) => app.err(format!("{e}")),
|
||||||
|
},
|
||||||
|
// Bare `/layout <name>` → load a saved preset if it exists.
|
||||||
|
name => match Layout::by_name(name) {
|
||||||
|
Ok(l) => {
|
||||||
|
app.layout = l;
|
||||||
|
resized = true;
|
||||||
|
app.sys(format!("⛧ loaded layout '{name}' — {}", app.layout.describe()));
|
||||||
|
}
|
||||||
|
Err(_) => app.sys(
|
||||||
|
"usage: /layout [save <name>|load <name>|list|rm <name>|reset] — resize live with F4/F5 or click",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if resized {
|
||||||
|
*announced_dims = None; // force the run loop to re-sync the PTY size
|
||||||
|
}
|
||||||
} else if line == "/drive" {
|
} else if line == "/drive" {
|
||||||
// Mobile-friendly alternative to F2 (no function key needed).
|
// Mobile-friendly alternative to F2 (no function key needed).
|
||||||
if app.sandbox.is_none() {
|
if app.sandbox.is_none() {
|
||||||
@@ -1656,7 +1862,7 @@ fn handle_command(
|
|||||||
// install also leaves its daemon up).
|
// install also leaves its daemon up).
|
||||||
let install_first = !installed;
|
let install_first = !installed;
|
||||||
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
|
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
|
||||||
let (rows, cols) = sbx_dims(sz.0, sz.1);
|
let (rows, cols) = sbx_dims(sz.0, sz.1, app.layout.effective_pty_pct());
|
||||||
*launching = true;
|
*launching = true;
|
||||||
let members: Vec<String> =
|
let members: Vec<String> =
|
||||||
app.users.iter().map(|u| u.username.clone()).collect();
|
app.users.iter().map(|u| u.username.clone()).collect();
|
||||||
@@ -1762,7 +1968,7 @@ fn handle_command(
|
|||||||
// (stopped, preserved) instance and re-attaches its shell.
|
// (stopped, preserved) instance and re-attaches its shell.
|
||||||
let label = label.to_string();
|
let label = label.to_string();
|
||||||
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
|
let sz = term.size().map(|s| (s.width, s.height)).unwrap_or((80, 24));
|
||||||
let (rows, cols) = sbx_dims(sz.0, sz.1);
|
let (rows, cols) = sbx_dims(sz.0, sz.1, app.layout.effective_pty_pct());
|
||||||
*launching = true;
|
*launching = true;
|
||||||
let members: Vec<String> =
|
let members: Vec<String> =
|
||||||
app.users.iter().map(|u| u.username.clone()).collect();
|
app.users.iter().map(|u| u.username.clone()).collect();
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
//! Window layout ("cell plan"): how the chat, roster and sandbox-terminal panes
|
||||||
|
//! divide the screen, plus a fullscreen ("zoom") mode for the terminal or chat.
|
||||||
|
//!
|
||||||
|
//! The split is a single source of truth shared by `ui::draw` (what's painted)
|
||||||
|
//! and `app::sbx_dims` (the PTY grid we resize the real shell to). Because the
|
||||||
|
//! run loop re-syncs the PTY every tick, changing these values is all it takes
|
||||||
|
//! to live-resize the terminal and broadcast the new dims to the room.
|
||||||
|
//!
|
||||||
|
//! Named arrangements can be saved to `layouts/<slug>.toml` and re-applied later
|
||||||
|
//! with `/layout load <name>`, mirroring how `theme.rs` persists vestments.
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Where saved layout presets live (mirrors `theme::THEMES_DIR`).
|
||||||
|
pub const LAYOUTS_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/layouts");
|
||||||
|
|
||||||
|
/// Fullscreen state. `Normal` honours the `pty_pct` split; `Term` gives the
|
||||||
|
/// whole body to the sandbox terminal (chat + roster hidden); `Chat` hides the
|
||||||
|
/// terminal so chat + roster fill the body. The 3-line input bar always stays
|
||||||
|
/// visible, so you can always type `/layout normal` or press F4 to get back.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub enum Zoom {
|
||||||
|
Normal,
|
||||||
|
Term,
|
||||||
|
Chat,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Zoom {
|
||||||
|
fn default() -> Self {
|
||||||
|
Zoom::Normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(default)]
|
||||||
|
pub struct Layout {
|
||||||
|
/// Sandbox terminal's share of the body height, as a percentage (clamped
|
||||||
|
/// 20–90 so neither chat nor terminal can collapse to nothing).
|
||||||
|
pub pty_pct: u16,
|
||||||
|
/// Roster column width in cells. `0` hides the roster entirely.
|
||||||
|
pub roster_width: u16,
|
||||||
|
/// Fullscreen state (not all presets care; defaults to `Normal`).
|
||||||
|
pub zoom: Zoom,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Layout {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
pty_pct: 55,
|
||||||
|
roster_width: 22,
|
||||||
|
zoom: Zoom::Normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layout {
|
||||||
|
/// Lower/upper bounds for the terminal's height share.
|
||||||
|
pub const MIN_PCT: u16 = 20;
|
||||||
|
pub const MAX_PCT: u16 = 90;
|
||||||
|
/// Upper bound on roster width (keeps it from eating the whole chat column).
|
||||||
|
pub const MAX_ROSTER: u16 = 60;
|
||||||
|
|
||||||
|
/// Re-clamp every field into its valid range. Call after any mutation that
|
||||||
|
/// came from user input so out-of-range values can never reach the layout.
|
||||||
|
pub fn clamp(&mut self) {
|
||||||
|
self.pty_pct = self.pty_pct.clamp(Self::MIN_PCT, Self::MAX_PCT);
|
||||||
|
self.roster_width = self.roster_width.min(Self::MAX_ROSTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terminal's effective height share once zoom is taken into account:
|
||||||
|
/// `Term` fills the body (100%); `Normal`/`Chat` use the stored split (the
|
||||||
|
/// terminal is simply not painted under `Chat`, so its size stays steady).
|
||||||
|
pub fn effective_pty_pct(&self) -> u16 {
|
||||||
|
match self.zoom {
|
||||||
|
Zoom::Term => 100,
|
||||||
|
_ => self.pty_pct,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cycle the fullscreen state: Normal → Term → Chat → Normal. Bound to F4.
|
||||||
|
pub fn cycle_zoom(&mut self) {
|
||||||
|
self.zoom = match self.zoom {
|
||||||
|
Zoom::Normal => Zoom::Term,
|
||||||
|
Zoom::Term => Zoom::Chat,
|
||||||
|
Zoom::Chat => Zoom::Normal,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grow the terminal pane by `step` percent (drops out of any zoom first so
|
||||||
|
/// the change is visible). Stays within [MIN_PCT, MAX_PCT].
|
||||||
|
pub fn grow_pty(&mut self, step: u16) {
|
||||||
|
self.zoom = Zoom::Normal;
|
||||||
|
self.pty_pct = (self.pty_pct + step).min(Self::MAX_PCT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shrink the terminal pane by `step` percent (counterpart of `grow_pty`).
|
||||||
|
pub fn shrink_pty(&mut self, step: u16) {
|
||||||
|
self.zoom = Zoom::Normal;
|
||||||
|
self.pty_pct = self.pty_pct.saturating_sub(step).max(Self::MIN_PCT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-line description of the current arrangement (for `/layout`).
|
||||||
|
pub fn describe(&self) -> String {
|
||||||
|
let zoom = match self.zoom {
|
||||||
|
Zoom::Normal => "split",
|
||||||
|
Zoom::Term => "terminal-fullscreen",
|
||||||
|
Zoom::Chat => "chat-fullscreen",
|
||||||
|
};
|
||||||
|
let roster = if self.roster_width == 0 {
|
||||||
|
"off".to_string()
|
||||||
|
} else {
|
||||||
|
self.roster_width.to_string()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"terminal {}% · chat {}% · roster {} · {}",
|
||||||
|
self.pty_pct,
|
||||||
|
100 - self.pty_pct,
|
||||||
|
roster,
|
||||||
|
zoom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist this arrangement to `layouts/<slug>.toml` so it can be re-applied
|
||||||
|
/// later with `/layout load <slug>`. Returns the slug actually written.
|
||||||
|
pub fn save(&self, name: &str) -> anyhow::Result<String> {
|
||||||
|
let slug = slugify(name);
|
||||||
|
anyhow::ensure!(!slug.is_empty(), "give the layout a name (letters/digits)");
|
||||||
|
std::fs::create_dir_all(LAYOUTS_DIR)
|
||||||
|
.with_context(|| format!("creating {LAYOUTS_DIR}"))?;
|
||||||
|
let path = format!("{LAYOUTS_DIR}/{slug}.toml");
|
||||||
|
let body = toml::to_string_pretty(self)
|
||||||
|
.with_context(|| format!("serialize layout '{slug}'"))?;
|
||||||
|
std::fs::write(&path, body).with_context(|| format!("write {path}"))?;
|
||||||
|
Ok(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a saved arrangement by name (`layouts/<slug>.toml`), re-clamped.
|
||||||
|
pub fn by_name(name: &str) -> anyhow::Result<Self> {
|
||||||
|
let slug = slugify(name);
|
||||||
|
let path = format!("{LAYOUTS_DIR}/{slug}.toml");
|
||||||
|
let s = std::fs::read_to_string(&path)
|
||||||
|
.with_context(|| format!("layout '{slug}' ({path})"))?;
|
||||||
|
let mut l: Layout = toml::from_str(&s)?;
|
||||||
|
l.clamp();
|
||||||
|
Ok(l)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Names of saved presets, sorted, for `/layout list`.
|
||||||
|
pub fn available() -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = std::fs::read_dir(LAYOUTS_DIR)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|e| {
|
||||||
|
e.file_name()
|
||||||
|
.to_str()
|
||||||
|
.and_then(|f| f.strip_suffix(".toml"))
|
||||||
|
.map(str::to_string)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a saved preset. Errors if it doesn't exist.
|
||||||
|
pub fn remove(name: &str) -> anyhow::Result<()> {
|
||||||
|
let slug = slugify(name);
|
||||||
|
let path = format!("{LAYOUTS_DIR}/{slug}.toml");
|
||||||
|
std::fs::remove_file(&path).with_context(|| format!("no saved layout '{slug}'"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduce a free-form name to a safe lowercase `<slug>` (ascii letters/digits,
|
||||||
|
/// any other run folded to a single '-'), matching theme.rs's convention.
|
||||||
|
fn slugify(name: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
let mut dash = false;
|
||||||
|
for c in name.trim().chars() {
|
||||||
|
if c.is_ascii_alphanumeric() {
|
||||||
|
if dash && !out.is_empty() {
|
||||||
|
out.push('-');
|
||||||
|
}
|
||||||
|
out.extend(c.to_lowercase());
|
||||||
|
dash = false;
|
||||||
|
} else {
|
||||||
|
dash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clamp_bounds_pct_and_roster() {
|
||||||
|
let mut l = Layout {
|
||||||
|
pty_pct: 999,
|
||||||
|
roster_width: 999,
|
||||||
|
zoom: Zoom::Normal,
|
||||||
|
};
|
||||||
|
l.clamp();
|
||||||
|
assert_eq!(l.pty_pct, Layout::MAX_PCT);
|
||||||
|
assert_eq!(l.roster_width, Layout::MAX_ROSTER);
|
||||||
|
let mut low = Layout {
|
||||||
|
pty_pct: 1,
|
||||||
|
roster_width: 0,
|
||||||
|
zoom: Zoom::Normal,
|
||||||
|
};
|
||||||
|
low.clamp();
|
||||||
|
assert_eq!(low.pty_pct, Layout::MIN_PCT);
|
||||||
|
assert_eq!(low.roster_width, 0); // 0 is valid (hidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_pct_full_under_term_zoom() {
|
||||||
|
let mut l = Layout::default();
|
||||||
|
assert_eq!(l.effective_pty_pct(), 55);
|
||||||
|
l.zoom = Zoom::Term;
|
||||||
|
assert_eq!(l.effective_pty_pct(), 100);
|
||||||
|
l.zoom = Zoom::Chat;
|
||||||
|
assert_eq!(l.effective_pty_pct(), 55); // chat hidden ≠ resize the pty
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cycle_and_resize_drop_out_of_zoom() {
|
||||||
|
let mut l = Layout::default();
|
||||||
|
l.cycle_zoom();
|
||||||
|
assert_eq!(l.zoom, Zoom::Term);
|
||||||
|
l.grow_pty(5);
|
||||||
|
assert_eq!(l.zoom, Zoom::Normal);
|
||||||
|
assert_eq!(l.pty_pct, 60);
|
||||||
|
l.shrink_pty(100);
|
||||||
|
assert_eq!(l.pty_pct, Layout::MIN_PCT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod crypto;
|
mod crypto;
|
||||||
mod ft;
|
mod ft;
|
||||||
|
mod layout;
|
||||||
mod net;
|
mod net;
|
||||||
mod sbx;
|
mod sbx;
|
||||||
mod theme;
|
mod theme;
|
||||||
|
|||||||
+145
-23
@@ -1,9 +1,10 @@
|
|||||||
//! ratatui rendering — top bar, chat, roster, input.
|
//! ratatui rendering — top bar, chat, roster, input.
|
||||||
|
|
||||||
use crate::app::{App, ChatLine, Role};
|
use crate::app::{App, ChatLine, Role};
|
||||||
|
use crate::layout::Zoom;
|
||||||
use crate::theme::Theme;
|
use crate::theme::Theme;
|
||||||
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||||
use ratatui::style::{Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, Wrap};
|
use ratatui::widgets::{Block, Clear, List, ListItem, Paragraph, Wrap};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -26,20 +27,17 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
|||||||
|
|
||||||
draw_top(f, rows[0], app, theme);
|
draw_top(f, rows[0], app, theme);
|
||||||
|
|
||||||
// When a sandbox is live, split the body: chat+roster on top, PTY below.
|
// Divide the body into chat / roster / sandbox-terminal rects. `body_areas`
|
||||||
let (chat_area, sbx_area) = if app.sandbox.is_some() {
|
// is the single source of truth for that geometry, shared with `pane_at`
|
||||||
let split = Layout::vertical([Constraint::Percentage(45), Constraint::Percentage(55)])
|
// (mouse hit-testing) so what you click is exactly what's painted.
|
||||||
.split(rows[1]);
|
let areas = body_areas(rows[1], app);
|
||||||
(split[0], Some(split[1]))
|
if let Some(chat_area) = areas.chat {
|
||||||
} else {
|
draw_chat(f, chat_area, app, theme);
|
||||||
(rows[1], None)
|
}
|
||||||
};
|
if let Some(roster_area) = areas.roster {
|
||||||
|
draw_roster(f, roster_area, app, theme);
|
||||||
let body = Layout::horizontal([Constraint::Min(1), Constraint::Length(theme.roster_width)])
|
}
|
||||||
.split(chat_area);
|
if let Some(area) = areas.sbx {
|
||||||
draw_chat(f, body[0], app, theme);
|
|
||||||
draw_roster(f, body[1], app, theme);
|
|
||||||
if let Some(area) = sbx_area {
|
|
||||||
draw_sandbox(f, area, app, theme);
|
draw_sandbox(f, area, app, theme);
|
||||||
}
|
}
|
||||||
draw_input(f, rows[2], app, theme);
|
draw_input(f, rows[2], app, theme);
|
||||||
@@ -55,6 +53,101 @@ pub fn draw(f: &mut Frame, app: &App, theme: &Theme) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The body's pane rectangles. Any field is `None` when that pane is hidden
|
||||||
|
/// (terminal absent, fullscreen zoom, or roster width 0).
|
||||||
|
struct BodyAreas {
|
||||||
|
chat: Option<Rect>,
|
||||||
|
roster: Option<Rect>,
|
||||||
|
sbx: Option<Rect>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Carve the body region (`rows[1]`) into chat / roster / sandbox rectangles,
|
||||||
|
/// honouring the sandbox presence, `Zoom`, and roster width. This is the single
|
||||||
|
/// source of truth used by both `draw` (painting) and `pane_at` (hit-testing).
|
||||||
|
fn body_areas(body: Rect, app: &App) -> BodyAreas {
|
||||||
|
// Vertical split: chat-column vs sandbox terminal.
|
||||||
|
let (chat_col, sbx) = if app.sandbox.is_some() {
|
||||||
|
match app.layout.zoom {
|
||||||
|
Zoom::Term => (None, Some(body)), // terminal fullscreen
|
||||||
|
Zoom::Chat => (Some(body), None), // chat fullscreen (terminal hidden)
|
||||||
|
Zoom::Normal => {
|
||||||
|
let pty = app.layout.pty_pct;
|
||||||
|
let split = Layout::vertical([
|
||||||
|
Constraint::Percentage(100 - pty),
|
||||||
|
Constraint::Percentage(pty),
|
||||||
|
])
|
||||||
|
.split(body);
|
||||||
|
(Some(split[0]), Some(split[1]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(Some(body), None) // no sandbox → chat owns the whole body
|
||||||
|
};
|
||||||
|
|
||||||
|
// Horizontal split of the chat column into chat vs roster.
|
||||||
|
let (chat, roster) = match chat_col {
|
||||||
|
Some(col) if app.layout.roster_width != 0 => {
|
||||||
|
let lr = Layout::horizontal([
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(app.layout.roster_width),
|
||||||
|
])
|
||||||
|
.split(col);
|
||||||
|
(Some(lr[0]), Some(lr[1]))
|
||||||
|
}
|
||||||
|
Some(col) => (Some(col), None), // roster hidden
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
BodyAreas { chat, roster, sbx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hit-test a screen cell against the laid-out panes, for click-to-select in
|
||||||
|
/// interactive layout editing. Recomputes the exact rects `draw` used (via the
|
||||||
|
/// shared `body_areas`) so a click lands on the pane the user actually sees.
|
||||||
|
pub fn pane_at(w: u16, h: u16, app: &App, col: u16, row: u16) -> Option<crate::app::Pane> {
|
||||||
|
use crate::app::Pane;
|
||||||
|
let area = Rect {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
};
|
||||||
|
// Mirror draw()'s top-bar / body / input split; only the body is selectable.
|
||||||
|
let rows = Layout::vertical([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(1),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
let areas = body_areas(rows[1], app);
|
||||||
|
let p = Position { x: col, y: row };
|
||||||
|
if areas.sbx.is_some_and(|r| r.contains(p)) {
|
||||||
|
Some(Pane::Terminal)
|
||||||
|
} else if areas.roster.is_some_and(|r| r.contains(p)) {
|
||||||
|
Some(Pane::Roster)
|
||||||
|
} else if areas.chat.is_some_and(|r| r.contains(p)) {
|
||||||
|
Some(Pane::Chat)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border decoration for a pane: when it's the one selected for interactive
|
||||||
|
/// resize (F5 / click), give it a bold accent border and an "✎" title marker so
|
||||||
|
/// it's obvious which pane the arrows will move; otherwise the plain `base`.
|
||||||
|
fn edit_decor(app: &App, pane: crate::app::Pane, theme: &Theme, base: Color) -> (Style, &'static str) {
|
||||||
|
if app.focused_pane == Some(pane) {
|
||||||
|
(
|
||||||
|
Style::default()
|
||||||
|
.fg(theme.accent)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
"✎ ",
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(Style::default().fg(base), "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Transient error popup, anchored top-right over the clergy so it never bleeds
|
/// Transient error popup, anchored top-right over the clergy so it never bleeds
|
||||||
/// onto the input box. Cleared by the next keypress (see the run loop).
|
/// onto the input box. Cleared by the next keypress (see the run loop).
|
||||||
fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) {
|
fn draw_error(f: &mut Frame, area: Rect, theme: &Theme, msg: &str) {
|
||||||
@@ -267,11 +360,34 @@ fn help_clusters(theme: &Theme) -> Vec<HelpCluster> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
HelpCluster {
|
||||||
|
title: "LAYOUT (resize panes)",
|
||||||
|
items: vec![
|
||||||
|
kv("F4", "fullscreen the terminal (cycle: terminal → chat → split)"),
|
||||||
|
kv(
|
||||||
|
"click a pane · F5",
|
||||||
|
"select a pane to resize (✎ marks it) — F5 cycles terminal → chat → roster",
|
||||||
|
),
|
||||||
|
kv(
|
||||||
|
"↑ / ↓ (terminal/chat selected)",
|
||||||
|
"grow / shrink that pane's height share",
|
||||||
|
),
|
||||||
|
kv("← / → (roster selected)", "narrow / widen the roster column"),
|
||||||
|
kv("Esc / Enter", "finish editing the selected pane"),
|
||||||
|
kv("/layout reset", "restore the default split"),
|
||||||
|
kv(
|
||||||
|
"/layout save <name> · load <name>",
|
||||||
|
"remember an arrangement and re-apply it later",
|
||||||
|
),
|
||||||
|
kv("/layout list · rm <name>", "list or delete saved layouts"),
|
||||||
|
],
|
||||||
|
},
|
||||||
HelpCluster {
|
HelpCluster {
|
||||||
title: "KEYS",
|
title: "KEYS",
|
||||||
items: vec![
|
items: vec![
|
||||||
kv("Enter", "send chat message"),
|
kv("Enter", "send chat message"),
|
||||||
kv("F1 · /help", "toggle this help"),
|
kv("F1 · /help", "toggle this help"),
|
||||||
|
kv("F4 · F5 · click", "layout: fullscreen terminal · select pane to resize (see LAYOUT)"),
|
||||||
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
kv("Ctrl-C (while driving)", "interrupt the running command"),
|
||||||
kv(
|
kv(
|
||||||
"Ctrl-X (owner, not driving)",
|
"Ctrl-X (owner, not driving)",
|
||||||
@@ -472,15 +588,16 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T
|
|||||||
} else {
|
} else {
|
||||||
" · /drive (or F2) · ↑/↓/wheel scroll".to_string()
|
" · /drive (or F2) · ↑/↓/wheel scroll".to_string()
|
||||||
};
|
};
|
||||||
let title = format!(" sandbox · {}{} ", sv.backend, drive);
|
let base = if app.driving {
|
||||||
let border = if app.driving {
|
|
||||||
theme.accent
|
theme.accent
|
||||||
} else {
|
} else {
|
||||||
theme.border
|
theme.border
|
||||||
};
|
};
|
||||||
|
let (border_style, mark) = edit_decor(app, crate::app::Pane::Terminal, theme, base);
|
||||||
|
let title = format!(" {mark}sandbox · {}{} ", sv.backend, drive);
|
||||||
let pane = Paragraph::new(lines).block(
|
let pane = Paragraph::new(lines).block(
|
||||||
Block::bordered()
|
Block::bordered()
|
||||||
.border_style(Style::default().fg(border))
|
.border_style(border_style)
|
||||||
.title(Span::styled(title, Style::default().fg(theme.title))),
|
.title(Span::styled(title, Style::default().fg(theme.title))),
|
||||||
);
|
);
|
||||||
f.render_widget(pane, area);
|
f.render_widget(pane, area);
|
||||||
@@ -586,15 +703,16 @@ fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Them
|
|||||||
let max_scroll = total_rows.saturating_sub(inner_h);
|
let max_scroll = total_rows.saturating_sub(inner_h);
|
||||||
let scroll = max_scroll.saturating_sub(app.chat_scroll) as u16;
|
let scroll = max_scroll.saturating_sub(app.chat_scroll) as u16;
|
||||||
|
|
||||||
|
let (border_style, mark) = edit_decor(app, crate::app::Pane::Chat, theme, theme.border);
|
||||||
let title = if app.chat_scroll > 0 {
|
let title = if app.chat_scroll > 0 {
|
||||||
format!(" chat ↑{} (End=live) ", app.chat_scroll)
|
format!(" {mark}chat ↑{} (End=live) ", app.chat_scroll)
|
||||||
} else {
|
} else {
|
||||||
" chat ".to_string()
|
format!(" {mark}chat ")
|
||||||
};
|
};
|
||||||
let chat = Paragraph::new(lines)
|
let chat = Paragraph::new(lines)
|
||||||
.block(
|
.block(
|
||||||
Block::bordered()
|
Block::bordered()
|
||||||
.border_style(Style::default().fg(theme.border))
|
.border_style(border_style)
|
||||||
.title(Span::styled(title, Style::default().fg(theme.title))),
|
.title(Span::styled(title, Style::default().fg(theme.title))),
|
||||||
)
|
)
|
||||||
.wrap(Wrap { trim: false })
|
.wrap(Wrap { trim: false })
|
||||||
@@ -639,10 +757,14 @@ fn draw_roster(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Th
|
|||||||
)))
|
)))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let (border_style, mark) = edit_decor(app, crate::app::Pane::Roster, theme, theme.border);
|
||||||
let roster = List::new(items).block(
|
let roster = List::new(items).block(
|
||||||
Block::bordered()
|
Block::bordered()
|
||||||
.border_style(Style::default().fg(theme.border))
|
.border_style(border_style)
|
||||||
.title(Span::styled(" clergy ", Style::default().fg(theme.title))),
|
.title(Span::styled(
|
||||||
|
format!(" {mark}clergy "),
|
||||||
|
Style::default().fg(theme.title),
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
f.render_widget(roster, area);
|
f.render_widget(roster, area);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user