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

This commit is contained in:
wehub-resource-sync
2026-07-13 13:10:34 +08:00
commit a789495a98
1551 changed files with 718128 additions and 0 deletions
+7
View File
@@ -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()),
);
}
}
+3
View File
@@ -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)
);
}
}