| 1 | //! Codewhale's terminal glyph charter. |
| 2 | //! |
| 3 | //! Renderers use semantic names from this module instead of choosing visual |
| 4 | //! punctuation ad hoc. The solid current marker (`●`) is the recurring |
| 5 | //! Codewhale anchor: it marks the active speaker or current human choice. |
| 6 | //! ASCII-safe terminals receive the semantic fallback from the same owner. |
| 7 | //! |
| 8 | //! Extracted from the Codewhale engine's `crates/tui/src/tui/glyphs.rs` |
| 9 | //! (`Hmbown/CodeWhale` `58b1dd3dd`, last changed in `d7712814e`). One meaning |
| 10 | //! per glyph: `●` current or running, `✓` done, `✕` failed, `◆` needs you, |
| 11 | //! `○` ready, `■` stopped, `▸` selection, `?` unknown. |
| 12 | |
| 13 | /// Current speaker or current human choice — the recurring identity anchor. |
| 14 | pub const CURRENT: &str = "●"; |
| 15 | /// Available but not current. |
| 16 | pub const AVAILABLE: &str = "○"; |
| 17 | /// Keyboard/list selection pointer. |
| 18 | pub const SELECTION: &str = "▸"; |
| 19 | /// Finished user-authored message marker. |
| 20 | pub const USER: &str = "▎"; |
| 21 | /// Transcript continuation rail, including its authored trailing space. |
| 22 | pub const TRANSCRIPT_RAIL: &str = "▏ "; |
| 23 | /// Settled successful state. |
| 24 | pub const DONE: &str = "✓"; |
| 25 | /// Settled failed state. |
| 26 | pub const FAILED: &str = "✕"; |
| 27 | /// State that needs human attention. |
| 28 | pub const ATTENTION: &str = "◆"; |
| 29 | /// Ready but not active. |
| 30 | pub const READY: &str = "○"; |
| 31 | /// Paused work. |
| 32 | pub const PAUSED: &str = "⏸"; |
| 33 | /// Stopped by a person, or cancelled. |
| 34 | pub const STOPPED: &str = "■"; |
| 35 | /// A state nobody reported. Never shown as a failure. |
| 36 | pub const UNKNOWN: &str = "?"; |
| 37 | /// The one ellipsis. |
| 38 | pub const ELLIPSIS: &str = "…"; |
| 39 | /// Fleet role marks share the same charter while retaining distinct shapes. |
| 40 | pub const ROLE_MANAGER: &str = "◆"; |
| 41 | pub const ROLE_BUILDER: &str = "■"; |
| 42 | pub const ROLE_REVIEWER: &str = "◇"; |
| 43 | pub const ROLE_VERIFIER: &str = CURRENT; |
| 44 | pub const ROLE_SYNTHESIZER: &str = "▲"; |
| 45 | pub const NEUTRAL: &str = "·"; |
| 46 | |
| 47 | #[must_use] |
| 48 | pub const fn selection_marker(selected: bool) -> &'static str { |
| 49 | if selected { SELECTION } else { " " } |
| 50 | } |
| 51 | |
| 52 | /// `symbol`, or its ASCII fallback when `ascii` is set. Symbols without a |
| 53 | /// fallback are returned unchanged. |
| 54 | #[must_use] |
| 55 | pub fn pick(symbol: &'static str, ascii: bool) -> &'static str { |
| 56 | if ascii { |
| 57 | ascii_fallback(symbol).unwrap_or(symbol) |
| 58 | } else { |
| 59 | symbol |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | /// Reduce a single Codewhale-authored decorative glyph to narrow ASCII. |
| 64 | /// Language text and model/user content are intentionally outside this map. |
| 65 | #[must_use] |
| 66 | pub fn ascii_fallback(symbol: &str) -> Option<&'static str> { |
| 67 | match symbol { |
| 68 | "─" | "━" | "═" | "╌" | "╍" | "┄" | "┅" | "┈" | "┉" | "—" | "–" => { |
| 69 | Some("-") |
| 70 | } |
| 71 | "│" | "┃" | "║" | "╎" | "╏" | "▏" | "▎" | "▍" | "▌" | "▐" | "▕" => { |
| 72 | Some("|") |
| 73 | } |
| 74 | "┌" | "┐" | "└" | "┘" | "╭" | "╮" | "╰" | "╯" | "├" | "┤" | "┬" | "┴" | "┼" => { |
| 75 | Some("+") |
| 76 | } |
| 77 | "█" | "▉" | "▊" | "▋" | "▀" | "▄" | "▅" | "▆" | "▇" | "▙" | "▛" | "▜" | "▟" | "▰" => { |
| 78 | Some("#") |
| 79 | } |
| 80 | "▁" | "▂" | "▃" => Some("_"), |
| 81 | // Tideline action glyphs (spec §2): one cell each, no wide glyphs. |
| 82 | "⌁" => Some("+"), |
| 83 | "⚙" => Some("*"), |
| 84 | "↺" => Some("<"), |
| 85 | "▤" => Some("="), |
| 86 | "◐" => Some("*"), |
| 87 | "⑂" => Some("y"), |
| 88 | "∼" | "∿" => Some("~"), |
| 89 | "⋯" => Some("."), |
| 90 | "▖" | "▗" | "▘" | "▝" => Some("."), |
| 91 | "▚" => Some("\\"), |
| 92 | "▞" => Some("/"), |
| 93 | "░" | "▒" | "▓" => Some(":"), |
| 94 | "▱" => Some("-"), |
| 95 | "▶" | "▷" | "▸" | "›" | "❯" | "→" | "↗" | "↘" | "»" => Some(">"), |
| 96 | "◀" | "◂" | "‹" | "❮" | "←" | "↖" | "↙" | "«" => Some("<"), |
| 97 | "▼" | "▾" | "▽" | "↓" => Some("v"), |
| 98 | "▲" | "△" | "↑" => Some("^"), |
| 99 | "◆" | "◇" | "♦" | "✦" | "◍" | "◉" | "★" | "☆" => Some("*"), |
| 100 | "■" | "□" | "▪" | "▫" | "◼" | "◻" => Some("#"), |
| 101 | // Filled marks stay a dot; hollow ones become `o` so CURRENT and |
| 102 | // AVAILABLE stay distinguishable on ASCII terminals. |
| 103 | "●" | "∘" | "•" | "·" => Some("."), |
| 104 | "○" | "☐" | "◌" | "˚" | "°" | "◦" => Some("o"), |
| 105 | "✓" | "✔" | "☑" => Some("Y"), |
| 106 | "✕" | "×" | "⊘" | "✗" | "✘" | "☒" => Some("X"), |
| 107 | "⏸" => Some("="), |
| 108 | // Schedule/timer (the activity band's automation slot) — cron's `@`. |
| 109 | "⏱" => Some("@"), |
| 110 | // The launch warning line's gate glyph ("no model connected"). |
| 111 | "⚠" => Some("!"), |
| 112 | // The working screen's MCP chip marker (`⋮ MCP n/m`). |
| 113 | "⋮" => Some("|"), |
| 114 | "≈≈>" => Some("~>"), |
| 115 | "≈" | "~" => Some("~"), |
| 116 | "🐳" | "🐋" => Some("w"), |
| 117 | "…" => Some("."), |
| 118 | "▏ " => Some("| "), |
| 119 | _ => None, |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | /// Preserve the working-bubble fill signal when Braille is unavailable. |
| 124 | #[must_use] |
| 125 | pub fn braille_ascii_fallback(ch: char) -> Option<&'static str> { |
| 126 | if !(('\u{2800}'..='\u{28FF}').contains(&ch)) { |
| 127 | return None; |
| 128 | } |
| 129 | let dots = ((ch as u32) - 0x2800).count_ones(); |
| 130 | Some(match dots { |
| 131 | 0 => " ", |
| 132 | 1..=2 => ".", |
| 133 | 3..=4 => ":", |
| 134 | 5..=6 => "+", |
| 135 | _ => "#", |
| 136 | }) |
| 137 | } |
| 138 | |
| 139 | #[cfg(test)] |
| 140 | mod tests { |
| 141 | use super::*; |
| 142 | |
| 143 | #[test] |
| 144 | fn charter_has_narrow_semantic_fallbacks() { |
| 145 | for (rich, safe) in [ |
| 146 | (SELECTION, ">"), |
| 147 | ("▷", ">"), |
| 148 | (CURRENT, "."), |
| 149 | (AVAILABLE, "o"), |
| 150 | (READY, "o"), |
| 151 | ("☐", "o"), |
| 152 | ("•", "."), |
| 153 | (NEUTRAL, "."), |
| 154 | (USER, "|"), |
| 155 | (DONE, "Y"), |
| 156 | (FAILED, "X"), |
| 157 | (ATTENTION, "*"), |
| 158 | ("≈≈>", "~>"), |
| 159 | ("≈", "~"), |
| 160 | ("~", "~"), |
| 161 | ("⌁", "+"), |
| 162 | ("↺", "<"), |
| 163 | ("▤", "="), |
| 164 | ("◐", "*"), |
| 165 | ("⑂", "y"), |
| 166 | ("∼", "~"), |
| 167 | ("∿", "~"), |
| 168 | ("⋯", "."), |
| 169 | ("⏱", "@"), |
| 170 | ("🐳", "w"), |
| 171 | ("🐋", "w"), |
| 172 | (STOPPED, "#"), |
| 173 | (ELLIPSIS, "."), |
| 174 | ] { |
| 175 | assert_eq!(ascii_fallback(rich), Some(safe)); |
| 176 | } |
| 177 | assert_ne!( |
| 178 | ascii_fallback(CURRENT), |
| 179 | ascii_fallback(AVAILABLE), |
| 180 | "current and available must stay distinct in ASCII" |
| 181 | ); |
| 182 | assert_eq!(braille_ascii_fallback('\u{2801}'), Some(".")); |
| 183 | assert_eq!(braille_ascii_fallback('A'), None); |
| 184 | } |
| 185 | |
| 186 | #[test] |
| 187 | fn every_public_mark_has_an_ascii_form() { |
| 188 | for mark in [ |
| 189 | CURRENT, |
| 190 | AVAILABLE, |
| 191 | SELECTION, |
| 192 | USER, |
| 193 | TRANSCRIPT_RAIL, |
| 194 | DONE, |
| 195 | FAILED, |
| 196 | ATTENTION, |
| 197 | READY, |
| 198 | PAUSED, |
| 199 | STOPPED, |
| 200 | UNKNOWN, |
| 201 | ELLIPSIS, |
| 202 | ROLE_MANAGER, |
| 203 | ROLE_BUILDER, |
| 204 | ROLE_REVIEWER, |
| 205 | ROLE_VERIFIER, |
| 206 | ROLE_SYNTHESIZER, |
| 207 | NEUTRAL, |
| 208 | selection_marker(true), |
| 209 | selection_marker(false), |
| 210 | ] { |
| 211 | let safe = pick(mark, true); |
| 212 | assert!(safe.is_ascii(), "{mark:?} -> {safe:?}"); |
| 213 | assert_eq!(safe.len(), mark.chars().count(), "{mark:?} keeps its width"); |
| 214 | } |
| 215 | } |
| 216 | } |
| 217 |