1mod layout;
8mod paint;
9#[cfg(test)]
10mod tests;
11
12use crate::event::{Event, MouseButton, MouseEvent, MouseKind};
13use crate::geometry::{Rect, Size, clamp_u16};
14use crate::keymap::Key;
15use crate::style::CellStyle;
16use crate::text;
17use crate::theme::State;
18use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
19
20use super::cells;
21use super::close_mark;
22use super::context_item::ContextItem;
23use super::context_menu;
24use super::edge_scroll::Edge;
25use super::row::LEAD;
26use super::scrollbar::{self, ScrollMetrics};
27use super::tab_model::{self, Direction, TabModel, drop_target, preview_order};
28
29const COLLAPSED: u16 = 4;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
39pub enum CollapsedMarker {
40 #[default]
42 Icon,
43 Initial,
46 Number,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct RailTab {
54 name: String,
55 icon: Option<String>,
56 badge: Option<String>,
57 status: Option<String>,
58 marker: Option<CollapsedMarker>,
59}
60
61impl RailTab {
62 #[must_use]
64 pub fn new(name: impl Into<String>) -> Self {
65 Self { name: name.into(), icon: None, badge: None, status: None, marker: None }
66 }
67
68 #[must_use]
71 pub fn marker(mut self, marker: CollapsedMarker) -> Self {
72 self.marker = Some(marker);
73 self
74 }
75
76 #[must_use]
79 pub fn icon(mut self, key: impl Into<String>) -> Self {
80 self.icon = Some(key.into());
81 self
82 }
83
84 #[must_use]
86 pub fn badge(mut self, text: impl Into<String>) -> Self {
87 self.badge = Some(text.into());
88 self
89 }
90
91 #[must_use]
94 pub fn status(mut self, token: impl Into<String>) -> Self {
95 self.status = Some(token.into());
96 self
97 }
98}
99
100pub struct TabRail<Msg> {
141 tabs: Vec<RailTab>,
142 collapsed: bool,
143 marker: CollapsedMarker,
144 row_height: u16,
145 gap: Option<u16>,
147 on_add: Option<Box<dyn Fn() -> Msg>>,
148 model: TabModel<Msg>,
149}
150
151#[derive(Debug, Default)]
152struct RailMemory {
153 offset: usize,
154 followed: Option<usize>,
155 hint: Option<RailRow>,
157 card: Option<(RailRow, Rect)>,
160 dragging_scrollbar: bool,
162}
163
164impl RailMemory {
165 fn reveal(&mut self, index: usize, visible: usize) {
167 if index < self.offset {
168 self.offset = index;
169 } else if index >= self.offset + visible {
170 self.offset = index + 1 - visible;
171 }
172 }
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
177enum RailRow {
178 Tab(usize),
179 Add,
180}
181
182impl<Msg: 'static> TabRail<Msg> {
183 #[must_use]
185 pub fn new(tabs: impl IntoIterator<Item = RailTab>) -> Self {
186 let tabs: Vec<RailTab> = tabs.into_iter().collect();
187 let model = TabModel::new(tabs.len());
188 Self { tabs, collapsed: false, marker: CollapsedMarker::Icon, row_height: 1, gap: None, on_add: None, model }
189 }
190
191 #[must_use]
193 pub fn active(mut self, index: usize) -> Self {
194 self.model.active = index;
195 self
196 }
197
198 #[must_use]
200 pub fn on_select(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
201 self.model.on_select = Some(Box::new(message));
202 self
203 }
204
205 #[must_use]
208 pub fn collapsed(mut self, collapsed: bool) -> Self {
209 self.collapsed = collapsed;
210 self
211 }
212
213 #[must_use]
216 pub fn collapsed_marker(mut self, marker: CollapsedMarker) -> Self {
217 self.marker = marker;
218 self
219 }
220
221 #[must_use]
227 pub fn row_height(mut self, lines: u16) -> Self {
228 self.row_height = lines.max(1);
229 self
230 }
231
232 #[must_use]
235 pub fn gap(mut self, lines: u16) -> Self {
236 self.gap = Some(lines);
237 self
238 }
239
240 #[must_use]
243 pub fn on_add(mut self, message: impl Fn() -> Msg + 'static) -> Self {
244 self.on_add = Some(Box::new(message));
245 self
246 }
247
248 #[must_use]
251 pub fn closable(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
252 self.model.set_on_close(message);
253 self
254 }
255
256 #[must_use]
258 pub fn pinned(mut self, indices: impl IntoIterator<Item = usize>) -> Self {
259 self.model.pinned = indices.into_iter().collect();
260 self
261 }
262
263 #[must_use]
266 pub fn reorderable(mut self, message: impl Fn(usize, usize) -> Msg + 'static) -> Self {
267 self.model.set_on_move(message);
268 self
269 }
270
271 #[must_use]
274 pub fn on_drag_scroll(mut self, message: impl Fn(usize) -> Msg + 'static) -> Self {
275 self.model.on_drag_scroll = Some(Box::new(message));
276 self
277 }
278
279 #[must_use]
284 pub fn context_menu(mut self, items: impl Fn(usize) -> Vec<ContextItem<Msg>> + 'static) -> Self {
285 self.model.set_context_menu(items);
286 self
287 }
288}
289
290impl<Msg: 'static> Widget<Msg> for TabRail<Msg> {
291 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
292 let height = self.natural_height(self.rows());
293 if self.collapsed {
294 return Size::new(COLLAPSED, height).min(available);
295 }
296 let widest = (0..self.tabs.len())
297 .map(|i| {
298 let icon = if self.tabs[i].icon.is_some() { 2 } else { 0 };
299 cells::sum([LEAD, icon, text::width(&self.tabs[i].name), self.trailing_width(i, self.row_height), 3])
300 })
301 .max()
302 .unwrap_or(0);
303 Size::new(widest, height).min(available)
304 }
305
306 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
307 cx.register_hit(area);
308 let card = {
309 let memory = cx.memory::<RailMemory>();
310 memory.hint = None;
311 memory.card.take()
312 };
313 if self.rows() == 0 || area.is_empty() {
314 return;
315 }
316 let focused = cx.is_focus_visible();
317 let pointer = cx.pointer();
318 let active = self.model.active();
319 let drag = self.model.drag(cx);
320 let visible = self.visible_blocks(area.height);
321 let offset = {
322 let memory = cx.memory::<RailMemory>();
323 if drag.is_none() && memory.followed != Some(active) {
324 memory.reveal(active, visible);
325 memory.followed = Some(active);
326 }
327 memory.offset = memory.offset.min(self.rows().saturating_sub(visible));
328 memory.offset
329 };
330 let (content, metrics) = self.content(area, offset);
331 let resting = self.slots(&self.identity(), offset, content);
332 let target = drag.map(|d| (d.index, drop_target(&resting, d.index, d.pointer, Direction::Down)));
333 let order = preview_order(self.tabs.len(), target);
334
335 let carded = card
338 .filter(|(_, rect)| drag.is_none() && pointer.is_some_and(|(x, y)| rect.contains(x, y)))
339 .map(|(row, _)| row);
340 let named = (self.collapsed && focused && pointer.is_none() && drag.is_none()).then_some(RailRow::Tab(active));
342 let menu_tab = self.model.menu_tab(cx);
345 let menu = menu_tab.is_some();
346 if menu {
347 cx.request_overlay(area);
348 }
349 let carded = carded.filter(|_| !menu);
350 let named = named.filter(|_| !menu);
351 let pointer = pointer.filter(|_| !menu);
352
353 for (index, rect) in self.slots(&order, offset, content) {
354 if drag.is_some_and(|d| d.index == index) {
355 tab_model::paint_drop_slot(cx, rect);
356 continue;
357 }
358 let mut states = Vec::new();
359 let hovered = drag.is_none() && pointer.is_some_and(|(x, y)| rect.contains(x, y));
360 if hovered || carded == Some(RailRow::Tab(index)) || menu_tab == Some(index) {
361 states.push(State::Hover);
362 }
363 if self.collapsed && !menu && (hovered || carded.or(named) == Some(RailRow::Tab(index))) {
364 cx.memory::<RailMemory>().hint = Some(RailRow::Tab(index));
365 cx.request_overlay(rect);
366 }
367 if index == active {
368 states.push(State::Selected);
369 if focused {
370 states.push(State::Focus);
371 }
372 }
373 self.paint_row(cx, rect, index, &states, None);
374 }
375
376 if let Some(rect) = self.add_rect(content, offset) {
377 let hovered =
378 drag.is_none() && (pointer.is_some_and(|(x, y)| rect.contains(x, y)) || carded == Some(RailRow::Add));
379 if hovered && self.collapsed && !menu {
380 cx.memory::<RailMemory>().hint = Some(RailRow::Add);
381 cx.request_overlay(rect);
382 }
383 self.paint_add(cx, rect, hovered);
384 }
385
386 if let Some(drag) = drag {
387 let height = resting.first().map_or(1, |(_, rect)| rect.height);
388 let top = content.y;
389 let bottom = (content.bottom() - i32::from(height)).max(top);
390 let y = (drag.pointer.1 - drag.grab.1).clamp(top, bottom);
391 let rect = Rect::new(content.x, y, content.width, height);
392 tab_model::paint_ghost_surface(cx, rect);
393 self.paint_row(cx, rect, drag.index, &[], Some("ghost"));
394 }
395
396 if metrics.overflows() {
397 let bar = Rect::new(area.right() - 1, area.y, 1, area.height);
398 let held =
401 drag.and_then(|drag| self.drag_zone(area, content, metrics, drag.pointer.1)).is_some_and(|zone| {
402 match zone.edge {
403 Edge::Back => offset > 0,
404 Edge::Forward => offset < metrics.max_offset(),
405 }
406 });
407 let active = held
408 || cx.memory::<RailMemory>().dragging_scrollbar
409 || (carded.is_none() && pointer.is_some_and(|(x, _)| x == bar.x));
410 scrollbar::paint(cx, bar, metrics, active, None);
411 }
412 }
413
414 fn paint_overlay(&self, cx: &mut PaintCx<'_>, anchor: Rect) {
415 if self.model.paint_menu(cx, anchor) {
416 return;
417 }
418 let Some(row) = cx.memory::<RailMemory>().hint else {
419 return;
420 };
421 let (name, badge, closable) = match row {
422 RailRow::Tab(index) => {
423 (self.tabs[index].name.clone(), self.tabs[index].badge.clone(), self.model.closable(index))
424 }
425 RailRow::Add => (cx.env().i18n().translate("quvyta.tab-rail.add", &[]), None, false),
426 };
427 let card = Self::card(cx.clip(), anchor, &name, badge.as_deref(), closable);
428 let line = Self::middle(card);
429 let close = closable.then(|| close_mark::rect(card.right() - i32::from(close_mark::WIDTH), line));
430 let on_card = cx.pointer_anywhere().is_some_and(|(x, y)| card.contains(x, y));
431 let on_close = cx.pointer_anywhere().is_some_and(|(x, y)| close.is_some_and(|close| close.contains(x, y)));
432 let states = if on_card && !on_close { vec![State::Hover] } else { Vec::new() };
433 let style = cx.style("rail-hint", None, &states).text();
434 let background = style.bg.unwrap_or_else(|| cx.color("overlay"));
435 let grounds = cx.grounds_around(card);
436 cx.clear(card, background);
437 cx.register_hit(card);
438
439 let mut right = card.right() - i32::from(close.map_or(1, |close| close.width));
442 if let Some(badge) = &badge {
443 let badge_style = cx.style("rail-badge", None, &states).text();
444 let width = text::width(badge);
445 right -= i32::from(width);
446 cx.text(right, line, badge, CellStyle { bg: None, ..badge_style }, width);
447 right -= 2;
448 }
449 let budget = clamp_u16(right - (card.x + 1));
450 let shown = text::truncate(&name, budget).into_owned();
451 cx.text(card.x + 1, line, &shown, CellStyle { bg: None, ..style }, budget);
452 if let Some(close) = close {
453 close_mark::paint(cx, close.x, close.y, true);
454 }
455 cx.stand_apart(card, &grounds, Some(background));
456 cx.memory::<RailMemory>().card = Some((row, card));
457 }
458
459 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
460 if self.rows() == 0 {
461 return false;
462 }
463 let area = cx.area();
464 if self.menu_event(cx, event) {
465 return true;
466 }
467 match event {
468 Event::Key(_) if self.tabs.is_empty() => false,
469 Event::Key(key) => {
470 if key.is_plain(Key::Home) {
471 return self.model.open(cx, 0);
472 }
473 if key.is_plain(Key::End) {
474 return self.model.open(cx, self.tabs.len() - 1);
475 }
476 self.model.key(cx, key, Direction::Down)
477 }
478 Event::Mouse(mouse) => {
479 let offset = cx.memory::<RailMemory>().offset;
480 let (content, metrics) = self.content(area, offset);
481 if matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) {
482 let memory = cx.memory::<RailMemory>();
483 memory.offset = if mouse.kind == MouseKind::ScrollUp {
484 memory.offset.saturating_sub(1)
485 } else {
486 (memory.offset + 1).min(metrics.max_offset())
487 };
488 return true;
489 }
490 let card = cx.memory::<RailMemory>().card.filter(|(_, rect)| rect.contains(mouse.x, mouse.y));
493 if (card.is_none() || cx.memory::<RailMemory>().dragging_scrollbar)
494 && self.scrollbar(cx, mouse, metrics)
495 {
496 return true;
497 }
498 let on_add = match card {
501 Some((row, _)) => row == RailRow::Add,
502 None => self.add_rect(content, offset).is_some_and(|add| add.contains(mouse.x, mouse.y)),
503 };
504 if on_add && let MouseKind::Down(button) = mouse.kind {
505 if button == MouseButton::Left
506 && let Some(message) = &self.on_add
507 {
508 cx.emit(message());
509 }
510 return true;
511 }
512 if self.tabs.is_empty() {
513 return false;
514 }
515 let slots = self.slots(&self.identity(), offset, content);
516 let hit = match card {
517 Some((RailRow::Tab(index), rect)) if index < self.tabs.len() => {
518 Some(Self::card_hit(rect, index, self.model.closable(index), mouse.x, mouse.y))
519 }
520 _ => self.hit(&slots, mouse.x, mouse.y),
521 };
522 let used = self.model.pointer(cx, mouse, hit, &slots, Direction::Down);
523 if mouse.kind == MouseKind::Drag(MouseButton::Left) {
524 let zone = self.drag_zone(area, content, metrics, mouse.y);
525 self.model.edge_scroll(cx, zone, |cx, edge| {
526 let memory = cx.memory::<RailMemory>();
527 memory.offset = match edge {
528 Edge::Back => memory.offset.checked_sub(1)?,
529 Edge::Forward => Some(memory.offset + 1).filter(|next| *next <= metrics.max_offset())?,
530 };
531 Some(memory.offset)
532 });
533 }
534 used
535 }
536 _ => false,
537 }
538 }
539
540 fn focusable(&self) -> bool {
541 !self.tabs.is_empty()
542 }
543}
544
545impl<Msg: 'static> TabRail<Msg> {
546 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
550 if let Event::Key(key) = event
551 && self.model.has_menu()
552 && context_menu::is_menu_key(key)
553 && !context_menu::is_open_in(cx)
554 {
555 let visible = self.visible_blocks(cx.area().height);
556 cx.memory::<RailMemory>().reveal(self.model.active(), visible);
557 }
558 let offset = cx.memory::<RailMemory>().offset;
559 let (content, _) = self.content(cx.area(), offset);
560 let slots = self.slots(&self.identity(), offset, content);
561 let hit = match event {
562 Event::Mouse(mouse) => match cx.memory::<RailMemory>().card {
563 Some((RailRow::Tab(index), rect)) if rect.contains(mouse.x, mouse.y) && index < self.tabs.len() => {
564 Some(Self::card_hit(rect, index, self.model.closable(index), mouse.x, mouse.y))
565 }
566 Some((RailRow::Add, rect)) if rect.contains(mouse.x, mouse.y) => None,
567 _ => self.hit(&slots, mouse.x, mouse.y),
568 },
569 _ => None,
570 };
571 let open_tab = slots.iter().find(|(index, _)| *index == self.model.active()).map(|(_, rect)| *rect);
572 self.model.menu_event(cx, event, hit, open_tab)
573 }
574
575 fn scrollbar(&self, cx: &mut EventCx<'_, Msg>, mouse: &MouseEvent, metrics: ScrollMetrics) -> bool {
577 let area = cx.area();
578 let row = clamp_u16(mouse.y - area.y);
579 match mouse.kind {
580 MouseKind::Down(MouseButton::Left) if metrics.overflows() && mouse.x == area.right() - 1 => {
581 cx.capture_pointer();
582 let memory = cx.memory::<RailMemory>();
583 memory.dragging_scrollbar = true;
584 memory.offset = metrics.offset_at(row, area.height);
585 true
586 }
587 MouseKind::Drag(MouseButton::Left) if cx.memory::<RailMemory>().dragging_scrollbar => {
588 cx.memory::<RailMemory>().offset = metrics.offset_at(row, area.height);
589 true
590 }
591 MouseKind::Up(MouseButton::Left) if cx.memory::<RailMemory>().dragging_scrollbar => {
592 cx.memory::<RailMemory>().dragging_scrollbar = false;
593 true
594 }
595 _ => false,
596 }
597 }
598}