| 1 | //! The two rows beneath Codewhale's composer, extracted from the current |
| 2 | //! TUI's `phase_strip.rs::TidelineFooter` and `infoline.rs::InfoLine`. |
| 3 | //! |
| 4 | //! The posture bar says permission, mode and current activity. The metrics |
| 5 | //! line says model, context and measured numbers. Both take caller facts; |
| 6 | //! neither discovers routes, counts work, starts a clock or dispatches keys. |
| 7 | |
| 8 | use std::borrow::Cow; |
| 9 | |
| 10 | use ratatui::{ |
| 11 | buffer::Buffer, |
| 12 | layout::{Margin, Rect}, |
| 13 | style::{Modifier, Style}, |
| 14 | }; |
| 15 | use unicode_segmentation::UnicodeSegmentation; |
| 16 | |
| 17 | use crate::{Paint, Role, Theme, TuiInk, glyphs, text}; |
| 18 | |
| 19 | const ITEM_JOIN: &str = " "; |
| 20 | const COUNT_JOIN: &str = ", "; |
| 21 | const HELP_GAP: usize = 2; |
| 22 | const SHED_TURN_CLOCK: u8 = 1; |
| 23 | const SHED_SESSION_CLOCK: u8 = 2; |
| 24 | const SHED_PERMISSION_KEY: u8 = 3; |
| 25 | const SHED_HINT: u8 = 4; |
| 26 | const SHED_COUNTS: u8 = 5; |
| 27 | const SHED_CAP_WARNING: u8 = 6; |
| 28 | const SHED_MODE_KEY: u8 = 7; |
| 29 | const SHED_MODE: u8 = 8; |
| 30 | const MAX_SHED: u8 = SHED_MODE; |
| 31 | const COMPACT_SHED: u8 = SHED_COUNTS; |
| 32 | |
| 33 | /// One caller-authored posture word and its semantic ink. |
| 34 | #[derive(Clone, Debug)] |
| 35 | pub struct PostureFact<'a> { |
| 36 | pub text: Cow<'a, str>, |
| 37 | pub role: Role, |
| 38 | pub ink: Option<TuiInk>, |
| 39 | } |
| 40 | |
| 41 | impl<'a> PostureFact<'a> { |
| 42 | #[must_use] |
| 43 | pub fn new(value: impl Into<Cow<'a, str>>, role: Role) -> Self { |
| 44 | Self { |
| 45 | text: value.into(), |
| 46 | role, |
| 47 | ink: None, |
| 48 | } |
| 49 | } |
| 50 | /// Choose an exact native permission, mode or status ink. |
| 51 | #[must_use] |
| 52 | pub fn ink(mut self, ink: TuiInk) -> Self { |
| 53 | self.ink = Some(ink); |
| 54 | self |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | /// Codewhale's one-row posture bar, immediately below the composer. |
| 59 | /// |
| 60 | /// Permission is the floor. Optional clocks, keys, hints and counts shed |
| 61 | /// before mode; the right notice never covers permission. At 80% context, |
| 62 | /// the native `surface soon — /compact` warning replaces the ordinary hint. |
| 63 | #[derive(Clone, Debug)] |
| 64 | pub struct PostureBar<'a> { |
| 65 | pub permission: PostureFact<'a>, |
| 66 | pub permission_key: Option<Cow<'a, str>>, |
| 67 | pub mode: Option<PostureFact<'a>>, |
| 68 | pub mode_key: Option<Cow<'a, str>>, |
| 69 | pub turn_clock: Option<PostureFact<'a>>, |
| 70 | pub counts: Vec<PostureFact<'a>>, |
| 71 | pub session_clock: Option<PostureFact<'a>>, |
| 72 | pub hint: Option<PostureFact<'a>>, |
| 73 | pub context_percent: u8, |
| 74 | pub cap_warning: Cow<'a, str>, |
| 75 | pub right: Option<PostureFact<'a>>, |
| 76 | pub compact: bool, |
| 77 | } |
| 78 | |
| 79 | impl<'a> PostureBar<'a> { |
| 80 | #[must_use] |
| 81 | pub fn new(permission: impl Into<Cow<'a, str>>) -> Self { |
| 82 | Self { |
| 83 | permission: PostureFact::new(permission, Role::Attention).ink(TuiInk::PermissionAsk), |
| 84 | permission_key: None, |
| 85 | mode: None, |
| 86 | mode_key: None, |
| 87 | turn_clock: None, |
| 88 | counts: Vec::new(), |
| 89 | session_clock: None, |
| 90 | hint: None, |
| 91 | context_percent: 0, |
| 92 | cap_warning: Cow::Borrowed("surface soon — /compact"), |
| 93 | right: None, |
| 94 | compact: false, |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | #[must_use] |
| 99 | pub fn permission_role(mut self, role: Role) -> Self { |
| 100 | self.permission.role = role; |
| 101 | self.permission.ink = None; |
| 102 | self |
| 103 | } |
| 104 | |
| 105 | #[must_use] |
| 106 | pub fn permission_ink(mut self, ink: TuiInk) -> Self { |
| 107 | self.permission.ink = Some(ink); |
| 108 | self |
| 109 | } |
| 110 | |
| 111 | #[must_use] |
| 112 | pub fn mode_ink(mut self, value: impl Into<Cow<'a, str>>, ink: TuiInk) -> Self { |
| 113 | self.mode = Some(PostureFact::new(value, ink.fallback_role()).ink(ink)); |
| 114 | self |
| 115 | } |
| 116 | |
| 117 | #[must_use] |
| 118 | pub fn permission_key(mut self, value: impl Into<Cow<'a, str>>) -> Self { |
| 119 | self.permission_key = Some(value.into()); |
| 120 | self |
| 121 | } |
| 122 | |
| 123 | #[must_use] |
| 124 | pub fn mode(mut self, value: impl Into<Cow<'a, str>>, role: Role) -> Self { |
| 125 | self.mode = Some(PostureFact::new(value, role)); |
| 126 | self |
| 127 | } |
| 128 | |
| 129 | #[must_use] |
| 130 | pub fn mode_key(mut self, value: impl Into<Cow<'a, str>>) -> Self { |
| 131 | self.mode_key = Some(value.into()); |
| 132 | self |
| 133 | } |
| 134 | |
| 135 | #[must_use] |
| 136 | pub fn turn_clock(mut self, value: impl Into<Cow<'a, str>>, role: Role) -> Self { |
| 137 | self.turn_clock = Some(PostureFact::new(value, role)); |
| 138 | self |
| 139 | } |
| 140 | |
| 141 | #[must_use] |
| 142 | pub fn session_clock(mut self, value: impl Into<Cow<'a, str>>, role: Role) -> Self { |
| 143 | self.session_clock = Some(PostureFact::new(value, role)); |
| 144 | self |
| 145 | } |
| 146 | |
| 147 | #[must_use] |
| 148 | pub fn counts(mut self, values: Vec<PostureFact<'a>>) -> Self { |
| 149 | self.counts = values; |
| 150 | self |
| 151 | } |
| 152 | |
| 153 | #[must_use] |
| 154 | pub fn hint(mut self, value: impl Into<Cow<'a, str>>, role: Role) -> Self { |
| 155 | self.hint = Some(PostureFact::new(value, role)); |
| 156 | self |
| 157 | } |
| 158 | |
| 159 | #[must_use] |
| 160 | pub fn right(mut self, value: impl Into<Cow<'a, str>>, role: Role) -> Self { |
| 161 | self.right = Some(PostureFact::new(value, role)); |
| 162 | self |
| 163 | } |
| 164 | |
| 165 | #[must_use] |
| 166 | pub fn context_percent(mut self, percent: u8) -> Self { |
| 167 | self.context_percent = percent.min(100); |
| 168 | self |
| 169 | } |
| 170 | |
| 171 | /// Localize the native cap warning without changing its priority. |
| 172 | #[must_use] |
| 173 | pub fn cap_warning(mut self, value: impl Into<Cow<'a, str>>) -> Self { |
| 174 | self.cap_warning = value.into(); |
| 175 | self |
| 176 | } |
| 177 | |
| 178 | #[must_use] |
| 179 | pub fn compact(mut self, compact: bool) -> Self { |
| 180 | self.compact = compact; |
| 181 | self |
| 182 | } |
| 183 | |
| 184 | fn at_context_cap(&self) -> bool { |
| 185 | self.context_percent >= 80 |
| 186 | } |
| 187 | |
| 188 | fn items(&self, shed: u8, ascii: bool) -> Vec<PostureItem> { |
| 189 | let mut items = vec![PostureItem { |
| 190 | text: format!( |
| 191 | "{} {}", |
| 192 | project(glyphs::CURRENT, ascii), |
| 193 | project(&self.permission.text, ascii) |
| 194 | ), |
| 195 | key: self |
| 196 | .permission_key |
| 197 | .as_deref() |
| 198 | .filter(|_| shed < SHED_PERMISSION_KEY) |
| 199 | .map(|key| format!(" {}", project(key, ascii))), |
| 200 | role: self.permission.role, |
| 201 | ink: self.permission.ink, |
| 202 | bold: true, |
| 203 | joined: false, |
| 204 | count_index: None, |
| 205 | }]; |
| 206 | if let Some(mode) = self.mode.as_ref().filter(|_| shed < SHED_MODE) { |
| 207 | items.push(PostureItem { |
| 208 | text: project(&mode.text, ascii), |
| 209 | key: self |
| 210 | .mode_key |
| 211 | .as_deref() |
| 212 | .filter(|_| shed < SHED_MODE_KEY) |
| 213 | .map(|key| format!(" ({})", project(key, ascii))), |
| 214 | role: mode.role, |
| 215 | ink: mode.ink, |
| 216 | bold: false, |
| 217 | joined: false, |
| 218 | count_index: None, |
| 219 | }); |
| 220 | } |
| 221 | // Preserve the native turn-only clock rung: removing the session |
| 222 | // half must not make the remaining clock shed a rung too early. |
| 223 | let turn_shed = if self.session_clock.is_none() { |
| 224 | SHED_SESSION_CLOCK |
| 225 | } else { |
| 226 | SHED_TURN_CLOCK |
| 227 | }; |
| 228 | if let Some(clock) = self.turn_clock.as_ref().filter(|_| shed < turn_shed) { |
| 229 | items.push(PostureItem::fact(clock, ascii)); |
| 230 | } |
| 231 | if shed < SHED_COUNTS { |
| 232 | for (index, count) in self.counts.iter().enumerate() { |
| 233 | let count_text = project(&count.text, ascii); |
| 234 | let (value, key) = match count_text.strip_suffix(" (Ctrl+])") { |
| 235 | Some(label) => (label.to_owned(), Some(" (Ctrl+])".to_owned())), |
| 236 | None => (count_text, None), |
| 237 | }; |
| 238 | items.push(PostureItem { |
| 239 | text: value, |
| 240 | key, |
| 241 | role: count.role, |
| 242 | ink: count.ink, |
| 243 | bold: false, |
| 244 | joined: index > 0, |
| 245 | count_index: Some(index), |
| 246 | }); |
| 247 | } |
| 248 | } |
| 249 | if let Some(clock) = self |
| 250 | .session_clock |
| 251 | .as_ref() |
| 252 | .filter(|_| shed < SHED_SESSION_CLOCK) |
| 253 | { |
| 254 | items.push(PostureItem::fact(clock, ascii)); |
| 255 | } |
| 256 | let hint_rung = if self.at_context_cap() { |
| 257 | SHED_CAP_WARNING |
| 258 | } else { |
| 259 | SHED_HINT |
| 260 | }; |
| 261 | if shed < hint_rung { |
| 262 | if self.at_context_cap() { |
| 263 | items.push(PostureItem::fact( |
| 264 | &PostureFact::new(format!("▲ {}", self.cap_warning), Role::Attention) |
| 265 | .ink(TuiInk::Warning), |
| 266 | ascii, |
| 267 | )); |
| 268 | } else if let Some(hint) = &self.hint { |
| 269 | items.push(PostureItem::fact(hint, ascii)); |
| 270 | } |
| 271 | } |
| 272 | items |
| 273 | } |
| 274 | |
| 275 | fn layout(&self, area: Rect, theme: &Theme) -> Option<PostureLayout> { |
| 276 | if area.width < 8 || area.height == 0 { |
| 277 | return None; |
| 278 | } |
| 279 | let area = area.inner(Margin::new(1, 0)); |
| 280 | let ascii = theme.ascii(); |
| 281 | let width = usize::from(area.width); |
| 282 | let floor = left_width(&self.items(MAX_SHED, ascii)); |
| 283 | let right = self.right.as_ref().map(|fact| { |
| 284 | ( |
| 285 | clip_native(&project(&fact.text, ascii), width.saturating_sub(floor + 1)), |
| 286 | fact.role, |
| 287 | ) |
| 288 | }); |
| 289 | let right_width = right |
| 290 | .as_ref() |
| 291 | .map_or(0, |(value, _)| text::width(value) + 1); |
| 292 | let budget = width.saturating_sub(right_width); |
| 293 | let first = if self.compact { COMPACT_SHED } else { 0 }; |
| 294 | let items = (first..=MAX_SHED) |
| 295 | .map(|shed| self.items(shed, ascii)) |
| 296 | .find(|items| left_width(items) <= budget) |
| 297 | .unwrap_or_else(|| self.items(MAX_SHED, ascii)); |
| 298 | Some(PostureLayout { |
| 299 | area, |
| 300 | budget, |
| 301 | items, |
| 302 | right, |
| 303 | }) |
| 304 | } |
| 305 | |
| 306 | /// Geometry of the live-count affordances in the painted row. |
| 307 | /// Pass the same area used for painting, intersected with the buffer. |
| 308 | #[must_use] |
| 309 | pub fn count_hitboxes(&self, area: Rect, theme: &Theme) -> Vec<(usize, Rect)> { |
| 310 | let Some(layout) = self.layout(area, theme) else { |
| 311 | return Vec::new(); |
| 312 | }; |
| 313 | layout.count_hitboxes(theme.ascii()) |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | struct PostureItem { |
| 318 | text: String, |
| 319 | key: Option<String>, |
| 320 | role: Role, |
| 321 | ink: Option<TuiInk>, |
| 322 | bold: bool, |
| 323 | joined: bool, |
| 324 | count_index: Option<usize>, |
| 325 | } |
| 326 | |
| 327 | impl PostureItem { |
| 328 | fn fact(fact: &PostureFact<'_>, ascii: bool) -> Self { |
| 329 | Self { |
| 330 | text: project(&fact.text, ascii), |
| 331 | key: None, |
| 332 | role: fact.role, |
| 333 | ink: fact.ink, |
| 334 | bold: false, |
| 335 | joined: false, |
| 336 | count_index: None, |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | fn width(&self) -> usize { |
| 341 | text::width(&self.text) + self.key.as_deref().map_or(0, text::width) |
| 342 | } |
| 343 | |
| 344 | fn separator(&self) -> &'static str { |
| 345 | if self.joined { COUNT_JOIN } else { ITEM_JOIN } |
| 346 | } |
| 347 | } |
| 348 | |
| 349 | fn left_width(items: &[PostureItem]) -> usize { |
| 350 | items.iter().map(PostureItem::width).sum::<usize>() |
| 351 | + items |
| 352 | .iter() |
| 353 | .skip(1) |
| 354 | .map(|item| text::width(item.separator())) |
| 355 | .sum::<usize>() |
| 356 | } |
| 357 | |
| 358 | struct PostureLayout { |
| 359 | area: Rect, |
| 360 | budget: usize, |
| 361 | items: Vec<PostureItem>, |
| 362 | right: Option<(String, Role)>, |
| 363 | } |
| 364 | |
| 365 | impl PostureLayout { |
| 366 | fn clip(&self, x: usize, value: &str) -> String { |
| 367 | clip_native( |
| 368 | value, |
| 369 | (usize::from(self.area.x) + self.budget).saturating_sub(x), |
| 370 | ) |
| 371 | } |
| 372 | |
| 373 | fn count_hitboxes(&self, ascii: bool) -> Vec<(usize, Rect)> { |
| 374 | let mut out = Vec::new(); |
| 375 | let mut x = usize::from(self.area.x); |
| 376 | for (index, item) in self.items.iter().enumerate() { |
| 377 | if index > 0 { |
| 378 | x += text::width(&project(item.separator(), ascii)); |
| 379 | } |
| 380 | let value = self.clip(x, &item.text); |
| 381 | let key = item |
| 382 | .key |
| 383 | .as_deref() |
| 384 | .map(|key| self.clip(x + text::width(&item.text), key)) |
| 385 | .unwrap_or_default(); |
| 386 | if let Some(index) = item.count_index |
| 387 | && !value.is_empty() |
| 388 | { |
| 389 | out.push(( |
| 390 | index, |
| 391 | Rect::new( |
| 392 | x as u16, |
| 393 | self.area.y, |
| 394 | (text::width(&value) + text::width(&key)) as u16, |
| 395 | 1, |
| 396 | ), |
| 397 | )); |
| 398 | } |
| 399 | x += item.width(); |
| 400 | } |
| 401 | out |
| 402 | } |
| 403 | } |
| 404 | |
| 405 | impl Paint for PostureBar<'_> { |
| 406 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 407 | let Some(layout) = self.layout(area.intersection(buf.area), theme) else { |
| 408 | return; |
| 409 | }; |
| 410 | let mut x = usize::from(layout.area.x); |
| 411 | for (index, item) in layout.items.iter().enumerate() { |
| 412 | if index > 0 { |
| 413 | let separator = project(item.separator(), theme.ascii()); |
| 414 | put( |
| 415 | layout.area, |
| 416 | buf, |
| 417 | x, |
| 418 | &layout.clip(x, &separator), |
| 419 | theme.fg(Role::Dim), |
| 420 | ); |
| 421 | x += text::width(&separator); |
| 422 | } |
| 423 | let base = item |
| 424 | .ink |
| 425 | .map_or_else(|| theme.fg(item.role), |ink| theme.tui_ink(ink)); |
| 426 | let style = if item.bold { |
| 427 | base.add_modifier(Modifier::BOLD) |
| 428 | } else { |
| 429 | base |
| 430 | }; |
| 431 | put(layout.area, buf, x, &layout.clip(x, &item.text), style); |
| 432 | if let Some(key) = &item.key { |
| 433 | let key_x = x + text::width(&item.text); |
| 434 | put( |
| 435 | layout.area, |
| 436 | buf, |
| 437 | key_x, |
| 438 | &layout.clip(key_x, key), |
| 439 | theme.fg(Role::Hint), |
| 440 | ); |
| 441 | } |
| 442 | x += item.width(); |
| 443 | } |
| 444 | if let Some((value, role)) = &layout.right |
| 445 | && !value.is_empty() |
| 446 | { |
| 447 | let x = usize::from(layout.area.right()).saturating_sub(text::width(value)); |
| 448 | put(layout.area, buf, x, value, theme.fg(*role)); |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | fn height(&self, width: u16, _theme: &Theme) -> u16 { |
| 453 | u16::from(width > 0) |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | /// Identity and native shedding priority of a metrics-line segment. |
| 458 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 459 | pub enum MetricKind { |
| 460 | Model, |
| 461 | Context, |
| 462 | Cost, |
| 463 | BillingTier, |
| 464 | OutputTokens, |
| 465 | Ttft, |
| 466 | Rate, |
| 467 | Cache, |
| 468 | Balance, |
| 469 | Goal, |
| 470 | Workspace, |
| 471 | GitBranch, |
| 472 | } |
| 473 | |
| 474 | impl MetricKind { |
| 475 | const SHED_BEFORE_HELP: u8 = 7; |
| 476 | |
| 477 | /// Higher priorities shed first; model and context never shed. |
| 478 | #[must_use] |
| 479 | pub const fn shed_priority(self) -> u8 { |
| 480 | match self { |
| 481 | Self::Cache => 6, |
| 482 | Self::Rate | Self::Ttft | Self::Cost => 5, |
| 483 | Self::OutputTokens | Self::BillingTier => 7, |
| 484 | Self::Balance | Self::Workspace | Self::GitBranch => 4, |
| 485 | Self::Goal => 3, |
| 486 | Self::Model | Self::Context => 0, |
| 487 | } |
| 488 | } |
| 489 | } |
| 490 | |
| 491 | /// A reported number or route name. Empty labels paint just the value. |
| 492 | #[derive(Clone, Debug)] |
| 493 | pub struct MetricSegment<'a> { |
| 494 | pub kind: MetricKind, |
| 495 | pub label: Cow<'a, str>, |
| 496 | pub value: Cow<'a, str>, |
| 497 | pub role: Role, |
| 498 | } |
| 499 | |
| 500 | impl<'a> MetricSegment<'a> { |
| 501 | #[must_use] |
| 502 | pub fn new( |
| 503 | kind: MetricKind, |
| 504 | label: impl Into<Cow<'a, str>>, |
| 505 | value: impl Into<Cow<'a, str>>, |
| 506 | ) -> Self { |
| 507 | Self { |
| 508 | kind, |
| 509 | label: label.into(), |
| 510 | value: value.into(), |
| 511 | role: Role::Muted, |
| 512 | } |
| 513 | } |
| 514 | |
| 515 | #[must_use] |
| 516 | pub fn role(mut self, role: Role) -> Self { |
| 517 | self.role = role; |
| 518 | self |
| 519 | } |
| 520 | } |
| 521 | |
| 522 | /// A segment's exact visible cells, including the space between label and value. |
| 523 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 524 | pub struct MetricHitbox { |
| 525 | pub kind: MetricKind, |
| 526 | pub area: Rect, |
| 527 | } |
| 528 | |
| 529 | /// Codewhale's one-row model, context and session metrics line. |
| 530 | /// |
| 531 | /// Missing measurements are omitted by leaving out their segments. The |
| 532 | /// native shed ladder preserves model and context, removes secondary counts |
| 533 | /// before help, and clips at the right edge only below the identity floor. |
| 534 | #[derive(Clone, Debug)] |
| 535 | pub struct MetricsLine<'a> { |
| 536 | pub segments: Vec<MetricSegment<'a>>, |
| 537 | pub help_hint: Cow<'a, str>, |
| 538 | pub hovered: Option<MetricKind>, |
| 539 | pub compact: bool, |
| 540 | } |
| 541 | |
| 542 | impl<'a> MetricsLine<'a> { |
| 543 | #[must_use] |
| 544 | pub fn new(segments: Vec<MetricSegment<'a>>) -> Self { |
| 545 | Self { |
| 546 | segments, |
| 547 | help_hint: Cow::Borrowed(""), |
| 548 | hovered: None, |
| 549 | compact: false, |
| 550 | } |
| 551 | } |
| 552 | |
| 553 | #[must_use] |
| 554 | pub fn help_hint(mut self, value: impl Into<Cow<'a, str>>) -> Self { |
| 555 | self.help_hint = value.into(); |
| 556 | self |
| 557 | } |
| 558 | |
| 559 | #[must_use] |
| 560 | pub fn hovered(mut self, kind: MetricKind) -> Self { |
| 561 | self.hovered = Some(kind); |
| 562 | self |
| 563 | } |
| 564 | |
| 565 | #[must_use] |
| 566 | pub fn compact(mut self, compact: bool) -> Self { |
| 567 | self.compact = compact; |
| 568 | self |
| 569 | } |
| 570 | |
| 571 | fn layout(&self, area: Rect, theme: &Theme) -> MetricLayout { |
| 572 | let ascii = theme.ascii(); |
| 573 | let help = project(&self.help_hint, ascii); |
| 574 | let mut kept: Vec<PreparedMetric> = self |
| 575 | .segments |
| 576 | .iter() |
| 577 | .filter(|segment| { |
| 578 | !self.compact || segment.kind.shed_priority() < MetricKind::SHED_BEFORE_HELP |
| 579 | }) |
| 580 | .map(|segment| PreparedMetric { |
| 581 | kind: segment.kind, |
| 582 | label: project(&segment.label, ascii), |
| 583 | value: project(&segment.value, ascii), |
| 584 | role: segment.role, |
| 585 | }) |
| 586 | .collect(); |
| 587 | let left_width = |segments: &[PreparedMetric]| -> usize { |
| 588 | segments.iter().map(PreparedMetric::width).sum::<usize>() |
| 589 | + text::width(ITEM_JOIN) * segments.len().saturating_sub(1) |
| 590 | }; |
| 591 | let needed = |left: usize, show_help: bool| -> usize { |
| 592 | left + if show_help && !help.is_empty() { |
| 593 | HELP_GAP + text::width(&help) |
| 594 | } else { |
| 595 | 0 |
| 596 | } |
| 597 | }; |
| 598 | let sheddable = |segments: &[PreparedMetric], min_priority: u8| -> Option<usize> { |
| 599 | segments |
| 600 | .iter() |
| 601 | .enumerate() |
| 602 | .filter(|(_, segment)| segment.kind.shed_priority() >= min_priority.max(1)) |
| 603 | .max_by_key(|(_, segment)| segment.kind.shed_priority()) |
| 604 | .map(|(index, _)| index) |
| 605 | }; |
| 606 | let mut show_help = !help.is_empty() && !self.compact; |
| 607 | while needed(left_width(&kept), show_help) > usize::from(area.width) { |
| 608 | if let Some(index) = sheddable(&kept, MetricKind::SHED_BEFORE_HELP) { |
| 609 | kept.remove(index); |
| 610 | } else if show_help { |
| 611 | show_help = false; |
| 612 | } else if let Some(index) = sheddable(&kept, 1) { |
| 613 | kept.remove(index); |
| 614 | } else { |
| 615 | break; |
| 616 | } |
| 617 | } |
| 618 | MetricLayout { |
| 619 | kept, |
| 620 | help: show_help.then_some(help), |
| 621 | } |
| 622 | } |
| 623 | |
| 624 | /// Geometry for all surviving readings. Only model and context acquire |
| 625 | /// hover styling; hosts choose which readings dispatch an action. |
| 626 | /// Use the same clipped area as painting when the buffer is smaller. |
| 627 | #[must_use] |
| 628 | pub fn hitboxes(&self, area: Rect, theme: &Theme) -> Vec<MetricHitbox> { |
| 629 | if area.is_empty() { |
| 630 | return Vec::new(); |
| 631 | } |
| 632 | let layout = self.layout(area, theme); |
| 633 | let mut x = usize::from(area.x); |
| 634 | let mut out = Vec::new(); |
| 635 | let right = usize::from(area.right()); |
| 636 | for (index, segment) in layout.kept.iter().enumerate() { |
| 637 | if index > 0 { |
| 638 | x += text::width(ITEM_JOIN); |
| 639 | } |
| 640 | let end = (x + segment.width()).min(right); |
| 641 | if x < end { |
| 642 | out.push(MetricHitbox { |
| 643 | kind: segment.kind, |
| 644 | area: Rect::new(x as u16, area.y, (end - x) as u16, 1), |
| 645 | }); |
| 646 | } |
| 647 | x += segment.width(); |
| 648 | } |
| 649 | out |
| 650 | } |
| 651 | |
| 652 | #[must_use] |
| 653 | pub fn context_hitbox(&self, area: Rect, theme: &Theme) -> Option<Rect> { |
| 654 | self.hitboxes(area, theme) |
| 655 | .into_iter() |
| 656 | .find(|hit| hit.kind == MetricKind::Context) |
| 657 | .map(|hit| hit.area) |
| 658 | } |
| 659 | } |
| 660 | |
| 661 | struct PreparedMetric { |
| 662 | kind: MetricKind, |
| 663 | label: String, |
| 664 | value: String, |
| 665 | role: Role, |
| 666 | } |
| 667 | |
| 668 | impl PreparedMetric { |
| 669 | fn width(&self) -> usize { |
| 670 | text::width(&self.value) |
| 671 | + if self.label.is_empty() { |
| 672 | 0 |
| 673 | } else { |
| 674 | text::width(&self.label) + 1 |
| 675 | } |
| 676 | } |
| 677 | } |
| 678 | |
| 679 | struct MetricLayout { |
| 680 | kept: Vec<PreparedMetric>, |
| 681 | help: Option<String>, |
| 682 | } |
| 683 | |
| 684 | impl Paint for MetricsLine<'_> { |
| 685 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 686 | let area = area.intersection(buf.area); |
| 687 | if area.is_empty() { |
| 688 | return; |
| 689 | } |
| 690 | let layout = self.layout(area, theme); |
| 691 | let mut x = usize::from(area.x); |
| 692 | for (index, segment) in layout.kept.iter().enumerate() { |
| 693 | if index > 0 { |
| 694 | put(area, buf, x, ITEM_JOIN, theme.fg(Role::Dim)); |
| 695 | x += text::width(ITEM_JOIN); |
| 696 | } |
| 697 | let hovered = matches!(segment.kind, MetricKind::Model | MetricKind::Context) |
| 698 | && self.hovered == Some(segment.kind); |
| 699 | let style = if hovered { |
| 700 | theme |
| 701 | .fg(segment.role) |
| 702 | .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) |
| 703 | } else { |
| 704 | theme.fg(segment.role) |
| 705 | }; |
| 706 | if !segment.label.is_empty() { |
| 707 | let label_role = if matches!(segment.role, Role::Danger | Role::Attention) { |
| 708 | segment.role |
| 709 | } else { |
| 710 | Role::Muted |
| 711 | }; |
| 712 | put(area, buf, x, &segment.label, theme.fg(label_role)); |
| 713 | x += text::width(&segment.label) + 1; |
| 714 | } |
| 715 | put(area, buf, x, &segment.value, style); |
| 716 | x += text::width(&segment.value); |
| 717 | } |
| 718 | if let Some(help) = layout.help { |
| 719 | let x = usize::from(area.right()).saturating_sub(text::width(&help)); |
| 720 | put(area, buf, x, &help, theme.fg(Role::Hint)); |
| 721 | } |
| 722 | } |
| 723 | |
| 724 | fn height(&self, width: u16, _theme: &Theme) -> u16 { |
| 725 | u16::from(width > 0) |
| 726 | } |
| 727 | } |
| 728 | |
| 729 | // Native punctuation projection, with the kit's existing text sanitation. |
| 730 | // Authored glyphs change in ASCII mode; language text stays intact. |
| 731 | fn project(value: &str, ascii: bool) -> String { |
| 732 | let safe = text::display_safe(value); |
| 733 | if !ascii { |
| 734 | return safe.into_owned(); |
| 735 | } |
| 736 | if let Some(fallback) = glyphs::ascii_fallback(&safe) { |
| 737 | return fallback.to_owned(); |
| 738 | } |
| 739 | safe.chars() |
| 740 | .map(|ch| { |
| 741 | let mut bytes = [0; 4]; |
| 742 | glyphs::ascii_fallback(ch.encode_utf8(&mut bytes)) |
| 743 | .map(str::to_owned) |
| 744 | .unwrap_or_else(|| ch.to_string()) |
| 745 | }) |
| 746 | .collect() |
| 747 | } |
| 748 | |
| 749 | // phase_strip uses ui_text::truncate_line_to_width, whose ellipsis is three |
| 750 | // ASCII cells even on Unicode terminals and whose tiny budgets have no dot. |
| 751 | fn clip_native(value: &str, max: usize) -> String { |
| 752 | if text::width(value) <= max { |
| 753 | return value.to_owned(); |
| 754 | } |
| 755 | let limit = max.saturating_sub(if max > 3 { 3 } else { 0 }); |
| 756 | let mut out = String::new(); |
| 757 | let mut used = 0; |
| 758 | for grapheme in value.graphemes(true) { |
| 759 | let width = text::width(grapheme); |
| 760 | if used + width > limit { |
| 761 | break; |
| 762 | } |
| 763 | out.push_str(grapheme); |
| 764 | used += width; |
| 765 | } |
| 766 | if max > 3 { |
| 767 | out.push_str("..."); |
| 768 | } |
| 769 | out |
| 770 | } |
| 771 | |
| 772 | fn put(area: Rect, buf: &mut Buffer, x: usize, value: &str, style: Style) { |
| 773 | let right = usize::from(area.right()); |
| 774 | if x >= usize::from(area.x) && x < right && !value.is_empty() { |
| 775 | buf.set_stringn(x as u16, area.y, value, right - x, style); |
| 776 | } |
| 777 | } |
| 778 |