37d5916b2e
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>
240 lines
8.1 KiB
Rust
240 lines
8.1 KiB
Rust
//! 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);
|
||
}
|
||
}
|