1use std::cell::RefCell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Color, CursorIcon, Insets, Key, Role};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use super::reorder::{self, Extents, Reorder};
13use crate::context::{BuildCx, NodeId, Paint, QuadPaint, Semantics};
14use crate::quick_edit::is_f2;
15use crate::stack::ScrollEdgeArrow;
16use crate::util::{self, FocusTarget, Roving};
17use crate::view::{Keyed, View};
18use crate::{ButtonSize, ContextMenu, Dot, HStack, IconButton, IconName, InlineRename, MenuItem, Tone};
19
20const TITLE_MAX_WIDTH: f32 = 160.0;
22const TAB_HEIGHT: f32 = 34.0;
23const COLOR_BAND: f32 = 3.0;
25
26#[derive(Clone, Debug, PartialEq)]
28#[non_exhaustive]
29#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
30pub struct DocumentTab {
31 pub id: u64,
33 pub title: String,
35 pub color: Option<Color>,
37 pub unsaved: bool,
39 pub icon: Option<IconName>,
41 pub closable: bool,
43}
44
45#[cfg_attr(feature = "live", ravenrs_live::expose)]
46impl DocumentTab {
47 pub fn new(id: u64, title: impl Into<String>) -> Self {
49 Self { id, title: title.into(), color: None, unsaved: false, icon: None, closable: true }
50 }
51
52 pub fn color(mut self, color: Color) -> Self {
54 self.color = Some(color);
55 self
56 }
57
58 pub fn unsaved(mut self, unsaved: bool) -> Self {
60 self.unsaved = unsaved;
61 self
62 }
63
64 pub fn icon(mut self, icon: IconName) -> Self {
66 self.icon = Some(icon);
67 self
68 }
69
70 pub fn closable(mut self, closable: bool) -> Self {
72 self.closable = closable;
73 self
74 }
75}
76
77#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
98pub struct DocumentTabs {
99 tabs: Vec<DocumentTab>,
100 active: Binding<u64>,
101 on_close: Option<Rc<dyn Fn(u64)>>,
102 on_reorder: Option<Rc<dyn Fn(usize, usize)>>,
103 on_rename: Option<Rc<dyn Fn(u64, String)>>,
104 on_new: Option<Rc<dyn Fn()>>,
105 menu: Option<Rc<dyn Fn(u64) -> Vec<MenuItem>>>,
106 label: Option<String>,
107 fade: Semantic,
108}
109
110impl fmt::Debug for DocumentTabs {
111 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112 f.debug_struct("DocumentTabs").field("tabs", &self.tabs).finish_non_exhaustive()
113 }
114}
115
116#[cfg_attr(feature = "live", ravenrs_live::expose)]
117impl DocumentTabs {
118 pub fn new(tabs: Vec<DocumentTab>, active: Binding<u64>) -> Self {
120 Self {
121 tabs,
122 active,
123 on_close: None,
124 on_reorder: None,
125 on_rename: None,
126 on_new: None,
127 menu: None,
128 label: None,
129 fade: Semantic::Canvas,
130 }
131 }
132
133 pub fn on_close(mut self, handler: impl Fn(u64) + 'static) -> Self {
135 self.on_close = Some(Rc::new(handler));
136 self
137 }
138
139 pub fn on_reorder(mut self, handler: impl Fn(usize, usize) + 'static) -> Self {
141 self.on_reorder = Some(Rc::new(handler));
142 self
143 }
144
145 pub fn on_rename(mut self, handler: impl Fn(u64, String) + 'static) -> Self {
147 self.on_rename = Some(Rc::new(handler));
148 self
149 }
150
151 pub fn on_new(mut self, handler: impl Fn() + 'static) -> Self {
153 self.on_new = Some(Rc::new(handler));
154 self
155 }
156
157 pub fn menu(mut self, items: impl Fn(u64) -> Vec<MenuItem> + 'static) -> Self {
159 self.menu = Some(Rc::new(items));
160 self
161 }
162
163 pub fn label(mut self, label: impl Into<String>) -> Self {
165 self.label = Some(label.into());
166 self
167 }
168
169 pub fn fade(mut self, fade: Semantic) -> Self {
171 self.fade = fade;
172 self
173 }
174}
175
176struct TabView<'a> {
177 strip: &'a DocumentTabs,
178 tab: &'a DocumentTab,
179 index: usize,
180 extents: Extents,
181 drag: State<Option<Reorder>>,
182 editing: State<bool>,
183 roving: Roving,
184 spoken: State<String>,
185}
186
187impl View for TabView<'_> {
188 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
189 let theme = *cx.theme();
190 let labels = cx.labels();
191 let (tab, strip, index) = (self.tab, self.strip, self.index);
192 let frame = cx.scroll_viewport();
193 if let (Some(size), Some(slot)) = (frame, self.extents.borrow_mut().get_mut(index)) {
194 *slot = size.width;
195 }
196 let on = strip.active.get() == tab.id;
197 let hovered = cx.is_hover_within();
198 let editing = self.editing.get() && strip.on_rename.is_some();
199 let ink = theme.color(if on { Semantic::Ink } else { Semantic::InkMuted });
200 let mut parts = Vec::with_capacity(6);
201 let attrs = util::attrs(&theme, TextStyle::Callout, None);
203 let title_width = if editing {
204 TITLE_MAX_WIDTH
205 } else {
206 (cx.measure_text(&tab.title, &attrs).width.ceil() + 1.0).min(TITLE_MAX_WIDTH)
207 };
208 let gap = theme.spacing.s2;
209 let closes = tab.closable && strip.on_close.is_some();
210 let right_pad = if closes { theme.spacing.s2 } else { theme.spacing.s3 };
211 let mut width = theme.spacing.s3 + right_pad + title_width;
212 if tab.icon.is_some() {
213 width += 14.0 + gap;
214 }
215 if tab.unsaved {
216 width += 7.0 + gap;
217 }
218 if closes {
219 width += ButtonSize::Mini.height() + gap;
220 }
221 if let Some(icon) = tab.icon {
222 parts.push(util::icon(cx, icon, 14.0, ink));
223 }
224 if editing {
225 let name: State<String> = cx.use_state(|| tab.title.clone());
226 name.set_if_changed(tab.title.clone());
227 let (rename, id) = (strip.on_rename.clone(), tab.id);
228 let field = InlineRename::new(name.binding())
229 .editing(self.editing.binding())
230 .label(labels.rename.clone())
231 .width(TITLE_MAX_WIDTH)
232 .on_rename(move |new| {
233 if let Some(rename) = &rename {
234 rename(id, new);
235 }
236 });
237 parts.push(cx.child(&field));
238 } else {
239 let _: State<String> = cx.use_state(|| tab.title.clone());
240 let title = cx.text_truncated(
241 &tab.title,
242 &attrs,
243 ink,
244 LayoutStyle { width: Some(title_width), min_width: Some(0.0), ..LayoutStyle::leaf() },
245 );
246 cx.semantics(title, Semantics::hidden());
247 parts.push(title);
248 }
249 if tab.unsaved {
250 parts.push(cx.child(&Dot::new().tone(Tone::Accent).size(7.0).label(labels.unsaved.clone())));
251 }
252 if closes {
253 let (close, id) = (strip.on_close.clone(), tab.id);
254 let button = IconButton::new(IconName::X)
255 .label(labels.close_tab.clone())
256 .size(ButtonSize::Mini)
257 .on_click(move || {
258 if let Some(close) = &close {
259 close(id);
260 }
261 });
262 parts.push(cx.child(&button));
263 }
264 if let (Some(color), Some(size)) = (tab.color, frame) {
265 parts.push(cx.container(
266 LayoutStyle {
267 absolute: Some((0.0, 0.0)),
268 width: Some(size.width),
269 height: Some(COLOR_BAND),
270 ..LayoutStyle::leaf()
271 },
272 &[],
273 Paint::Quad(QuadPaint::new(color)),
274 ));
275 }
276 if let (Some(gap), Some(size)) = (self.drag.get().and_then(|d| d.marked_gap()), frame) {
277 if gap == index {
278 parts.push(reorder::marker(cx, false, size.height, 0.0));
279 } else if gap == index + 1 && gap == strip.tabs.len() {
280 parts.push(reorder::marker(cx, false, size.height, size.width));
281 }
282 }
283 let fill = if on {
285 theme.color(Semantic::Surface)
286 } else if hovered {
287 theme.color(Semantic::Ink).with_alpha(0.05)
288 } else {
289 Color::TRANSPARENT
290 };
291 let radius = theme.radii.sm;
292 let node = cx.container(
293 LayoutStyle {
294 height: Some(TAB_HEIGHT),
295 width: Some(width),
296 min_width: Some(width),
297 spacing: gap,
298 padding: Insets { top: 0.0, bottom: 0.0, left: theme.spacing.s3, right: right_pad },
299 align: Align::Center,
300 ..LayoutStyle::stack(Axis::Horizontal)
301 },
302 &parts,
303 Paint::Quad(QuadPaint { corner_radius: radius, ..QuadPaint::new(fill) }),
304 );
305 cx.report_frame(node);
306 let (active, id) = (strip.active.clone(), tab.id);
307 cx.on_click(node, Rc::new(move || active.set(id)));
308 cx.cursor(node, CursorIcon::Pointer);
309 cx.focusable(node, radius);
310 cx.semantics(node, {
311 let semantics = Semantics::new(Role::Tab).name(tab.title.clone()).selected(on);
312 if tab.unsaved { semantics.description(labels.unsaved.clone()) } else { semantics }
313 });
314 let count = strip.tabs.len();
315 let apply: Rc<dyn Fn(usize, usize)> = {
316 let (handler, spoken, focus_to, words) =
317 (strip.on_reorder.clone(), self.spoken.clone(), self.roving.focus_to.clone(), labels.clone());
318 Rc::new(move |from, to| {
319 if let Some(handler) = &handler {
320 handler(from, to);
321 spoken.set(words.moved_to_position(to + 1, count));
322 focus_to.request(to);
323 }
324 })
325 };
326 if strip.on_reorder.is_some() && !editing {
327 cx.on_drag(
328 node,
329 reorder::drag_handler(
330 self.drag.clone(),
331 self.extents.clone(),
332 index,
333 0.0,
334 false,
335 apply.clone(),
336 ),
337 );
338 }
339 if strip.on_rename.is_some() {
340 let starts = self.editing.clone();
341 cx.on_double_click(node, Rc::new(move || starts.set(true)));
342 }
343 let (rename_key, close_key, called_off) =
344 (self.editing.clone(), strip.on_close.clone().filter(|_| closes), self.drag.clone());
345 let renames = strip.on_rename.is_some();
346 let before: crate::KeyHandler = Rc::new(move |key, modifiers| {
347 if *key == Key::Escape && called_off.peek().is_some() {
348 called_off.set(None);
349 return true;
350 }
351 if renames && is_f2(key) {
352 rename_key.set(true);
353 return true;
354 }
355 if *key == Key::Delete
356 && let Some(close) = &close_key
357 {
358 close(id);
359 return true;
360 }
361 if let Some(to) = reorder::key_target(key, modifiers, index, count, false) {
362 apply(index, to);
363 }
364 reorder::is_move_key(key, modifiers)
365 });
366 let active = strip.active.clone();
367 let ids: Rc<Vec<u64>> = Rc::new(strip.tabs.iter().map(|t| t.id).collect());
368 let opens: Rc<dyn Fn(usize)> = Rc::new(move |to| {
369 if let Some(id) = ids.get(to) {
370 active.set(*id);
371 }
372 });
373 self.roving.wire_with(cx, node, Some(opens), Some(before));
374 node
375 }
376}
377
378struct TabWithMenu<'a> {
380 strip: &'a DocumentTabs,
381 index: usize,
382 extents: Extents,
383 drag: State<Option<Reorder>>,
384 focus_to: FocusTarget,
385 spoken: State<String>,
386}
387
388impl View for TabWithMenu<'_> {
389 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
390 let labels = cx.labels();
391 let strip = self.strip;
392 let Some(tab) = strip.tabs.get(self.index) else { return cx.empty() };
393 let editing: State<bool> = cx.use_state(|| false);
394 let view = TabView {
395 strip,
396 tab,
397 index: self.index,
398 extents: self.extents.clone(),
399 drag: self.drag.clone(),
400 editing: editing.clone(),
401 roving: Roving { index: self.index, count: strip.tabs.len(), focus_to: self.focus_to.clone() },
402 spoken: self.spoken.clone(),
403 };
404 let mut menu = ContextMenu::new(view);
405 let mut any_above = false;
406 if let Some(items) = &strip.menu {
407 for item in items(tab.id) {
408 menu = menu.item(item);
409 any_above = true;
410 }
411 }
412 if any_above {
413 menu = menu.separator();
414 }
415 if strip.on_rename.is_some() {
416 menu = menu.item(MenuItem::new(labels.rename.clone()).on_select(move || editing.set(true)));
417 }
418 if let Some(close) = &strip.on_close {
419 let id = tab.id;
420 if tab.closable {
421 let close_one = close.clone();
422 menu = menu.item(MenuItem::new(labels.close_tab.clone()).on_select(move || close_one(id)));
423 }
424 let others: Vec<u64> =
425 strip.tabs.iter().filter(|t| t.id != id && t.closable).map(|t| t.id).collect();
426 if !others.is_empty() {
427 let close_all = close.clone();
428 menu = menu.item(MenuItem::new(labels.close_other_tabs.clone()).on_select(move || {
429 others.iter().for_each(|other| close_all(*other));
430 }));
431 }
432 let right: Vec<u64> =
433 strip.tabs.iter().skip(self.index + 1).filter(|t| t.closable).map(|t| t.id).collect();
434 if !right.is_empty() {
435 let close_all = close.clone();
436 menu = menu.item(MenuItem::new(labels.close_tabs_to_right.clone()).on_select(move || {
437 right.iter().for_each(|other| close_all(*other));
438 }));
439 }
440 }
441 menu.build(cx)
442 }
443}
444
445impl View for DocumentTabs {
446 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
447 let theme = *cx.theme();
448 let labels = cx.labels();
449 let drag: State<Option<Reorder>> = cx.use_state(|| None);
450 let extents: Extents = cx.use_state(|| Rc::new(RefCell::new(Vec::new()))).peek().clone();
451 extents.borrow_mut().resize(self.tabs.len(), 0.0);
452 let spoken: State<String> = cx.use_state(String::new);
453 let focus_to = util::roving_cell(cx, None);
454 let tabs: Vec<Keyed<TabWithMenu<'_>>> = self
455 .tabs
456 .iter()
457 .enumerate()
458 .map(|(index, tab)| {
459 Keyed::new(
460 tab.id,
461 TabWithMenu {
462 strip: self,
463 index,
464 extents: extents.clone(),
465 drag: drag.clone(),
466 focus_to: focus_to.clone(),
467 spoken: spoken.clone(),
468 },
469 )
470 })
471 .collect();
472 let strip = cx.child(&ScrollEdgeArrow::new(tabs).spacing(2.0).fade(self.fade).height(TAB_HEIGHT));
473 let strip = cx.container(
474 LayoutStyle {
475 grow: 1.0,
476 min_width: Some(0.0),
477 align: Align::Stretch,
478 ..LayoutStyle::stack(Axis::Vertical)
479 },
480 &[strip],
481 Paint::None,
482 );
483 let mut nodes = vec![strip];
484 if let Some(new) = self.on_new.clone() {
485 nodes.push(
486 cx.child(
487 &HStack::new((IconButton::new(IconName::Plus)
488 .label(labels.new_tab.clone())
489 .size(ButtonSize::Small)
490 .on_click(move || new()),))
491 .padding(Insets::symmetric(0.0, theme.spacing.s1)),
492 ),
493 );
494 }
495 let announcement = spoken.get();
496 nodes.push(reorder::announcer(cx, announcement));
497 let node = cx.container(
498 LayoutStyle {
499 min_width: Some(0.0),
500 align: Align::Center,
501 align_self: Some(Align::Stretch),
502 ..LayoutStyle::stack(Axis::Horizontal)
503 },
504 &nodes,
505 Paint::None,
506 );
507 let name = self.label.clone().unwrap_or_else(|| labels.document_tabs_name.clone());
508 cx.semantics(node, Semantics::new(Role::TabList).name(name));
509 node
510 }
511}