chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "jcode-tui-workspace"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.30"
|
||||
@@ -0,0 +1,478 @@
|
||||
use ratatui::style::Color;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ColorCapability {
|
||||
TrueColor,
|
||||
Color256,
|
||||
}
|
||||
|
||||
static CAPABILITY: OnceLock<ColorCapability> = OnceLock::new();
|
||||
|
||||
pub fn color_capability() -> ColorCapability {
|
||||
*CAPABILITY.get_or_init(detect_color_capability)
|
||||
}
|
||||
|
||||
/// Terminals whose GPU glyph atlas corrupts under heavy per-cell *truecolor*
|
||||
/// churn (the macOS 26 "garbled glyphs" bug in the VS Code integrated terminal
|
||||
/// and Apple Terminal; see issue #330 and `jcode_tui_style::color`). Capping
|
||||
/// these to the 256-color palette bounds the distinct-color space the atlas
|
||||
/// must cache, keeping markdown/mermaid colors readable. Mirrors the detection
|
||||
/// in `jcode_tui_style::color::fragile_glyph_cache_terminal`. Overridable with
|
||||
/// `JCODE_GLYPH_SAFE_MODE=on|off`.
|
||||
fn fragile_glyph_cache_terminal() -> bool {
|
||||
if let Ok(raw) = std::env::var("JCODE_GLYPH_SAFE_MODE") {
|
||||
match raw.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" | "yes" | "on" => return true,
|
||||
"0" | "false" | "no" | "off" => return false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !cfg!(target_os = "macos") {
|
||||
return false;
|
||||
}
|
||||
|
||||
match std::env::var("TERM_PROGRAM") {
|
||||
Ok(tp) => {
|
||||
let tp = tp.to_ascii_lowercase();
|
||||
tp == "vscode" || tp == "apple_terminal"
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_color_capability() -> ColorCapability {
|
||||
let raw = detect_raw_color_capability();
|
||||
// Downgrade truecolor to 256-color on fragile-glyph terminals so animated
|
||||
// colors quantize to a bounded palette instead of overflowing the GPU
|
||||
// glyph atlas (#330).
|
||||
if raw == ColorCapability::TrueColor && fragile_glyph_cache_terminal() {
|
||||
return ColorCapability::Color256;
|
||||
}
|
||||
raw
|
||||
}
|
||||
|
||||
fn detect_raw_color_capability() -> ColorCapability {
|
||||
if let Ok(val) = std::env::var("COLORTERM") {
|
||||
let v = val.to_lowercase();
|
||||
if v == "truecolor" || v == "24bit" {
|
||||
return ColorCapability::TrueColor;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(term_program) = std::env::var("TERM_PROGRAM") {
|
||||
let tp = term_program.to_lowercase();
|
||||
if tp == "ghostty"
|
||||
|| tp == "iterm.app"
|
||||
|| tp == "wezterm"
|
||||
|| tp == "warp"
|
||||
|| tp == "alacritty"
|
||||
|| tp == "hyper"
|
||||
{
|
||||
return ColorCapability::TrueColor;
|
||||
}
|
||||
}
|
||||
|
||||
if std::env::var("GHOSTTY_RESOURCES_DIR").is_ok()
|
||||
|| std::env::var("GHOSTTY_BIN_DIR").is_ok()
|
||||
|| std::env::var("WEZTERM_EXECUTABLE").is_ok()
|
||||
|| std::env::var("WEZTERM_PANE").is_ok()
|
||||
{
|
||||
return ColorCapability::TrueColor;
|
||||
}
|
||||
|
||||
if let Ok(term) = std::env::var("TERM") {
|
||||
let t = term.to_lowercase();
|
||||
if t.contains("kitty") || t.contains("ghostty") || t.contains("alacritty") {
|
||||
return ColorCapability::TrueColor;
|
||||
}
|
||||
if t.contains("256color") {
|
||||
return ColorCapability::Color256;
|
||||
}
|
||||
}
|
||||
|
||||
ColorCapability::Color256
|
||||
}
|
||||
|
||||
pub fn has_truecolor() -> bool {
|
||||
color_capability() == ColorCapability::TrueColor
|
||||
}
|
||||
|
||||
pub fn clear_buf(area: Rect, buf: &mut Buffer) {
|
||||
for x in area.left()..area.right() {
|
||||
for y in area.top()..area.bottom() {
|
||||
buf[(x, y)].reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn rgb(r: u8, g: u8, b: u8) -> Color {
|
||||
if has_truecolor() {
|
||||
Color::Rgb(r, g, b)
|
||||
} else {
|
||||
Color::Indexed(rgb_to_xterm256(r, g, b))
|
||||
}
|
||||
}
|
||||
|
||||
// The xterm-256 color cube: indices 16-231 map to a 6x6x6 RGB cube.
|
||||
// Each axis uses values: 0, 95, 135, 175, 215, 255 (indices 0-5).
|
||||
// Indices 232-255 are a grayscale ramp from rgb(8,8,8) to rgb(238,238,238).
|
||||
fn rgb_to_xterm256(r: u8, g: u8, b: u8) -> u8 {
|
||||
let gray_avg = (r as u16 + g as u16 + b as u16) / 3;
|
||||
|
||||
let cube_idx = nearest_cube_index(r, g, b);
|
||||
let cube_color = cube_index_to_rgb(cube_idx);
|
||||
let cube_dist = color_distance(r, g, b, cube_color.0, cube_color.1, cube_color.2);
|
||||
|
||||
// Always evaluate the grayscale ramp candidate too and pick whichever is
|
||||
// perceptually closer. The previous `is_grayish` gate (all channels within
|
||||
// 15 of each other) excluded near-neutral colors whose channels happened to
|
||||
// span exactly 15, so subtle dark gray-blues like the user-prompt
|
||||
// background `rgb(35,40,50)` snapped to a saturated navy cube corner
|
||||
// (index 17 = `(0,0,95)`) on 256-color terminals such as Apple Terminal.
|
||||
// Comparing both candidates is strictly never worse and keeps these tones
|
||||
// reading as the intended neutral gray.
|
||||
let gray_idx = nearest_gray_index(gray_avg as u8);
|
||||
let gray_val = gray_index_to_value(gray_idx);
|
||||
let gray_dist = color_distance(r, g, b, gray_val, gray_val, gray_val);
|
||||
|
||||
if gray_dist < cube_dist {
|
||||
return 232 + gray_idx;
|
||||
}
|
||||
|
||||
cube_idx as u8 + 16
|
||||
}
|
||||
|
||||
const CUBE_VALUES: [u8; 6] = [0, 95, 135, 175, 215, 255];
|
||||
|
||||
/// Return the one or two cube axis indices whose value is nearest `v`. There
|
||||
/// are exactly two when `v` sits at a midpoint between adjacent steps (e.g.
|
||||
/// 115 is equidistant from 95 and 135); those are genuine ties that the older
|
||||
/// code silently resolved toward the lower step.
|
||||
fn nearest_cube_components(v: u8) -> ([u8; 2], usize) {
|
||||
let mut best = 0u8;
|
||||
let mut best_dist = u16::MAX;
|
||||
for (i, &cv) in CUBE_VALUES.iter().enumerate() {
|
||||
let d = (v as i16 - cv as i16).unsigned_abs();
|
||||
if d < best_dist {
|
||||
best_dist = d;
|
||||
best = i as u8;
|
||||
}
|
||||
}
|
||||
// Check whether the next step up ties the best distance.
|
||||
let next = best as usize + 1;
|
||||
if next < CUBE_VALUES.len() && (v as i16 - CUBE_VALUES[next] as i16).unsigned_abs() == best_dist
|
||||
{
|
||||
([best, best + 1], 2)
|
||||
} else {
|
||||
([best, best], 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hue scaled to 0..1530 (= 6 * 255) using only integer math, mirroring HSV
|
||||
/// hue ordering. Achromatic colors return 0 so a gray candidate never looks
|
||||
/// "hue-closer" to a tinted target than a same-hue cube color.
|
||||
fn hue_scaled(r: u8, g: u8, b: u8) -> i32 {
|
||||
let (r, g, b) = (r as i32, g as i32, b as i32);
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let chroma = max - min;
|
||||
if chroma == 0 {
|
||||
return 0;
|
||||
}
|
||||
let h = if max == r {
|
||||
((g - b) * 255 / chroma).rem_euclid(1530)
|
||||
} else if max == g {
|
||||
(b - r) * 255 / chroma + 510
|
||||
} else {
|
||||
(r - g) * 255 / chroma + 1020
|
||||
};
|
||||
h.rem_euclid(1530)
|
||||
}
|
||||
|
||||
fn hue_distance(target_hue: i32, r: u8, g: u8, b: u8) -> i32 {
|
||||
let d = (target_hue - hue_scaled(r, g, b)).abs();
|
||||
d.min(1530 - d)
|
||||
}
|
||||
|
||||
/// Pick the nearest cube color, breaking exact per-channel ties in favor of the
|
||||
/// candidate whose hue best matches the target (then higher chroma). All tie
|
||||
/// candidates are equidistant under the weighted metric, so this never picks a
|
||||
/// color farther from the target; it only stops light tints like
|
||||
/// `rgb(190,210,235)` from collapsing onto a duller, hue-shifted neighbor
|
||||
/// (e.g. teal `(175,215,215)` instead of light blue `(175,215,255)`) on
|
||||
/// 256-color terminals such as Apple Terminal.
|
||||
fn nearest_cube_index(r: u8, g: u8, b: u8) -> u16 {
|
||||
let (rs, rn) = nearest_cube_components(r);
|
||||
let (gs, gn) = nearest_cube_components(g);
|
||||
let (bs, bn) = nearest_cube_components(b);
|
||||
|
||||
if rn == 1 && gn == 1 && bn == 1 {
|
||||
return rs[0] as u16 * 36 + gs[0] as u16 * 6 + bs[0] as u16;
|
||||
}
|
||||
|
||||
let target_hue = hue_scaled(r, g, b);
|
||||
let mut best_idx = 0u16;
|
||||
let mut best_key = (i32::MAX, i32::MIN);
|
||||
for &ri in &rs[..rn] {
|
||||
for &gi in &gs[..gn] {
|
||||
for &bi in &bs[..bn] {
|
||||
let cr = CUBE_VALUES[ri as usize];
|
||||
let cg = CUBE_VALUES[gi as usize];
|
||||
let cb = CUBE_VALUES[bi as usize];
|
||||
let chroma = cr.max(cg).max(cb) as i32 - cr.min(cg).min(cb) as i32;
|
||||
let key = (hue_distance(target_hue, cr, cg, cb), -chroma);
|
||||
if key < best_key {
|
||||
best_key = key;
|
||||
best_idx = ri as u16 * 36 + gi as u16 * 6 + bi as u16;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
best_idx
|
||||
}
|
||||
|
||||
fn cube_index_to_rgb(idx: u16) -> (u8, u8, u8) {
|
||||
let bi = (idx % 6) as usize;
|
||||
let gi = ((idx / 6) % 6) as usize;
|
||||
let ri = (idx / 36) as usize;
|
||||
(CUBE_VALUES[ri], CUBE_VALUES[gi], CUBE_VALUES[bi])
|
||||
}
|
||||
|
||||
fn nearest_gray_index(v: u8) -> u8 {
|
||||
// Grayscale ramp: 232-255, values 8, 18, 28, ..., 238 (24 steps, step=10).
|
||||
// Use signed math so values just below the first ramp entry (1..=7) round
|
||||
// to index 0 instead of underflowing (`v - 8`).
|
||||
if v > 243 {
|
||||
return 23;
|
||||
}
|
||||
(((v as i16 - 8 + 5) / 10).clamp(0, 23)) as u8
|
||||
}
|
||||
|
||||
fn gray_index_to_value(idx: u8) -> u8 {
|
||||
8 + idx * 10
|
||||
}
|
||||
|
||||
fn color_distance(r1: u8, g1: u8, b1: u8, r2: u8, g2: u8, b2: u8) -> u32 {
|
||||
let dr = r1 as i32 - r2 as i32;
|
||||
let dg = g1 as i32 - g2 as i32;
|
||||
let db = b1 as i32 - b2 as i32;
|
||||
// Weighted Euclidean - human eye is more sensitive to green
|
||||
(2 * dr * dr + 4 * dg * dg + 3 * db * db) as u32
|
||||
}
|
||||
|
||||
pub fn indexed_to_rgb(idx: u8) -> (u8, u8, u8) {
|
||||
if idx >= 232 {
|
||||
let v = gray_index_to_value(idx - 232);
|
||||
(v, v, v)
|
||||
} else if idx >= 16 {
|
||||
cube_index_to_rgb((idx - 16) as u16)
|
||||
} else {
|
||||
match idx {
|
||||
0 => (0, 0, 0),
|
||||
1 => (128, 0, 0),
|
||||
2 => (0, 128, 0),
|
||||
3 => (128, 128, 0),
|
||||
4 => (0, 0, 128),
|
||||
5 => (128, 0, 128),
|
||||
6 => (0, 128, 128),
|
||||
7 => (192, 192, 192),
|
||||
8 => (128, 128, 128),
|
||||
9 => (255, 0, 0),
|
||||
10 => (0, 255, 0),
|
||||
11 => (255, 255, 0),
|
||||
12 => (0, 0, 255),
|
||||
13 => (255, 0, 255),
|
||||
14 => (0, 255, 255),
|
||||
_ => (255, 255, 255),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_pure_black() {
|
||||
let idx = rgb_to_xterm256(0, 0, 0);
|
||||
assert_eq!(idx, 16); // cube index 0,0,0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pure_white() {
|
||||
let idx = rgb_to_xterm256(255, 255, 255);
|
||||
assert_eq!(idx, 231); // cube index 5,5,5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mid_gray() {
|
||||
let idx = rgb_to_xterm256(128, 128, 128);
|
||||
// Should pick grayscale 243 (value 128) or nearby
|
||||
assert!(
|
||||
(232..=255).contains(&u16::from(idx)),
|
||||
"Expected grayscale, got {}",
|
||||
idx
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dim_gray() {
|
||||
let idx = rgb_to_xterm256(80, 80, 80);
|
||||
assert!(
|
||||
(232..=255).contains(&u16::from(idx)),
|
||||
"Expected grayscale for dim, got {}",
|
||||
idx
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_red() {
|
||||
let idx = rgb_to_xterm256(255, 0, 0);
|
||||
assert_eq!(idx, 196); // cube 5,0,0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_green() {
|
||||
let idx = rgb_to_xterm256(0, 255, 0);
|
||||
assert_eq!(idx, 46); // cube 0,5,0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blue() {
|
||||
let idx = rgb_to_xterm256(0, 0, 255);
|
||||
assert_eq!(idx, 21); // cube 0,0,5
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rgb_truecolor() {
|
||||
// When we have truecolor, rgb() should return Color::Rgb
|
||||
// (can't easily test since it depends on env, but test the mapper)
|
||||
let color = Color::Indexed(rgb_to_xterm256(138, 180, 248));
|
||||
match color {
|
||||
Color::Indexed(n) => assert!(n >= 16, "Should be extended color"),
|
||||
_ => panic!("Expected indexed color"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_near_colors_are_stable() {
|
||||
let a = rgb_to_xterm256(80, 80, 80);
|
||||
let b = rgb_to_xterm256(82, 82, 82);
|
||||
assert_eq!(a, b, "Similar grays should map to same index");
|
||||
}
|
||||
|
||||
/// Regression for the Apple Terminal "navy user prompt" bug: the subtle
|
||||
/// dark gray-blue user-prompt background `rgb(35,40,50)` must quantize to a
|
||||
/// neutral grayscale ramp entry, not a saturated navy cube corner
|
||||
/// (index 17 = `(0,0,95)`). Its channels span exactly 15, which the old
|
||||
/// `is_grayish` gate (`< 15`) excluded, snapping it to navy on 256-color
|
||||
/// terminals.
|
||||
#[test]
|
||||
fn test_near_neutral_dark_blue_quantizes_to_gray_not_navy() {
|
||||
let idx = rgb_to_xterm256(35, 40, 50);
|
||||
assert_ne!(idx, 17, "must not snap to saturated navy (0,0,95)");
|
||||
assert!(
|
||||
(232..=255).contains(&u16::from(idx)),
|
||||
"near-neutral dark tone should map to the grayscale ramp, got {idx}"
|
||||
);
|
||||
let (r, g, b) = indexed_to_rgb(idx);
|
||||
assert_eq!((r, g, b), (38, 38, 38), "expected neutral gray ramp entry");
|
||||
}
|
||||
|
||||
/// Light blue tints whose blue channel sits exactly between cube steps
|
||||
/// (e.g. `header_name` = `rgb(190,210,235)`, blue 235 ties 215/255) must
|
||||
/// keep their blue cast instead of collapsing onto the duller teal
|
||||
/// neighbor `(175,215,215)`. The old code always rounded ties down, which
|
||||
/// dropped blue to equal green and shifted the hue ~33 degrees toward cyan
|
||||
/// on 256-color terminals such as Apple Terminal.
|
||||
#[test]
|
||||
fn test_light_blue_tint_keeps_blue_cast_on_tie() {
|
||||
let idx = rgb_to_xterm256(190, 210, 235);
|
||||
let (r, g, b) = indexed_to_rgb(idx);
|
||||
assert_eq!(
|
||||
(r, g, b),
|
||||
(175, 215, 255),
|
||||
"light blue should stay blue, not become teal (175,215,215)"
|
||||
);
|
||||
assert!(b > g, "blue channel must remain dominant over green");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod fragile_glyph_tests {
|
||||
use super::*;
|
||||
use std::sync::Mutex;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn temp_env_scope(vars: &[(&str, Option<&str>)], body: impl FnOnce()) {
|
||||
let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let saved: Vec<(String, Option<String>)> = vars
|
||||
.iter()
|
||||
.map(|(k, _)| ((*k).to_string(), std::env::var(k).ok()))
|
||||
.collect();
|
||||
for (k, v) in vars {
|
||||
match v {
|
||||
Some(val) => unsafe { std::env::set_var(k, val) },
|
||||
None => unsafe { std::env::remove_var(k) },
|
||||
}
|
||||
}
|
||||
body();
|
||||
for (k, v) in saved {
|
||||
match v {
|
||||
Some(val) => unsafe { std::env::set_var(&k, val) },
|
||||
None => unsafe { std::env::remove_var(&k) },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_off_forces_truecolor() {
|
||||
temp_env_scope(
|
||||
&[
|
||||
("JCODE_GLYPH_SAFE_MODE", Some("off")),
|
||||
("TERM_PROGRAM", Some("vscode")),
|
||||
],
|
||||
|| assert!(!fragile_glyph_cache_terminal()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_on_forces_quantize() {
|
||||
temp_env_scope(&[("JCODE_GLYPH_SAFE_MODE", Some("on"))], || {
|
||||
assert!(fragile_glyph_cache_terminal())
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn detects_vscode_and_apple_terminal() {
|
||||
temp_env_scope(
|
||||
&[
|
||||
("JCODE_GLYPH_SAFE_MODE", None),
|
||||
("TERM_PROGRAM", Some("vscode")),
|
||||
],
|
||||
|| assert!(fragile_glyph_cache_terminal()),
|
||||
);
|
||||
temp_env_scope(
|
||||
&[
|
||||
("JCODE_GLYPH_SAFE_MODE", None),
|
||||
("TERM_PROGRAM", Some("Apple_Terminal")),
|
||||
],
|
||||
|| assert!(fragile_glyph_cache_terminal()),
|
||||
);
|
||||
temp_env_scope(
|
||||
&[
|
||||
("JCODE_GLYPH_SAFE_MODE", None),
|
||||
("TERM_PROGRAM", Some("ghostty")),
|
||||
],
|
||||
|| assert!(!fragile_glyph_cache_terminal()),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod color_support;
|
||||
pub mod workspace_map;
|
||||
pub mod workspace_map_widget;
|
||||
@@ -0,0 +1,411 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Visual state for a session rectangle in the workspace map.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum WorkspaceSessionVisualState {
|
||||
#[default]
|
||||
Idle,
|
||||
Running,
|
||||
Completed,
|
||||
Waiting,
|
||||
Error,
|
||||
Detached,
|
||||
}
|
||||
|
||||
/// A single session in a Niri-style horizontal workspace strip.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct WorkspaceSessionTile {
|
||||
pub session_id: String,
|
||||
pub state: WorkspaceSessionVisualState,
|
||||
}
|
||||
|
||||
impl WorkspaceSessionTile {
|
||||
pub fn new(session_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
session_id: session_id.into(),
|
||||
state: WorkspaceSessionVisualState::Idle,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_state(session_id: impl Into<String>, state: WorkspaceSessionVisualState) -> Self {
|
||||
Self {
|
||||
session_id: session_id.into(),
|
||||
state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A logical workspace row. Sessions are ordered left-to-right.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct WorkspaceRow {
|
||||
pub sessions: Vec<WorkspaceSessionTile>,
|
||||
/// Last focused session index within this row.
|
||||
pub last_focused: Option<usize>,
|
||||
}
|
||||
|
||||
impl WorkspaceRow {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.sessions.is_empty()
|
||||
}
|
||||
|
||||
pub fn focused_index(&self) -> Option<usize> {
|
||||
let len = self.sessions.len();
|
||||
self.last_focused
|
||||
.filter(|idx| *idx < len)
|
||||
.or_else(|| (!self.sessions.is_empty()).then_some(0))
|
||||
}
|
||||
|
||||
pub fn focus(&mut self, index: usize) -> bool {
|
||||
if index < self.sessions.len() {
|
||||
self.last_focused = Some(index);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a session to the right of the currently focused session.
|
||||
/// If nothing is focused yet, append to the end.
|
||||
pub fn insert_right_of_focus(&mut self, tile: WorkspaceSessionTile) -> usize {
|
||||
let insert_at = self
|
||||
.focused_index()
|
||||
.map(|idx| (idx + 1).min(self.sessions.len()))
|
||||
.unwrap_or(self.sessions.len());
|
||||
self.sessions.insert(insert_at, tile);
|
||||
self.last_focused = Some(insert_at);
|
||||
insert_at
|
||||
}
|
||||
|
||||
pub fn move_focus_left(&mut self) -> bool {
|
||||
let Some(current) = self.focused_index() else {
|
||||
return false;
|
||||
};
|
||||
if current == 0 {
|
||||
return false;
|
||||
}
|
||||
self.last_focused = Some(current - 1);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn move_focus_right(&mut self) -> bool {
|
||||
let Some(current) = self.focused_index() else {
|
||||
return false;
|
||||
};
|
||||
if current + 1 >= self.sessions.len() {
|
||||
return false;
|
||||
}
|
||||
self.last_focused = Some(current + 1);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// A full Niri-style session workspace model.
|
||||
///
|
||||
/// Horizontal movement happens within a row. Vertical movement switches rows,
|
||||
/// restoring the remembered focus for that workspace.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct WorkspaceMapModel {
|
||||
rows: BTreeMap<i32, WorkspaceRow>,
|
||||
current_workspace: i32,
|
||||
}
|
||||
|
||||
impl WorkspaceMapModel {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn current_workspace(&self) -> i32 {
|
||||
self.current_workspace
|
||||
}
|
||||
|
||||
pub fn set_current_workspace(&mut self, workspace: i32) {
|
||||
self.current_workspace = workspace;
|
||||
self.rows.entry(workspace).or_default();
|
||||
}
|
||||
|
||||
pub fn row(&self, workspace: i32) -> Option<&WorkspaceRow> {
|
||||
self.rows.get(&workspace)
|
||||
}
|
||||
|
||||
pub fn row_mut(&mut self, workspace: i32) -> &mut WorkspaceRow {
|
||||
self.rows.entry(workspace).or_default()
|
||||
}
|
||||
|
||||
pub fn current_row(&self) -> Option<&WorkspaceRow> {
|
||||
self.row(self.current_workspace)
|
||||
}
|
||||
|
||||
pub fn current_row_mut(&mut self) -> &mut WorkspaceRow {
|
||||
self.row_mut(self.current_workspace)
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rows.values().all(WorkspaceRow::is_empty)
|
||||
}
|
||||
|
||||
pub fn add_session_to_current_workspace(&mut self, tile: WorkspaceSessionTile) -> (i32, usize) {
|
||||
let workspace = self.current_workspace;
|
||||
let index = self.current_row_mut().insert_right_of_focus(tile);
|
||||
(workspace, index)
|
||||
}
|
||||
|
||||
pub fn focus_session_in_workspace(&mut self, workspace: i32, index: usize) -> bool {
|
||||
self.row_mut(workspace).focus(index)
|
||||
}
|
||||
|
||||
pub fn locate_session(&self, session_id: &str) -> Option<(i32, usize)> {
|
||||
self.rows.iter().find_map(|(workspace, row)| {
|
||||
row.sessions
|
||||
.iter()
|
||||
.position(|tile| tile.session_id == session_id)
|
||||
.map(|index| (*workspace, index))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn focus_session_by_id(&mut self, session_id: &str) -> bool {
|
||||
let Some((workspace, index)) = self.locate_session(session_id) else {
|
||||
return false;
|
||||
};
|
||||
self.current_workspace = workspace;
|
||||
self.row_mut(workspace).focus(index)
|
||||
}
|
||||
|
||||
pub fn current_focused_session_id(&self) -> Option<&str> {
|
||||
let row = self.current_row()?;
|
||||
let index = row.focused_index()?;
|
||||
row.sessions.get(index).map(|tile| tile.session_id.as_str())
|
||||
}
|
||||
|
||||
pub fn set_row_sessions(
|
||||
&mut self,
|
||||
workspace: i32,
|
||||
sessions: Vec<WorkspaceSessionTile>,
|
||||
focused_index: Option<usize>,
|
||||
) {
|
||||
let row = self.row_mut(workspace);
|
||||
row.sessions = sessions;
|
||||
row.last_focused = focused_index.filter(|idx| *idx < row.sessions.len());
|
||||
}
|
||||
|
||||
pub fn insert_session_in_workspace(
|
||||
&mut self,
|
||||
workspace: i32,
|
||||
tile: WorkspaceSessionTile,
|
||||
) -> usize {
|
||||
self.current_workspace = workspace;
|
||||
self.row_mut(workspace).insert_right_of_focus(tile)
|
||||
}
|
||||
|
||||
pub fn focused_session_in_workspace(&self, workspace: i32) -> Option<&str> {
|
||||
let row = self.row(workspace)?;
|
||||
let index = row.focused_index()?;
|
||||
row.sessions.get(index).map(|tile| tile.session_id.as_str())
|
||||
}
|
||||
|
||||
pub fn nearest_populated_workspace_above(&self) -> Option<i32> {
|
||||
self.rows
|
||||
.iter()
|
||||
.filter_map(|(workspace, row)| {
|
||||
(*workspace > self.current_workspace && !row.is_empty()).then_some(*workspace)
|
||||
})
|
||||
.min()
|
||||
}
|
||||
|
||||
pub fn nearest_populated_workspace_below(&self) -> Option<i32> {
|
||||
self.rows
|
||||
.iter()
|
||||
.filter_map(|(workspace, row)| {
|
||||
(*workspace < self.current_workspace && !row.is_empty()).then_some(*workspace)
|
||||
})
|
||||
.max()
|
||||
}
|
||||
|
||||
pub fn move_left(&mut self) -> bool {
|
||||
self.current_row_mut().move_focus_left()
|
||||
}
|
||||
|
||||
pub fn move_right(&mut self) -> bool {
|
||||
self.current_row_mut().move_focus_right()
|
||||
}
|
||||
|
||||
/// Move to the workspace above the current one, creating it if needed.
|
||||
pub fn move_up(&mut self) {
|
||||
self.current_workspace += 1;
|
||||
self.rows.entry(self.current_workspace).or_default();
|
||||
}
|
||||
|
||||
/// Move to the workspace below the current one, creating it if needed.
|
||||
pub fn move_down(&mut self) {
|
||||
self.current_workspace -= 1;
|
||||
self.rows.entry(self.current_workspace).or_default();
|
||||
}
|
||||
|
||||
pub fn populated_workspaces(&self) -> Vec<i32> {
|
||||
self.rows
|
||||
.iter()
|
||||
.filter_map(|(workspace, row)| (!row.is_empty()).then_some(*workspace))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns visible rows centered on the current workspace.
|
||||
///
|
||||
/// Empty rows are omitted unless the row is the current workspace.
|
||||
pub fn visible_rows(&self, max_rows: usize) -> Vec<VisibleWorkspaceRow> {
|
||||
if max_rows == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut ordered: Vec<i32> = self
|
||||
.rows
|
||||
.iter()
|
||||
.filter_map(|(workspace, row)| {
|
||||
if *workspace == self.current_workspace || !row.is_empty() {
|
||||
Some(*workspace)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
ordered.sort_unstable_by(|a, b| b.cmp(a));
|
||||
|
||||
if ordered.is_empty() {
|
||||
ordered.push(self.current_workspace);
|
||||
}
|
||||
|
||||
let current_pos = ordered
|
||||
.iter()
|
||||
.position(|workspace| *workspace == self.current_workspace)
|
||||
.unwrap_or(0);
|
||||
let half = max_rows / 2;
|
||||
let mut start = current_pos.saturating_sub(half);
|
||||
let end = (start + max_rows).min(ordered.len());
|
||||
if end - start < max_rows {
|
||||
start = end.saturating_sub(max_rows);
|
||||
}
|
||||
let slice = &ordered[start..end];
|
||||
|
||||
slice
|
||||
.iter()
|
||||
.map(|workspace| {
|
||||
let row = self.rows.get(workspace).cloned().unwrap_or_default();
|
||||
VisibleWorkspaceRow {
|
||||
workspace: *workspace,
|
||||
is_current: *workspace == self.current_workspace,
|
||||
focused_index: row.focused_index(),
|
||||
sessions: row.sessions,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VisibleWorkspaceRow {
|
||||
pub workspace: i32,
|
||||
pub is_current: bool,
|
||||
pub focused_index: Option<usize>,
|
||||
pub sessions: Vec<WorkspaceSessionTile>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{WorkspaceMapModel, WorkspaceSessionTile, WorkspaceSessionVisualState};
|
||||
|
||||
#[test]
|
||||
fn add_session_grows_current_row_to_the_right() {
|
||||
let mut map = WorkspaceMapModel::new();
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("fox"));
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("bear"));
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("owl"));
|
||||
|
||||
let row = map.current_row().expect("current row");
|
||||
let ids: Vec<_> = row.sessions.iter().map(|t| t.session_id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["fox", "bear", "owl"]);
|
||||
assert_eq!(row.focused_index(), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserting_after_refocusing_places_new_session_to_the_right_of_focus() {
|
||||
let mut map = WorkspaceMapModel::new();
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("fox"));
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("bear"));
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("owl"));
|
||||
|
||||
assert!(map.focus_session_in_workspace(0, 0));
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("ibis"));
|
||||
|
||||
let row = map.current_row().expect("current row");
|
||||
let ids: Vec<_> = row.sessions.iter().map(|t| t.session_id.as_str()).collect();
|
||||
assert_eq!(ids, vec!["fox", "ibis", "bear", "owl"]);
|
||||
assert_eq!(row.focused_index(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_between_workspaces_remembers_last_focus_per_workspace() {
|
||||
let mut map = WorkspaceMapModel::new();
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("fox"));
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("bear"));
|
||||
assert!(map.move_left());
|
||||
assert_eq!(
|
||||
map.current_row().and_then(|row| row.focused_index()),
|
||||
Some(0)
|
||||
);
|
||||
|
||||
map.move_up();
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("owl"));
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("ibis"));
|
||||
assert!(map.move_left());
|
||||
assert_eq!(map.current_workspace(), 1);
|
||||
assert_eq!(
|
||||
map.current_row().and_then(|row| row.focused_index()),
|
||||
Some(0)
|
||||
);
|
||||
|
||||
map.move_down();
|
||||
assert_eq!(map.current_workspace(), 0);
|
||||
assert_eq!(
|
||||
map.current_row().and_then(|row| row.focused_index()),
|
||||
Some(0)
|
||||
);
|
||||
|
||||
map.move_up();
|
||||
assert_eq!(map.current_workspace(), 1);
|
||||
assert_eq!(
|
||||
map.current_row().and_then(|row| row.focused_index()),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_rows_only_include_populated_rows_and_current_workspace() {
|
||||
let mut map = WorkspaceMapModel::new();
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("fox"));
|
||||
map.move_up();
|
||||
map.move_up();
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::new("owl"));
|
||||
map.move_down();
|
||||
|
||||
let rows = map.visible_rows(5);
|
||||
let workspaces: Vec<_> = rows.iter().map(|row| row.workspace).collect();
|
||||
assert_eq!(workspaces, vec![2, 1, 0]);
|
||||
assert!(rows.iter().any(|row| row.workspace == 1 && row.is_current));
|
||||
assert!(
|
||||
rows.iter()
|
||||
.find(|row| row.workspace == 1)
|
||||
.expect("current workspace row")
|
||||
.sessions
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_tiles_preserve_visual_state() {
|
||||
let mut map = WorkspaceMapModel::new();
|
||||
map.add_session_to_current_workspace(WorkspaceSessionTile::with_state(
|
||||
"fox",
|
||||
WorkspaceSessionVisualState::Running,
|
||||
));
|
||||
let row = map.current_row().expect("current row");
|
||||
assert_eq!(row.sessions[0].state, WorkspaceSessionVisualState::Running);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
use crate::color_support::rgb;
|
||||
use crate::workspace_map::{VisibleWorkspaceRow, WorkspaceSessionVisualState};
|
||||
use ratatui::{
|
||||
buffer::Buffer,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
};
|
||||
|
||||
const TILE_WIDTH: u16 = 1;
|
||||
const TILE_HEIGHT: u16 = 1;
|
||||
const COL_GAP: u16 = 1;
|
||||
const ROW_GAP: u16 = 1;
|
||||
|
||||
pub fn preferred_size(rows: &[VisibleWorkspaceRow]) -> (u16, u16) {
|
||||
let max_tiles = rows.iter().map(|row| row.sessions.len()).max().unwrap_or(0) as u16;
|
||||
let width = if max_tiles == 0 {
|
||||
TILE_WIDTH
|
||||
} else {
|
||||
max_tiles * TILE_WIDTH + max_tiles.saturating_sub(1) * COL_GAP
|
||||
};
|
||||
let height = rows.len() as u16 * TILE_HEIGHT + rows.len().saturating_sub(1) as u16 * ROW_GAP;
|
||||
(width, height)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct WorkspaceTilePlacement {
|
||||
pub workspace: i32,
|
||||
pub session_index: usize,
|
||||
pub rect: Rect,
|
||||
pub focused: bool,
|
||||
pub current_workspace: bool,
|
||||
pub state: WorkspaceSessionVisualState,
|
||||
}
|
||||
|
||||
pub fn compute_workspace_tile_placements(
|
||||
area: Rect,
|
||||
rows: &[VisibleWorkspaceRow],
|
||||
) -> Vec<WorkspaceTilePlacement> {
|
||||
if area.width == 0 || area.height == 0 || rows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let row_stride = TILE_HEIGHT + ROW_GAP;
|
||||
let total_height = rows
|
||||
.len()
|
||||
.saturating_mul(TILE_HEIGHT as usize)
|
||||
.saturating_add(rows.len().saturating_sub(1) * ROW_GAP as usize)
|
||||
.min(u16::MAX as usize) as u16;
|
||||
let top_offset = area.y + area.height.saturating_sub(total_height) / 2;
|
||||
|
||||
let mut placements = Vec::new();
|
||||
for (row_idx, row) in rows.iter().enumerate() {
|
||||
let tile_count = row.sessions.len() as u16;
|
||||
let row_width = if tile_count == 0 {
|
||||
0
|
||||
} else {
|
||||
tile_count * TILE_WIDTH + tile_count.saturating_sub(1) * COL_GAP
|
||||
};
|
||||
let left_offset = area.x + area.width.saturating_sub(row_width) / 2;
|
||||
let y = top_offset + (row_idx as u16 * row_stride);
|
||||
|
||||
for (session_index, session) in row.sessions.iter().enumerate() {
|
||||
let x = left_offset + (session_index as u16 * (TILE_WIDTH + COL_GAP));
|
||||
let area_right = area.x.saturating_add(area.width);
|
||||
let area_bottom = area.y.saturating_add(area.height);
|
||||
if x >= area_right || y >= area_bottom {
|
||||
continue;
|
||||
}
|
||||
let width = area_right.saturating_sub(x).min(TILE_WIDTH);
|
||||
let height = area_bottom.saturating_sub(y).min(TILE_HEIGHT);
|
||||
if width == 0 || height == 0 {
|
||||
continue;
|
||||
}
|
||||
placements.push(WorkspaceTilePlacement {
|
||||
workspace: row.workspace,
|
||||
session_index,
|
||||
rect: Rect::new(x, y, width, height),
|
||||
focused: row.focused_index == Some(session_index),
|
||||
current_workspace: row.is_current,
|
||||
state: session.state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
placements
|
||||
}
|
||||
|
||||
pub fn render_workspace_map(buf: &mut Buffer, area: Rect, rows: &[VisibleWorkspaceRow], tick: u64) {
|
||||
clear_area(buf, area);
|
||||
for placement in compute_workspace_tile_placements(area, rows) {
|
||||
draw_workspace_tile(buf, placement, tick);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_area(buf: &mut Buffer, area: Rect) {
|
||||
for y in area.y..area.y.saturating_add(area.height) {
|
||||
for x in area.x..area.x.saturating_add(area.width) {
|
||||
buf[(x, y)].set_symbol(" ").set_style(Style::default());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_workspace_tile(buf: &mut Buffer, placement: WorkspaceTilePlacement, tick: u64) {
|
||||
if placement.rect.width == 0 || placement.rect.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let fg = tile_color(
|
||||
placement.state,
|
||||
placement.focused,
|
||||
placement.current_workspace,
|
||||
tick,
|
||||
);
|
||||
let symbol = tile_symbol(placement.state, placement.focused, tick);
|
||||
let style = if placement.focused {
|
||||
Style::default().fg(fg).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(fg)
|
||||
};
|
||||
|
||||
for y in placement.rect.y..placement.rect.y.saturating_add(placement.rect.height) {
|
||||
for x in placement.rect.x..placement.rect.x.saturating_add(placement.rect.width) {
|
||||
buf[(x, y)].set_symbol(symbol).set_style(style);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tile_symbol(state: WorkspaceSessionVisualState, focused: bool, tick: u64) -> &'static str {
|
||||
match state {
|
||||
WorkspaceSessionVisualState::Running => match tick % 4 {
|
||||
0 => "◴",
|
||||
1 => "◷",
|
||||
2 => "◶",
|
||||
_ => "◵",
|
||||
},
|
||||
_ if focused => "■",
|
||||
_ => "▪",
|
||||
}
|
||||
}
|
||||
|
||||
fn tile_color(
|
||||
state: WorkspaceSessionVisualState,
|
||||
focused: bool,
|
||||
current_workspace: bool,
|
||||
tick: u64,
|
||||
) -> Color {
|
||||
match state {
|
||||
WorkspaceSessionVisualState::Running => {
|
||||
if focused {
|
||||
if tick.is_multiple_of(2) {
|
||||
rgb(180, 220, 255)
|
||||
} else {
|
||||
rgb(130, 170, 220)
|
||||
}
|
||||
} else if tick.is_multiple_of(2) {
|
||||
rgb(140, 200, 255)
|
||||
} else {
|
||||
rgb(90, 140, 190)
|
||||
}
|
||||
}
|
||||
WorkspaceSessionVisualState::Error => {
|
||||
if focused {
|
||||
rgb(255, 160, 160)
|
||||
} else {
|
||||
rgb(255, 120, 120)
|
||||
}
|
||||
}
|
||||
WorkspaceSessionVisualState::Waiting => {
|
||||
if focused {
|
||||
rgb(255, 225, 150)
|
||||
} else {
|
||||
rgb(255, 210, 120)
|
||||
}
|
||||
}
|
||||
WorkspaceSessionVisualState::Completed => {
|
||||
if focused {
|
||||
rgb(160, 240, 180)
|
||||
} else {
|
||||
rgb(120, 220, 140)
|
||||
}
|
||||
}
|
||||
WorkspaceSessionVisualState::Detached => {
|
||||
if focused {
|
||||
rgb(200, 200, 215)
|
||||
} else {
|
||||
rgb(170, 170, 190)
|
||||
}
|
||||
}
|
||||
WorkspaceSessionVisualState::Idle => {
|
||||
if focused {
|
||||
rgb(220, 220, 240)
|
||||
} else if current_workspace {
|
||||
rgb(150, 150, 165)
|
||||
} else {
|
||||
rgb(95, 95, 110)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{compute_workspace_tile_placements, render_workspace_map};
|
||||
use crate::workspace_map::{
|
||||
VisibleWorkspaceRow, WorkspaceSessionTile, WorkspaceSessionVisualState,
|
||||
};
|
||||
use ratatui::{buffer::Buffer, layout::Rect};
|
||||
|
||||
fn row(
|
||||
workspace: i32,
|
||||
is_current: bool,
|
||||
focused_index: Option<usize>,
|
||||
sessions: Vec<WorkspaceSessionTile>,
|
||||
) -> VisibleWorkspaceRow {
|
||||
VisibleWorkspaceRow {
|
||||
workspace,
|
||||
is_current,
|
||||
focused_index,
|
||||
sessions,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placements_center_rows_and_preserve_order() {
|
||||
let rows = vec![row(
|
||||
0,
|
||||
true,
|
||||
Some(1),
|
||||
vec![
|
||||
WorkspaceSessionTile::new("fox"),
|
||||
WorkspaceSessionTile::new("bear"),
|
||||
WorkspaceSessionTile::new("owl"),
|
||||
],
|
||||
)];
|
||||
let placements = compute_workspace_tile_placements(Rect::new(0, 0, 40, 8), &rows);
|
||||
assert_eq!(placements.len(), 3);
|
||||
assert!(placements[0].rect.x < placements[1].rect.x);
|
||||
assert!(placements[1].rect.x < placements[2].rect.x);
|
||||
assert!(placements[1].focused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_workspace_map_uses_square_for_focused_tile() {
|
||||
let rows = vec![row(
|
||||
0,
|
||||
true,
|
||||
Some(0),
|
||||
vec![WorkspaceSessionTile::new("fox")],
|
||||
)];
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 6));
|
||||
render_workspace_map(&mut buf, Rect::new(0, 0, 20, 6), &rows, 0);
|
||||
|
||||
let symbols: String = buf
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
assert!(symbols.contains("■"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_workspace_map_colors_completed_tiles_green() {
|
||||
let rows = vec![row(
|
||||
0,
|
||||
true,
|
||||
Some(0),
|
||||
vec![WorkspaceSessionTile::with_state(
|
||||
"fox",
|
||||
WorkspaceSessionVisualState::Completed,
|
||||
)],
|
||||
)];
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 6));
|
||||
render_workspace_map(&mut buf, Rect::new(0, 0, 20, 6), &rows, 0);
|
||||
|
||||
let has_greenish_fg = buf.content().iter().any(|cell| {
|
||||
matches!(cell.style().fg, Some(ratatui::style::Color::Rgb(r, g, b)) if g > r && g > b)
|
||||
});
|
||||
assert!(has_greenish_fg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_tile_uses_spinner_frames() {
|
||||
let rows = vec![row(
|
||||
0,
|
||||
true,
|
||||
Some(0),
|
||||
vec![WorkspaceSessionTile::with_state(
|
||||
"fox",
|
||||
WorkspaceSessionVisualState::Running,
|
||||
)],
|
||||
)];
|
||||
let mut buf_a = Buffer::empty(Rect::new(0, 0, 20, 6));
|
||||
render_workspace_map(&mut buf_a, Rect::new(0, 0, 20, 6), &rows, 0);
|
||||
let mut buf_b = Buffer::empty(Rect::new(0, 0, 20, 6));
|
||||
render_workspace_map(&mut buf_b, Rect::new(0, 0, 20, 6), &rows, 1);
|
||||
|
||||
let symbols_a: String = buf_a
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let symbols_b: String = buf_b
|
||||
.content()
|
||||
.iter()
|
||||
.map(|cell| cell.symbol())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
assert_ne!(symbols_a, symbols_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placements_clip_when_area_is_narrower_than_full_row() {
|
||||
let rows = vec![row(
|
||||
0,
|
||||
true,
|
||||
Some(0),
|
||||
vec![
|
||||
WorkspaceSessionTile::new("fox"),
|
||||
WorkspaceSessionTile::new("bear"),
|
||||
WorkspaceSessionTile::new("owl"),
|
||||
],
|
||||
)];
|
||||
let area = Rect::new(0, 0, 12, 6);
|
||||
let placements = compute_workspace_tile_placements(area, &rows);
|
||||
assert!(!placements.is_empty());
|
||||
let right = area.x + area.width;
|
||||
assert!(placements.iter().all(|placement| placement.rect.x < right));
|
||||
assert!(
|
||||
placements
|
||||
.iter()
|
||||
.all(|placement| placement.rect.x + placement.rect.width <= right)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user