| 1 | //! Codewhale's instrument rooms and saved-session list. |
| 2 | //! |
| 3 | //! Extracted from `crates/tui/src/tui/views/mod.rs::{render_underwater_surface, |
| 4 | //! action_footer_lines,place_footer_lines,render_panel_scroll_rail}` and |
| 5 | //! `session_picker.rs::{build_list_lines,format_session_line}` at |
| 6 | //! `a79ce5c4d5ed1a5f7032185710c27343a900351c` in Hmbown/CodeWhale. |
| 7 | //! Callers provide display facts and own filtering, persistence, and actions. |
| 8 | |
| 9 | //! |
| 10 | //! MIT License |
| 11 | //! |
| 12 | //! Copyright (c) 2024-2025 DeepSeek-TUI Contributors |
| 13 | //! |
| 14 | //! Permission is hereby granted, free of charge, to any person obtaining a copy |
| 15 | //! of this software and associated documentation files (the "Software"), to deal |
| 16 | //! in the Software without restriction, including without limitation the rights |
| 17 | //! to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
| 18 | //! copies of the Software, and to permit persons to whom the Software is |
| 19 | //! furnished to do so, subject to the following conditions: |
| 20 | //! |
| 21 | //! The above copyright notice and this permission notice shall be included in all |
| 22 | //! copies or substantial portions of the Software. |
| 23 | //! |
| 24 | //! THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
| 25 | //! IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
| 26 | //! FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
| 27 | //! AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
| 28 | //! LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
| 29 | //! OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
| 30 | //! SOFTWARE. |
| 31 | |
| 32 | use std::borrow::Cow; |
| 33 | |
| 34 | use ratatui::{ |
| 35 | buffer::Buffer, |
| 36 | layout::{Position, Rect}, |
| 37 | style::Modifier, |
| 38 | symbols::border, |
| 39 | text::{Line, Span}, |
| 40 | widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, |
| 41 | }; |
| 42 | use unicode_segmentation::UnicodeSegmentation; |
| 43 | |
| 44 | use crate::{KeyHint, Paint, PickerState, Role, Theme, TuiGround, text}; |
| 45 | |
| 46 | /// The native room shell, including a wrapping, bottom-anchored action rail. |
| 47 | #[derive(Clone, Debug)] |
| 48 | pub struct InstrumentSurface<'a> { |
| 49 | pub title: Cow<'a, str>, |
| 50 | pub actions: Vec<KeyHint>, |
| 51 | pub quiet_gutter: bool, |
| 52 | } |
| 53 | |
| 54 | /// Geometry returned by [`InstrumentSurface::areas`]. |
| 55 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 56 | pub struct InstrumentAreas { |
| 57 | pub surface: Rect, |
| 58 | pub body: Rect, |
| 59 | pub footer: Rect, |
| 60 | } |
| 61 | |
| 62 | impl<'a> InstrumentSurface<'a> { |
| 63 | #[must_use] |
| 64 | pub fn new(title: impl Into<Cow<'a, str>>) -> Self { |
| 65 | Self { |
| 66 | title: title.into(), |
| 67 | actions: Vec::new(), |
| 68 | quiet_gutter: false, |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | #[must_use] |
| 73 | pub fn actions(mut self, actions: Vec<KeyHint>) -> Self { |
| 74 | self.actions = actions; |
| 75 | self |
| 76 | } |
| 77 | |
| 78 | /// Keep one quiet row before the action rail when four body rows remain. |
| 79 | #[must_use] |
| 80 | pub const fn quiet_gutter(mut self, enabled: bool) -> Self { |
| 81 | self.quiet_gutter = enabled; |
| 82 | self |
| 83 | } |
| 84 | |
| 85 | fn block(&self, area: Rect, theme: &Theme) -> Block<'static> { |
| 86 | let surface = surface_area(area); |
| 87 | let title = native_safe(&self.title, theme); |
| 88 | let title = text::truncate_words( |
| 89 | &title, |
| 90 | usize::from(surface.width.saturating_sub(4)), |
| 91 | theme.ascii(), |
| 92 | ); |
| 93 | let mut block = Block::default() |
| 94 | .title(Line::from(Span::styled( |
| 95 | format!(" {title} "), |
| 96 | theme.fg(Role::Primary).add_modifier(Modifier::BOLD), |
| 97 | ))) |
| 98 | .borders(Borders::TOP | Borders::BOTTOM) |
| 99 | .border_style(theme.fg(Role::Border)) |
| 100 | .style(theme.tui_ground(TuiGround::Surface)) |
| 101 | .padding(Padding::new(1, 1, u16::from(area.height >= 24), 0)); |
| 102 | if theme.ascii() { |
| 103 | block = block.border_set(border::Set { |
| 104 | horizontal_top: "-", |
| 105 | horizontal_bottom: "-", |
| 106 | ..border::PLAIN |
| 107 | }); |
| 108 | } |
| 109 | block |
| 110 | } |
| 111 | |
| 112 | /// Layout against the same rectangle used for painting. For a clipped |
| 113 | /// buffer, intersect that rectangle with `buf.area` first. |
| 114 | #[must_use] |
| 115 | pub fn areas(&self, area: Rect, theme: &Theme) -> InstrumentAreas { |
| 116 | let surface = surface_area(area); |
| 117 | let inner = self.block(area, theme).inner(surface); |
| 118 | let (body, footer) = footer_areas( |
| 119 | inner, |
| 120 | action_lines(&self.actions, inner.width, theme).len(), |
| 121 | self.quiet_gutter, |
| 122 | ); |
| 123 | InstrumentAreas { |
| 124 | surface, |
| 125 | body, |
| 126 | footer, |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | /// Paint the native shell and action rail, returning its content rectangle. |
| 131 | pub fn draw(&self, area: Rect, buf: &mut Buffer, theme: &Theme) -> Rect { |
| 132 | let area = area.intersection(buf.area); |
| 133 | if area.is_empty() { |
| 134 | return area; |
| 135 | } |
| 136 | Clear.render(area, buf); |
| 137 | Block::default() |
| 138 | .style(theme.tui_ground(TuiGround::Surface)) |
| 139 | .render(area, buf); |
| 140 | let block = self.block(area, theme); |
| 141 | let inner = block.inner(surface_area(area)); |
| 142 | block.render(surface_area(area), buf); |
| 143 | Self::draw_footer(inner, buf, &self.actions, theme, self.quiet_gutter) |
| 144 | } |
| 145 | |
| 146 | /// Reuse the native action rail inside a centered choice or another host |
| 147 | /// surface. Hints wrap as whole actions, with a bold padded key and one |
| 148 | /// space between actions; no action is discarded to make the row fit. |
| 149 | pub fn draw_footer( |
| 150 | area: Rect, |
| 151 | buf: &mut Buffer, |
| 152 | actions: &[KeyHint], |
| 153 | theme: &Theme, |
| 154 | quiet_gutter: bool, |
| 155 | ) -> Rect { |
| 156 | let area = area.intersection(buf.area); |
| 157 | let lines = action_lines(actions, area.width, theme); |
| 158 | let (body, footer) = footer_areas(area, lines.len(), quiet_gutter); |
| 159 | if !footer.is_empty() { |
| 160 | Paragraph::new(lines).render(footer, buf); |
| 161 | } |
| 162 | body |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | impl Paint for InstrumentSurface<'_> { |
| 167 | fn height(&self, _width: u16, _theme: &Theme) -> u16 { |
| 168 | 1 |
| 169 | } |
| 170 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 171 | self.draw(area, buf, theme); |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | fn surface_area(area: Rect) -> Rect { |
| 176 | let x = u16::from(area.width >= 44); |
| 177 | let y = u16::from(area.height >= 24); |
| 178 | Rect::new( |
| 179 | area.x.saturating_add(x), |
| 180 | area.y.saturating_add(y), |
| 181 | area.width.saturating_sub(2 * x), |
| 182 | area.height.saturating_sub(2 * y), |
| 183 | ) |
| 184 | } |
| 185 | |
| 186 | fn footer_areas(inner: Rect, line_count: usize, quiet: bool) -> (Rect, Rect) { |
| 187 | let height = u16::try_from(line_count) |
| 188 | .unwrap_or(u16::MAX) |
| 189 | .min(inner.height); |
| 190 | let gutter = u16::from(height > 0 && quiet && inner.height >= height.saturating_add(4)); |
| 191 | let footer = Rect::new( |
| 192 | inner.x, |
| 193 | inner.bottom().saturating_sub(height), |
| 194 | inner.width, |
| 195 | height, |
| 196 | ); |
| 197 | let body = Rect::new( |
| 198 | inner.x, |
| 199 | inner.y, |
| 200 | inner.width, |
| 201 | inner.height.saturating_sub(height.saturating_add(gutter)), |
| 202 | ); |
| 203 | (body, footer) |
| 204 | } |
| 205 | |
| 206 | fn action_lines(actions: &[KeyHint], width: u16, theme: &Theme) -> Vec<Line<'static>> { |
| 207 | if width == 0 { |
| 208 | return Vec::new(); |
| 209 | } |
| 210 | let mut result = Vec::new(); |
| 211 | let mut current = Vec::new(); |
| 212 | let mut used = 0usize; |
| 213 | for action in actions { |
| 214 | let key = native_safe(&action.keys, theme); |
| 215 | let label = native_safe(&action.verb, theme); |
| 216 | let needed = text::width(&key) + 2 + text::width(&label); |
| 217 | if !current.is_empty() && used + 1 + needed > usize::from(width) { |
| 218 | result.push(Line::from(std::mem::take(&mut current))); |
| 219 | used = 0; |
| 220 | } |
| 221 | if !current.is_empty() { |
| 222 | current.push(Span::raw(" ")); |
| 223 | used += 1; |
| 224 | } |
| 225 | let key_role = if action.enabled { |
| 226 | Role::Primary |
| 227 | } else { |
| 228 | Role::Dim |
| 229 | }; |
| 230 | let label_role = if action.enabled { |
| 231 | Role::Muted |
| 232 | } else { |
| 233 | Role::Dim |
| 234 | }; |
| 235 | current.push(Span::styled( |
| 236 | format!(" {key} "), |
| 237 | theme.fg(key_role).add_modifier(Modifier::BOLD), |
| 238 | )); |
| 239 | current.push(Span::styled(label, theme.fg(label_role))); |
| 240 | used += needed; |
| 241 | } |
| 242 | if !current.is_empty() { |
| 243 | result.push(Line::from(current)); |
| 244 | } |
| 245 | result |
| 246 | } |
| 247 | |
| 248 | /// A saved-session display row. `updated` is formatted by the host, so the |
| 249 | /// library needs neither a clock nor a session store. |
| 250 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 251 | pub struct SessionRow<'a> { |
| 252 | pub id: Cow<'a, str>, |
| 253 | pub title: Cow<'a, str>, |
| 254 | pub message_count: usize, |
| 255 | pub mode: Option<Cow<'a, str>>, |
| 256 | pub updated: Cow<'a, str>, |
| 257 | pub current: bool, |
| 258 | pub fork: bool, |
| 259 | pub archived: bool, |
| 260 | } |
| 261 | |
| 262 | impl<'a> SessionRow<'a> { |
| 263 | #[must_use] |
| 264 | pub fn new(id: impl Into<Cow<'a, str>>, title: impl Into<Cow<'a, str>>) -> Self { |
| 265 | Self { |
| 266 | id: id.into(), |
| 267 | title: title.into(), |
| 268 | message_count: 0, |
| 269 | mode: None, |
| 270 | updated: Cow::Borrowed(""), |
| 271 | current: false, |
| 272 | fork: false, |
| 273 | archived: false, |
| 274 | } |
| 275 | } |
| 276 | #[must_use] |
| 277 | pub const fn messages(mut self, count: usize) -> Self { |
| 278 | self.message_count = count; |
| 279 | self |
| 280 | } |
| 281 | #[must_use] |
| 282 | pub fn mode(mut self, mode: impl Into<Cow<'a, str>>) -> Self { |
| 283 | self.mode = Some(mode.into()); |
| 284 | self |
| 285 | } |
| 286 | #[must_use] |
| 287 | pub fn updated(mut self, updated: impl Into<Cow<'a, str>>) -> Self { |
| 288 | self.updated = updated.into(); |
| 289 | self |
| 290 | } |
| 291 | #[must_use] |
| 292 | pub const fn current(mut self, current: bool) -> Self { |
| 293 | self.current = current; |
| 294 | self |
| 295 | } |
| 296 | #[must_use] |
| 297 | pub const fn fork(mut self, fork: bool) -> Self { |
| 298 | self.fork = fork; |
| 299 | self |
| 300 | } |
| 301 | #[must_use] |
| 302 | pub const fn archived(mut self, archived: bool) -> Self { |
| 303 | self.archived = archived; |
| 304 | self |
| 305 | } |
| 306 | |
| 307 | fn line(&self, words: &SessionListWords<'_>, theme: &Theme) -> String { |
| 308 | let id = native_safe(&self.id, theme); |
| 309 | let id = id.get(..8).unwrap_or(&id); |
| 310 | let raw_title = if self.title == "Session" { |
| 311 | id.to_string() |
| 312 | } else { |
| 313 | native_safe(&self.title, theme) |
| 314 | }; |
| 315 | let title = session_truncate(&raw_title, 32); |
| 316 | let unit = if self.message_count == 1 { |
| 317 | &words.message |
| 318 | } else { |
| 319 | &words.messages |
| 320 | }; |
| 321 | let mut flags = String::new(); |
| 322 | for (enabled, label) in [ |
| 323 | (self.current, &words.current), |
| 324 | (self.fork, &words.fork), |
| 325 | (self.archived, &words.archived), |
| 326 | ] { |
| 327 | if enabled { |
| 328 | flags.push_str(" | "); |
| 329 | flags.push_str(&native_safe(label, theme)); |
| 330 | } |
| 331 | } |
| 332 | let mode = self.mode.as_ref().map_or_else( |
| 333 | || native_safe(&words.unknown_mode, theme), |
| 334 | |mode| native_safe(mode, theme).to_ascii_lowercase(), |
| 335 | ); |
| 336 | format!( |
| 337 | "{id} | {title} | {} {}{flags} | {mode} | {}", |
| 338 | self.message_count, |
| 339 | native_safe(unit, theme), |
| 340 | native_safe(&self.updated, theme) |
| 341 | ) |
| 342 | } |
| 343 | } |
| 344 | |
| 345 | /// Localizable copy used by the native session list. Query, status, sort |
| 346 | /// labels, titles, and timestamps remain caller-provided text. |
| 347 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 348 | pub struct SessionListWords<'a> { |
| 349 | pub new_title: Cow<'a, str>, |
| 350 | pub confirm_delete: Cow<'a, str>, |
| 351 | pub empty_title: Cow<'a, str>, |
| 352 | pub empty_hint: Cow<'a, str>, |
| 353 | /// Contains `{start}`, `{end}`, and `{total}`. |
| 354 | pub showing_range: Cow<'a, str>, |
| 355 | pub message: Cow<'a, str>, |
| 356 | pub messages: Cow<'a, str>, |
| 357 | pub current: Cow<'a, str>, |
| 358 | pub fork: Cow<'a, str>, |
| 359 | pub archived: Cow<'a, str>, |
| 360 | pub unknown_mode: Cow<'a, str>, |
| 361 | } |
| 362 | |
| 363 | impl Default for SessionListWords<'_> { |
| 364 | fn default() -> Self { |
| 365 | Self { |
| 366 | new_title: "New title: ".into(), |
| 367 | confirm_delete: "Confirm delete (y/n)".into(), |
| 368 | empty_title: "No saved sessions yet.".into(), |
| 369 | empty_hint: "Send a message to start one — it saves automatically.".into(), |
| 370 | showing_range: "Showing {start}-{end} / {total}".into(), |
| 371 | message: "msg".into(), |
| 372 | messages: "msgs".into(), |
| 373 | current: "current".into(), |
| 374 | fork: "fork".into(), |
| 375 | archived: "archived".into(), |
| 376 | unknown_mode: "unknown".into(), |
| 377 | } |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | /// The portable saved-session list used by the current `/sessions` room. |
| 382 | /// Supply already-filtered/sorted rows and update [`PickerState`] in the host. |
| 383 | #[derive(Clone, Debug)] |
| 384 | pub struct SessionList<'a> { |
| 385 | pub rows: Vec<SessionRow<'a>>, |
| 386 | pub state: PickerState, |
| 387 | pub scope_sort: Cow<'a, str>, |
| 388 | pub query: Option<Cow<'a, str>>, |
| 389 | pub rename: Option<Cow<'a, str>>, |
| 390 | pub status: Option<Cow<'a, str>>, |
| 391 | pub confirm_delete: bool, |
| 392 | pub words: SessionListWords<'a>, |
| 393 | } |
| 394 | |
| 395 | /// A visible source-row index and its one-line mouse target. |
| 396 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 397 | pub struct SessionHitbox { |
| 398 | pub index: usize, |
| 399 | pub area: Rect, |
| 400 | } |
| 401 | |
| 402 | impl<'a> SessionList<'a> { |
| 403 | #[must_use] |
| 404 | pub fn new(rows: Vec<SessionRow<'a>>) -> Self { |
| 405 | Self { |
| 406 | rows, |
| 407 | state: PickerState::default(), |
| 408 | scope_sort: "scope and sort · recent".into(), |
| 409 | query: None, |
| 410 | rename: None, |
| 411 | status: None, |
| 412 | confirm_delete: false, |
| 413 | words: SessionListWords::default(), |
| 414 | } |
| 415 | } |
| 416 | #[must_use] |
| 417 | pub const fn state(mut self, state: PickerState) -> Self { |
| 418 | self.state = state; |
| 419 | self |
| 420 | } |
| 421 | #[must_use] |
| 422 | pub fn scope_sort(mut self, label: impl Into<Cow<'a, str>>) -> Self { |
| 423 | self.scope_sort = label.into(); |
| 424 | self |
| 425 | } |
| 426 | #[must_use] |
| 427 | pub fn query(mut self, query: impl Into<Cow<'a, str>>) -> Self { |
| 428 | self.query = Some(query.into()); |
| 429 | self |
| 430 | } |
| 431 | #[must_use] |
| 432 | pub fn rename(mut self, title: impl Into<Cow<'a, str>>) -> Self { |
| 433 | self.rename = Some(title.into()); |
| 434 | self |
| 435 | } |
| 436 | #[must_use] |
| 437 | pub fn status(mut self, status: impl Into<Cow<'a, str>>) -> Self { |
| 438 | self.status = Some(status.into()); |
| 439 | self |
| 440 | } |
| 441 | #[must_use] |
| 442 | pub const fn confirm_delete(mut self, confirm: bool) -> Self { |
| 443 | self.confirm_delete = confirm; |
| 444 | self |
| 445 | } |
| 446 | #[must_use] |
| 447 | pub fn words(mut self, words: SessionListWords<'a>) -> Self { |
| 448 | self.words = words; |
| 449 | self |
| 450 | } |
| 451 | |
| 452 | fn layout(&self, area: Rect) -> SessionLayout { |
| 453 | let header = 1 + usize::from(self.confirm_delete || self.status.is_some()); |
| 454 | let visible = usize::from(area.height) |
| 455 | .saturating_sub(header + usize::from(!self.rows.is_empty())) |
| 456 | .max(1); |
| 457 | let offset = self.state.visible_offset(self.rows.len(), visible); |
| 458 | let scrolls = |
| 459 | area.width >= 2 && area.height > 0 && self.rows.len().saturating_add(header) > visible; |
| 460 | SessionLayout { |
| 461 | header, |
| 462 | visible, |
| 463 | offset, |
| 464 | content: Rect::new( |
| 465 | area.x, |
| 466 | area.y, |
| 467 | area.width.saturating_sub(u16::from(scrolls)), |
| 468 | area.height, |
| 469 | ), |
| 470 | scrolls, |
| 471 | } |
| 472 | } |
| 473 | |
| 474 | /// Visible item targets, excluding the header, range label, and rail. |
| 475 | /// Pass the clipped paint area when the buffer only covers part of a view. |
| 476 | #[must_use] |
| 477 | pub fn hitboxes(&self, area: Rect, _theme: &Theme) -> Vec<SessionHitbox> { |
| 478 | if area.is_empty() || self.rows.is_empty() { |
| 479 | return Vec::new(); |
| 480 | } |
| 481 | let layout = self.layout(area); |
| 482 | self.rows |
| 483 | .iter() |
| 484 | .enumerate() |
| 485 | .skip(layout.offset) |
| 486 | .take(layout.visible) |
| 487 | .enumerate() |
| 488 | .filter_map(|(slot, (index, _))| { |
| 489 | let y = area |
| 490 | .y |
| 491 | .saturating_add(u16::try_from(layout.header + slot).unwrap_or(u16::MAX)); |
| 492 | (y < area.bottom() && layout.content.width > 0).then_some(SessionHitbox { |
| 493 | index, |
| 494 | area: Rect::new(area.x, y, layout.content.width, 1), |
| 495 | }) |
| 496 | }) |
| 497 | .collect() |
| 498 | } |
| 499 | |
| 500 | #[must_use] |
| 501 | pub fn item_at(&self, area: Rect, position: Position, theme: &Theme) -> Option<usize> { |
| 502 | self.hitboxes(area, theme) |
| 503 | .into_iter() |
| 504 | .find_map(|hit| hit.area.contains(position).then_some(hit.index)) |
| 505 | } |
| 506 | } |
| 507 | |
| 508 | struct SessionLayout { |
| 509 | header: usize, |
| 510 | visible: usize, |
| 511 | offset: usize, |
| 512 | content: Rect, |
| 513 | scrolls: bool, |
| 514 | } |
| 515 | |
| 516 | impl Paint for SessionList<'_> { |
| 517 | fn height(&self, _width: u16, _theme: &Theme) -> u16 { |
| 518 | u16::try_from( |
| 519 | 1 + usize::from(self.confirm_delete || self.status.is_some()) |
| 520 | + if self.rows.is_empty() { |
| 521 | 2 |
| 522 | } else { |
| 523 | self.rows.len() + 1 |
| 524 | }, |
| 525 | ) |
| 526 | .unwrap_or(u16::MAX) |
| 527 | } |
| 528 | fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) { |
| 529 | let area = area.intersection(buf.area); |
| 530 | if area.is_empty() { |
| 531 | return; |
| 532 | } |
| 533 | let layout = self.layout(area); |
| 534 | if layout.scrolls { |
| 535 | scroll_rail( |
| 536 | area, |
| 537 | buf, |
| 538 | self.rows.len().saturating_add(layout.header), |
| 539 | layout.offset, |
| 540 | layout.visible, |
| 541 | theme, |
| 542 | ); |
| 543 | } |
| 544 | let width = usize::from(layout.content.width); |
| 545 | let header = if let Some(query) = &self.query { |
| 546 | format!("/{}", native_safe(query, theme)) |
| 547 | } else if let Some(rename) = &self.rename { |
| 548 | format!( |
| 549 | "{}{}_", |
| 550 | native_safe(&self.words.new_title, theme), |
| 551 | native_safe(rename, theme) |
| 552 | ) |
| 553 | } else { |
| 554 | native_safe(&self.scope_sort, theme) |
| 555 | }; |
| 556 | let mut lines = vec![Line::styled( |
| 557 | session_truncate(&header, width), |
| 558 | theme.fg(Role::Muted), |
| 559 | )]; |
| 560 | if self.confirm_delete { |
| 561 | lines.push(Line::styled( |
| 562 | session_truncate(&native_safe(&self.words.confirm_delete, theme), width), |
| 563 | theme.fg(Role::Attention).add_modifier(Modifier::BOLD), |
| 564 | )); |
| 565 | } else if let Some(status) = &self.status { |
| 566 | lines.push(Line::styled( |
| 567 | session_truncate(&native_safe(status, theme), width), |
| 568 | theme.fg(Role::Primary), |
| 569 | )); |
| 570 | } |
| 571 | if self.rows.is_empty() { |
| 572 | lines.push(Line::styled( |
| 573 | session_truncate(&native_safe(&self.words.empty_title, theme), width), |
| 574 | theme.fg(Role::Muted), |
| 575 | )); |
| 576 | lines.push(Line::styled( |
| 577 | session_truncate(&native_safe(&self.words.empty_hint, theme), width), |
| 578 | theme.fg(Role::Hint), |
| 579 | )); |
| 580 | } else { |
| 581 | for (slot, (index, row)) in self |
| 582 | .rows |
| 583 | .iter() |
| 584 | .enumerate() |
| 585 | .skip(layout.offset) |
| 586 | .take(layout.visible) |
| 587 | .enumerate() |
| 588 | { |
| 589 | let prefix = if slot < 9 { |
| 590 | format!("{}. ", slot + 1) |
| 591 | } else { |
| 592 | " ".into() |
| 593 | }; |
| 594 | let style = if index == self.state.selected.min(self.rows.len().saturating_sub(1)) { |
| 595 | theme |
| 596 | .fg(Role::Foreground) |
| 597 | .patch(theme.bg(Role::Selected)) |
| 598 | .add_modifier(Modifier::BOLD) |
| 599 | } else { |
| 600 | theme.fg(if row.current { |
| 601 | Role::Primary |
| 602 | } else { |
| 603 | Role::Foreground |
| 604 | }) |
| 605 | }; |
| 606 | lines.push(Line::styled( |
| 607 | session_truncate(&format!("{prefix}{}", row.line(&self.words, theme)), width), |
| 608 | style, |
| 609 | )); |
| 610 | } |
| 611 | if self.rows.len() > layout.visible { |
| 612 | let label = native_safe(&self.words.showing_range, theme) |
| 613 | .replace("{start}", &layout.offset.saturating_add(1).to_string()) |
| 614 | .replace( |
| 615 | "{end}", |
| 616 | &layout |
| 617 | .offset |
| 618 | .saturating_add(layout.visible) |
| 619 | .min(self.rows.len()) |
| 620 | .to_string(), |
| 621 | ) |
| 622 | .replace("{total}", &self.rows.len().to_string()); |
| 623 | lines.push(Line::styled( |
| 624 | session_truncate(&label, width), |
| 625 | theme.fg(Role::Dim), |
| 626 | )); |
| 627 | } |
| 628 | } |
| 629 | Paragraph::new(lines).render(layout.content, buf); |
| 630 | } |
| 631 | } |
| 632 | |
| 633 | fn scroll_rail( |
| 634 | area: Rect, |
| 635 | buf: &mut Buffer, |
| 636 | total: usize, |
| 637 | offset: usize, |
| 638 | visible: usize, |
| 639 | theme: &Theme, |
| 640 | ) { |
| 641 | let height = usize::from(area.height); |
| 642 | if height == 0 || area.width < 2 || total <= visible.max(1) { |
| 643 | return; |
| 644 | } |
| 645 | let thumb = height |
| 646 | .saturating_mul(visible) |
| 647 | .div_ceil(total) |
| 648 | .clamp(1, height); |
| 649 | let max_offset = total.saturating_sub(visible); |
| 650 | let top = (height - thumb) |
| 651 | .saturating_mul(offset.min(max_offset)) |
| 652 | .checked_div(max_offset) |
| 653 | .unwrap_or(0); |
| 654 | for i in 0..height { |
| 655 | let on = i >= top && i < top + thumb; |
| 656 | let glyph = match (on, theme.ascii()) { |
| 657 | (true, false) => "█", |
| 658 | (false, false) => "│", |
| 659 | (true, true) => "#", |
| 660 | (false, true) => "|", |
| 661 | }; |
| 662 | buf[(area.right() - 1, area.y + i as u16)] |
| 663 | .set_symbol(glyph) |
| 664 | .set_style(theme.fg(if on { Role::Muted } else { Role::Border })); |
| 665 | } |
| 666 | } |
| 667 | |
| 668 | /// The session list's native three-dot truncation and conservative title |
| 669 | /// budget. Grapheme boundaries and degenerate widths are made safe here. |
| 670 | fn session_truncate(value: &str, max: usize) -> String { |
| 671 | if text::width(value) <= max { |
| 672 | return value.into(); |
| 673 | } |
| 674 | let mut out = String::new(); |
| 675 | let mut used = 0; |
| 676 | for g in value.graphemes(true) { |
| 677 | let width = text::width(g); |
| 678 | if if max > 3 { |
| 679 | used + width >= max - 3 |
| 680 | } else { |
| 681 | used + width > max |
| 682 | } { |
| 683 | break; |
| 684 | } |
| 685 | out.push_str(g); |
| 686 | used += width; |
| 687 | } |
| 688 | if max > 3 { |
| 689 | out.push_str("..."); |
| 690 | } |
| 691 | out |
| 692 | } |
| 693 | |
| 694 | fn native_safe(value: &str, theme: &Theme) -> String { |
| 695 | let safe = text::display_safe(value); |
| 696 | if !theme.ascii() { |
| 697 | return safe.into_owned(); |
| 698 | } |
| 699 | safe.chars() |
| 700 | .map(|c| match c { |
| 701 | '↑' => "Up".into(), |
| 702 | '↓' => "Dn".into(), |
| 703 | '←' => "Left".into(), |
| 704 | '→' => "Right".into(), |
| 705 | '·' => ".".into(), |
| 706 | '—' | '–' => "-".into(), |
| 707 | '↵' => ">".into(), |
| 708 | '⇆' => "<>".into(), |
| 709 | '×' => "x".into(), |
| 710 | '✓' => "+".into(), |
| 711 | '▸' => ">".into(), |
| 712 | '…' => "...".into(), |
| 713 | _ => c.to_string(), |
| 714 | }) |
| 715 | .collect() |
| 716 | } |
| 717 |