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 cx.clear(card, style.bg.unwrap_or_else(|| cx.color("overlay")));
435 cx.register_hit(card);
436
437 let mut right = card.right() - i32::from(close.map_or(1, |close| close.width));
440 if let Some(badge) = &badge {
441 let badge_style = cx.style("rail-badge", None, &states).text();
442 let width = text::width(badge);
443 right -= i32::from(width);
444 cx.text(right, line, badge, CellStyle { bg: None, ..badge_style }, width);
445 right -= 2;
446 }
447 let budget = clamp_u16(right - (card.x + 1));
448 let shown = text::truncate(&name, budget).into_owned();
449 cx.text(card.x + 1, line, &shown, CellStyle { bg: None, ..style }, budget);
450 if let Some(close) = close {
451 close_mark::paint(cx, close.x, close.y, true);
452 }
453 cx.memory::<RailMemory>().card = Some((row, card));
454 }
455
456 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
457 if self.rows() == 0 {
458 return false;
459 }
460 let area = cx.area();
461 if self.menu_event(cx, event) {
462 return true;
463 }
464 match event {
465 Event::Key(_) if self.tabs.is_empty() => false,
466 Event::Key(key) => {
467 if key.is_plain(Key::Home) {
468 return self.model.open(cx, 0);
469 }
470 if key.is_plain(Key::End) {
471 return self.model.open(cx, self.tabs.len() - 1);
472 }
473 self.model.key(cx, key, Direction::Down)
474 }
475 Event::Mouse(mouse) => {
476 let offset = cx.memory::<RailMemory>().offset;
477 let (content, metrics) = self.content(area, offset);
478 if matches!(mouse.kind, MouseKind::ScrollUp | MouseKind::ScrollDown) {
479 let memory = cx.memory::<RailMemory>();
480 memory.offset = if mouse.kind == MouseKind::ScrollUp {
481 memory.offset.saturating_sub(1)
482 } else {
483 (memory.offset + 1).min(metrics.max_offset())
484 };
485 return true;
486 }
487 let card = cx.memory::<RailMemory>().card.filter(|(_, rect)| rect.contains(mouse.x, mouse.y));
490 if (card.is_none() || cx.memory::<RailMemory>().dragging_scrollbar)
491 && self.scrollbar(cx, mouse, metrics)
492 {
493 return true;
494 }
495 let on_add = match card {
498 Some((row, _)) => row == RailRow::Add,
499 None => self.add_rect(content, offset).is_some_and(|add| add.contains(mouse.x, mouse.y)),
500 };
501 if on_add && let MouseKind::Down(button) = mouse.kind {
502 if button == MouseButton::Left
503 && let Some(message) = &self.on_add
504 {
505 cx.emit(message());
506 }
507 return true;
508 }
509 if self.tabs.is_empty() {
510 return false;
511 }
512 let slots = self.slots(&self.identity(), offset, content);
513 let hit = match card {
514 Some((RailRow::Tab(index), rect)) if index < self.tabs.len() => {
515 Some(Self::card_hit(rect, index, self.model.closable(index), mouse.x, mouse.y))
516 }
517 _ => self.hit(&slots, mouse.x, mouse.y),
518 };
519 let used = self.model.pointer(cx, mouse, hit, &slots, Direction::Down);
520 if mouse.kind == MouseKind::Drag(MouseButton::Left) {
521 let zone = self.drag_zone(area, content, metrics, mouse.y);
522 self.model.edge_scroll(cx, zone, |cx, edge| {
523 let memory = cx.memory::<RailMemory>();
524 memory.offset = match edge {
525 Edge::Back => memory.offset.checked_sub(1)?,
526 Edge::Forward => Some(memory.offset + 1).filter(|next| *next <= metrics.max_offset())?,
527 };
528 Some(memory.offset)
529 });
530 }
531 used
532 }
533 _ => false,
534 }
535 }
536
537 fn focusable(&self) -> bool {
538 !self.tabs.is_empty()
539 }
540}
541
542impl<Msg: 'static> TabRail<Msg> {
543 fn menu_event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
547 if let Event::Key(key) = event
548 && self.model.has_menu()
549 && context_menu::is_menu_key(key)
550 && !context_menu::is_open_in(cx)
551 {
552 let visible = self.visible_blocks(cx.area().height);
553 cx.memory::<RailMemory>().reveal(self.model.active(), visible);
554 }
555 let offset = cx.memory::<RailMemory>().offset;
556 let (content, _) = self.content(cx.area(), offset);
557 let slots = self.slots(&self.identity(), offset, content);
558 let hit = match event {
559 Event::Mouse(mouse) => match cx.memory::<RailMemory>().card {
560 Some((RailRow::Tab(index), rect)) if rect.contains(mouse.x, mouse.y) && index < self.tabs.len() => {
561 Some(Self::card_hit(rect, index, self.model.closable(index), mouse.x, mouse.y))
562 }
563 Some((RailRow::Add, rect)) if rect.contains(mouse.x, mouse.y) => None,
564 _ => self.hit(&slots, mouse.x, mouse.y),
565 },
566 _ => None,
567 };
568 let open_tab = slots.iter().find(|(index, _)| *index == self.model.active()).map(|(_, rect)| *rect);
569 self.model.menu_event(cx, event, hit, open_tab)
570 }
571
572 fn scrollbar(&self, cx: &mut EventCx<'_, Msg>, mouse: &MouseEvent, metrics: ScrollMetrics) -> bool {
574 let area = cx.area();
575 let row = clamp_u16(mouse.y - area.y);
576 match mouse.kind {
577 MouseKind::Down(MouseButton::Left) if metrics.overflows() && mouse.x == area.right() - 1 => {
578 cx.capture_pointer();
579 let memory = cx.memory::<RailMemory>();
580 memory.dragging_scrollbar = true;
581 memory.offset = metrics.offset_at(row, area.height);
582 true
583 }
584 MouseKind::Drag(MouseButton::Left) if cx.memory::<RailMemory>().dragging_scrollbar => {
585 cx.memory::<RailMemory>().offset = metrics.offset_at(row, area.height);
586 true
587 }
588 MouseKind::Up(MouseButton::Left) if cx.memory::<RailMemory>().dragging_scrollbar => {
589 cx.memory::<RailMemory>().dragging_scrollbar = false;
590 true
591 }
592 _ => false,
593 }
594 }
595}