返回 CodeWhale
workbench.rs
1 //! Composable workspace geometry, quiet module headers, composer context,
2 //! and the optional Codewhale workbar. Everything shown is a caller fact;
3 //! these components own neither a session nor a timer.
4
5 use std::borrow::Cow;
6
7 use ratatui::{
8 buffer::Buffer,
9 layout::Rect,
10 style::Modifier,
11 text::{Line, Span},
12 };
13
14 use crate::{Paint, Role, Theme, text};
15
16 /// Paint one clipped row without asking a terminal widget to walk beyond
17 /// an offset buffer or the last representable terminal coordinate.
18 pub(crate) fn row(area: Rect, buf: &mut Buffer, line: &Line<'_>) {
19 let area = area.intersection(buf.area);
20 if !area.is_empty() {
21 buf.set_line(area.x, area.y, line, area.width);
22 }
23 }
24
25 fn safe(value: &str) -> String {
26 text::display_safe(value).into_owned()
27 }
28
29 fn rule(area: Rect, buf: &mut Buffer, theme: &Theme) {
30 let mark = if theme.ascii() { "-" } else { "─" };
31 row(
32 area,
33 buf,
34 &Line::styled(mark.repeat(usize::from(area.width)), theme.fg(Role::Border)),
35 );
36 }
37
38 /// Regions inside a [`WorkspaceFrame`]. A hidden side pane receives no
39 /// rectangle: the host can expose it through its own Details action.
40 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41 pub struct WorkspaceAreas {
42 pub main: Rect,
43 pub side: Option<Rect>,
44 pub footer: Rect,
45 }
46
47 /// A project and branch header, reading canvas, optional inset dock, and a
48 /// quiet footer. The host paints its conversation and modules in `areas()`.
49 #[derive(Clone, Debug)]
50 pub struct WorkspaceFrame<'a> {
51 pub project: Cow<'a, str>,
52 pub branch: Cow<'a, str>,
53 pub mode: Cow<'a, str>,
54 pub footer: Cow<'a, str>,
55 pub side_width: u16,
56 pub minimum_main_width: u16,
57 }
58
59 impl<'a> WorkspaceFrame<'a> {
60 #[must_use]
61 pub fn new(project: impl Into<Cow<'a, str>>) -> Self {
62 Self {
63 project: project.into(),
64 branch: Cow::Borrowed(""),
65 mode: Cow::Borrowed("Workspace"),
66 footer: Cow::Borrowed(""),
67 side_width: 36,
68 minimum_main_width: 56,
69 }
70 }
71 #[must_use]
72 pub fn branch(mut self, branch: impl Into<Cow<'a, str>>) -> Self {
73 self.branch = branch.into();
74 self
75 }
76 #[must_use]
77 pub fn mode(mut self, mode: impl Into<Cow<'a, str>>) -> Self {
78 self.mode = mode.into();
79 self
80 }
81 #[must_use]
82 pub fn footer(mut self, footer: impl Into<Cow<'a, str>>) -> Self {
83 self.footer = footer.into();
84 self
85 }
86 #[must_use]
87 pub fn side_width(mut self, width: u16) -> Self {
88 self.side_width = width;
89 self
90 }
91 #[must_use]
92 pub fn minimum_main_width(mut self, width: u16) -> Self {
93 self.minimum_main_width = width;
94 self
95 }
96
97 /// Layout is pure and deterministic. Pass the intersection of the
98 /// requested area and the buffer when painting into an offset buffer.
99 #[must_use]
100 pub fn areas(&self, area: Rect) -> WorkspaceAreas {
101 if area.is_empty() {
102 return WorkspaceAreas::default();
103 }
104 let header = if area.height >= 4 { 3 } else { 1 };
105 let footer_height = u16::from(area.height > header + 1);
106 let footer = Rect::new(
107 area.x,
108 area.bottom().saturating_sub(footer_height),
109 area.width,
110 footer_height,
111 );
112 let body = Rect::new(
113 area.x,
114 area.y.saturating_add(header),
115 area.width,
116 area.height.saturating_sub(header + footer_height),
117 );
118 let gutter = u16::from(area.width >= 8) * 2;
119 let required = usize::from(self.minimum_main_width)
120 + usize::from(self.side_width)
121 + usize::from(2 * gutter)
122 + 1;
123 let side_visible = self.side_width >= 8 && usize::from(body.width) >= required;
124 if side_visible {
125 let split = body.right().saturating_sub(self.side_width + 1);
126 WorkspaceAreas {
127 main: Rect::new(
128 body.x.saturating_add(gutter),
129 body.y,
130 split.saturating_sub(body.x).saturating_sub(2 * gutter),
131 body.height,
132 ),
133 side: Some(Rect::new(
134 split.saturating_add(3),
135 body.y,
136 self.side_width.saturating_sub(4),
137 body.height,
138 )),
139 footer,
140 }
141 } else {
142 WorkspaceAreas {
143 main: Rect::new(
144 body.x.saturating_add(gutter),
145 body.y,
146 body.width.saturating_sub(2 * gutter),
147 body.height,
148 ),
149 side: None,
150 footer,
151 }
152 }
153 }
154 }
155
156 impl Paint for WorkspaceFrame<'_> {
157 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
158 let area = area.intersection(buf.area);
159 if area.is_empty() {
160 return;
161 }
162 buf.set_style(
163 area,
164 theme.bg(Role::Background).patch(theme.fg(Role::Foreground)),
165 );
166 let gutter = u16::from(area.width >= 8) * 2;
167 let header = Rect::new(
168 area.x.saturating_add(gutter),
169 area.y,
170 area.width.saturating_sub(2 * gutter),
171 1,
172 );
173 let identity = if self.branch.is_empty() {
174 safe(&self.project)
175 } else {
176 format!("{} / {}", safe(&self.project), safe(&self.branch))
177 };
178 let brand = "Codewhale";
179 let mode = safe(&self.mode);
180 let mode_width = text::width(&mode);
181 let reserve = if usize::from(header.width) > 48 + mode_width {
182 mode_width + 2
183 } else {
184 0
185 };
186 let left = usize::from(header.width).saturating_sub(reserve);
187 let mut spans = vec![Span::styled(
188 text::truncate(brand, left, theme.ascii()).into_owned(),
189 theme.fg(Role::Primary).add_modifier(Modifier::BOLD),
190 )];
191 if left > brand.len() + 3 {
192 spans.push(Span::styled(" / ", theme.fg(Role::BorderStrong)));
193 spans.push(Span::styled(
194 text::truncate_words(
195 &identity,
196 left.saturating_sub(brand.len() + 5),
197 theme.ascii(),
198 )
199 .into_owned(),
200 theme.fg(Role::Muted),
201 ));
202 }
203 row(header, buf, &Line::from(spans));
204 if reserve > 0 {
205 row(
206 Rect::new(
207 header.right().saturating_sub(mode_width as u16),
208 header.y,
209 mode_width as u16,
210 1,
211 ),
212 buf,
213 &Line::styled(
214 mode,
215 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
216 ),
217 );
218 }
219 if area.height >= 4 {
220 rule(
221 Rect::new(area.x, area.y.saturating_add(1), area.width, 1),
222 buf,
223 theme,
224 );
225 }
226 let regions = self.areas(area);
227 if let Some(side) = regions.side {
228 let panel = Rect::new(
229 side.x.saturating_sub(2),
230 area.y.saturating_add(2),
231 side.width.saturating_add(4),
232 regions.footer.y.saturating_sub(area.y.saturating_add(2)),
233 );
234 buf.set_style(panel, theme.bg(Role::Sidebar));
235 let x = panel.x.saturating_sub(1);
236 for y in panel.y..panel.bottom() {
237 row(
238 Rect::new(x, y, 1, 1),
239 buf,
240 &Line::styled(
241 if theme.ascii() { "|" } else { "│" },
242 theme.fg(Role::Border),
243 ),
244 );
245 }
246 }
247 if !regions.footer.is_empty() {
248 buf.set_style(regions.footer, theme.bg(Role::Sidebar));
249 row(
250 Rect::new(
251 regions.footer.x.saturating_add(gutter),
252 regions.footer.y,
253 regions.footer.width.saturating_sub(2 * gutter),
254 1,
255 ),
256 buf,
257 &Line::styled(
258 text::truncate_words(
259 &safe(&self.footer),
260 usize::from(regions.footer.width.saturating_sub(2 * gutter)),
261 theme.ascii(),
262 )
263 .into_owned(),
264 theme.fg(Role::Muted),
265 ),
266 );
267 }
268 }
269 }
270
271 /// One thin module title and optional right-aligned fact, with a hairline.
272 #[derive(Clone, Debug)]
273 pub struct PaneHeader<'a> {
274 pub title: Cow<'a, str>,
275 pub meta: Cow<'a, str>,
276 pub focused: bool,
277 pub divider: bool,
278 }
279 impl<'a> PaneHeader<'a> {
280 #[must_use]
281 pub fn new(title: impl Into<Cow<'a, str>>) -> Self {
282 Self {
283 title: title.into(),
284 meta: Cow::Borrowed(""),
285 focused: false,
286 divider: true,
287 }
288 }
289 #[must_use]
290 pub fn meta(mut self, meta: impl Into<Cow<'a, str>>) -> Self {
291 self.meta = meta.into();
292 self
293 }
294 #[must_use]
295 pub fn focused(mut self, focused: bool) -> Self {
296 self.focused = focused;
297 self
298 }
299 #[must_use]
300 pub fn divider(mut self, divider: bool) -> Self {
301 self.divider = divider;
302 self
303 }
304 }
305 impl Paint for PaneHeader<'_> {
306 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
307 let area = area.intersection(buf.area);
308 if area.is_empty() {
309 return;
310 }
311 let title = safe(&self.title);
312 let meta = safe(&self.meta);
313 let meta_width = text::width(&meta);
314 let show_meta =
315 !meta.is_empty() && meta_width + text::width(&title) + 2 <= usize::from(area.width);
316 let width =
317 usize::from(area.width).saturating_sub(if show_meta { meta_width + 2 } else { 0 });
318 row(
319 area,
320 buf,
321 &Line::styled(
322 text::truncate_words(&title, width, theme.ascii()).into_owned(),
323 theme.fg(Role::Foreground).add_modifier(Modifier::BOLD),
324 ),
325 );
326 if show_meta {
327 row(
328 Rect::new(
329 area.right().saturating_sub(meta_width as u16),
330 area.y,
331 meta_width as u16,
332 1,
333 ),
334 buf,
335 &Line::styled(meta, theme.fg(Role::Muted)),
336 );
337 }
338 if self.divider && area.height > 1 {
339 rule(
340 Rect::new(area.x, area.y.saturating_add(1), area.width, 1),
341 buf,
342 theme,
343 );
344 }
345 if self.focused {
346 row(
347 Rect::new(
348 area.x,
349 area.y
350 .saturating_add(u16::from(self.divider && area.height > 1)),
351 1,
352 1,
353 ),
354 buf,
355 &Line::styled(
356 if theme.ascii() { ">" } else { "╸" },
357 theme.fg(Role::Primary),
358 ),
359 );
360 }
361 }
362 fn height(&self, _width: u16, _theme: &Theme) -> u16 {
363 1 + u16::from(self.divider)
364 }
365 }
366
367 /// A composer-adjacent fact. Larger priorities survive a narrow ribbon.
368 #[derive(Clone, Debug)]
369 pub struct ContextItem<'a> {
370 pub label: Cow<'a, str>,
371 pub value: Cow<'a, str>,
372 pub priority: u8,
373 pub role: Role,
374 }
375 impl<'a> ContextItem<'a> {
376 #[must_use]
377 pub fn new(label: impl Into<Cow<'a, str>>, value: impl Into<Cow<'a, str>>) -> Self {
378 Self {
379 label: label.into(),
380 value: value.into(),
381 priority: 0,
382 role: Role::Foreground,
383 }
384 }
385 #[must_use]
386 pub fn priority(mut self, priority: u8) -> Self {
387 self.priority = priority;
388 self
389 }
390 #[must_use]
391 pub fn role(mut self, role: Role) -> Self {
392 self.role = role;
393 self
394 }
395 fn content(&self) -> String {
396 if self.label.is_empty() {
397 safe(&self.value)
398 } else {
399 format!("{} {}", safe(&self.label), safe(&self.value))
400 }
401 }
402 }
403
404 /// Facts in declaration order, folded by priority with an explicit count.
405 #[derive(Clone, Debug)]
406 pub struct ContextRibbon<'a> {
407 pub items: Vec<ContextItem<'a>>,
408 pub details_label: Cow<'a, str>,
409 }
410 impl<'a> ContextRibbon<'a> {
411 #[must_use]
412 pub fn new(items: Vec<ContextItem<'a>>) -> Self {
413 Self {
414 items,
415 details_label: Cow::Borrowed("details"),
416 }
417 }
418 #[must_use]
419 pub fn details_label(mut self, label: impl Into<Cow<'a, str>>) -> Self {
420 self.details_label = label.into();
421 self
422 }
423 }
424 impl Paint for ContextRibbon<'_> {
425 fn paint(&self, area: Rect, buf: &mut Buffer, theme: &Theme) {
426 let area = area.intersection(buf.area);
427 if area.is_empty() || self.items.is_empty() {
428 return;
429 }
430 let separator = if theme.ascii() { " / " } else { " · " };
431 let mut kept: Vec<usize> = (0..self.items.len()).collect();
432 loop {
433 let omitted = self.items.len() - kept.len();
434 let more = if omitted == 0 {
435 String::new()
436 } else {
437 format!("+{omitted} {}", safe(&self.details_label))
438 };
439 if kept.len() == 1 && omitted > 0 && usize::from(area.width) <= text::width(&more) + 3 {
440 kept.clear();
441 continue;
442 }
443 let total = kept
444 .iter()
445 .map(|&i| text::width(&self.items[i].content()))
446 .sum::<usize>()
447 + 3 * kept.len().saturating_sub(1)
448 + if omitted > 0 {
449 usize::from(!kept.is_empty()) * 3 + text::width(&more)
450 } else {
451 0
452 };
453 if total <= usize::from(area.width) || kept.len() <= 1 {
454 let remaining = if kept.is_empty() {
455 0
456 } else {
457 usize::from(area.width).saturating_sub(if omitted > 0 {
458 text::width(&more) + 3
459 } else {
460 0
461 })
462 };
463 let mut spans = Vec::new();
464 for (position, &index) in kept.iter().enumerate() {
465 if position > 0 {
466 spans.push(Span::styled(separator, theme.fg(Role::BorderStrong)));
467 }
468 let value = self.items[index].content();
469 let fitted = if kept.len() == 1 {
470 text::truncate_words(&value, remaining, theme.ascii()).into_owned()
471 } else {
472 value
473 };
474 spans.push(Span::styled(fitted, theme.fg(self.items[index].role)));
475 }
476 if omitted > 0 {
477 if remaining > 0 {
478 spans.push(Span::styled(separator, theme.fg(Role::BorderStrong)));
479 }
480 spans.push(Span::styled(
481 text::truncate_words(
482 &more,
483 usize::from(area.width)
484 .saturating_sub(remaining + usize::from(remaining > 0) * 3),
485 theme.ascii(),
486 )
487 .into_owned(),
488 theme.fg(Role::Muted),
489 ));
490 }
491 row(area, buf, &Line::from(spans));
492 break;
493 }
494 let drop = kept
495 .iter()
496 .enumerate()
497 .min_by_key(|&(position, &index)| {
498 (self.items[index].priority, std::cmp::Reverse(position))
499 })
500 .map(|(position, _)| position)
501 .unwrap_or(0);
502 kept.remove(drop);
503 }
504 }
505 }
506
506 lines RUST