diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 40ce78b9b..6cbd3475d 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -527,69 +527,129 @@ pub enum DocAction { Close(u64), } -const DOC_PAD: f32 = 8.0; -const DOC_SWATCH: f32 = 14.0; +/// Room around a tab's label, as craft-ui gives its dock tabs. +pub const TAB_PADDING: f32 = 24.0; +/// Width of the » button that lists the comp tabs with no room in the strip. +pub const DOC_OVERFLOW: f32 = 22.0; +/// Room for the × after the shown comp's name. const DOC_CLOSE: f32 = 16.0; -fn doc_width(ui: &egui::Ui, doc: &DocTab) -> f32 { - let label = ui.painter().layout_no_wrap(doc.title.clone(), Tokens::ui(12.0), egui::Color32::WHITE).size().x; - DOC_PAD + DOC_SWATCH + label + if doc.active { DOC_CLOSE } else { 0.0 } + DOC_PAD +/// A comp tab's text: the label swatch, then the name, as the panel tabs with a swatch show it. +fn doc_job(doc: &DocTab, font: &egui::FontId, color: egui::Color32) -> egui::text::LayoutJob { + let mut job = egui::text::LayoutJob::default(); + job.append("\u{25a0} ", 0.0, egui::TextFormat::simple(font.clone(), doc.swatch)); + job.append(&doc.title, 0.0, egui::TextFormat::simple(font.clone(), color)); + job } -/// The width the comp tabs ask for in the Timeline's tab strip. -pub fn doc_tabs_width(ui: &egui::Ui, docs: &[DocTab]) -> f32 { - docs.iter().map(|d| doc_width(ui, d)).sum() +/// The width of one line of `job` at the fonts' current size. +pub fn text_width(ctx: &egui::Context, job: egui::text::LayoutJob) -> f32 { + ctx.fonts_mut(|f| f.layout_job(job)).size().x } -/// Draw the Timeline's comp tabs into `strip` (in its group header): the comp's label swatch, its -/// name, and × on the shown one. Tabs past the strip's end are left out; Shift+, and Shift+. step -/// through every open comp. Automation: `panel.tab.Timeline.`, and `panel.tab.Timeline.close` -/// for the shown comp's ×. -pub fn doc_tabs(ui: &mut egui::Ui, strip: Rect, docs: &[DocTab], t: &Tokens, reg: &mut crate::automation::Registry) -> Vec { +/// Whether comp tab `doc` carries the ×: the comp the Timeline shows, while it is the group's +/// shown panel. +fn has_close(doc: &DocTab, selected: bool) -> bool { + selected && doc.active +} + +/// The width each comp tab asks for in the Timeline group's tab row. +pub fn doc_widths(ctx: &egui::Context, docs: &[DocTab], selected: bool, font: &egui::FontId) -> Vec { + docs.iter().map(|d| text_width(ctx, doc_job(d, font, egui::Color32::WHITE)) + TAB_PADDING + if has_close(d, selected) { DOC_CLOSE } else { 0.0 }).collect() +} + +/// Draw the Timeline's comp tabs into `strip`, the part of the Timeline group's tab row after its +/// panel tabs. Each is a tab like the panel tabs: the comp's label swatch and name, and × on the +/// shown comp while the Timeline is the group's shown panel (`selected`). Tabs that do not fit go +/// to a » menu at the end; the shown comp's tab always stays in the row. Shift+, and Shift+. step +/// through every open comp. Automation: `panel.tab.Timeline.`, `panel.tab.Timeline.close` +/// for the shown comp's ×, `panel.tab.Timeline.more` for », and `panel.tab.Timeline.more.` +/// for its items. +pub fn doc_tabs( + ui: &mut egui::Ui, + strip: Rect, + docs: &[DocTab], + selected: bool, + style: &craft_ui::docking::DockStyle, + reg: &mut crate::automation::Registry, +) -> Vec { let mut out = Vec::new(); - let painter = ui.painter().clone(); - let mut x = strip.min.x; + let timeline = PanelKind::Timeline.id(); + let widths = doc_widths(ui.ctx(), docs, selected, &style.font); + let active = docs.iter().position(|d| d.active).unwrap_or(0); + let fit = craft_ui::tabs::fit_whole(&widths, active, strip.width(), DOC_OVERFLOW); + let clip = strip.intersect(ui.clip_rect()); let mid = strip.center().y; - for doc in docs { - let w = doc_width(ui, doc); - if x + w > strip.max.x { - break; - } - let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(w, strip.height())); - let resp = ui.interact(tab, egui::Id::unique(("comp-tab", doc.id)), Sense::click()); - reg.add_response(&format!("panel.tab.{}.{}", PanelKind::Timeline.id(), doc.id), &resp, &doc.title); - if doc.active { - painter.rect_filled(tab, 0.0, t.panel_bg); - } - let sw = Rect::from_center_size(pos2(tab.min.x + DOC_PAD + 5.0, mid), vec2(9.0, 9.0)); - painter.rect_filled(sw, 1.0, doc.swatch); - let color = if doc.active || resp.hovered() { t.tab_text_active } else { t.tab_text }; - let galley = painter.layout_no_wrap(doc.title.clone(), Tokens::ui(12.0), color); - let label_x = tab.min.x + DOC_PAD + DOC_SWATCH; - let label_w = galley.size().x; - painter.galley(pos2(label_x, mid - galley.size().y / 2.0), galley, color); - if doc.active { - painter.line_segment([pos2(label_x, strip.max.y - 3.0), pos2(label_x + label_w, strip.max.y - 3.0)], Stroke::new(1.0, t.tab_text_active)); - let cr = Rect::from_center_size(pos2(label_x + label_w + DOC_CLOSE / 2.0 + 2.0, mid), vec2(10.0, 10.0)); - let close = ui.interact(cr.expand(2.0), egui::Id::unique(("comp-tab-close", doc.id)), Sense::click()); - let cc = if close.hovered() { t.tab_text_active } else { t.tab_text }; + let mut x = strip.min.x; + for (index, width) in &fit.shown { + let Some(doc) = docs.get(*index) else { continue }; + let tab = Rect::from_min_size(pos2(x, strip.min.y), vec2(*width, strip.height())); + x += width; + let on = selected && doc.active; + let color = if on { style.text } else { style.inactive_text }; + let close = has_close(doc, selected); + let mut job = doc_job(doc, &style.font, color); + job.wrap = egui::text::TextWrapping { + max_width: (tab.width() - TAB_PADDING - if close { DOC_CLOSE } else { 0.0 }).max(0.0), + max_rows: 1, + break_anywhere: true, + overflow_character: Some('\u{2026}'), + }; + let galley = ui.fonts_mut(|f| f.layout_job(job)); + let label_end = tab.min.x + TAB_PADDING * 0.5 + galley.size().x; + let resp = craft_ui::tabs::Tab::new(egui::Id::unique(("comp-tab", doc.id)), &doc.title, on).focus_stroke(Stroke::new(1.0, style.accent)).show_at( + ui, + tab, + |ui, response| { + let painter = ui.painter().with_clip_rect(tab.intersect(clip)); + painter.rect_filled(tab, 0.0, if on || response.hovered() { style.active_background } else { style.tab_background }); + painter.galley(pos2(tab.min.x + TAB_PADDING * 0.5, mid - galley.size().y * 0.5), galley.clone(), color); + }, + ); + let resp = if galley.elided { resp.on_hover_text(&doc.title) } else { resp }; + reg.add_response(&format!("panel.tab.{timeline}.{}", doc.id), &resp, &doc.title); + if close { + let cr = Rect::from_center_size(pos2(label_end + DOC_CLOSE / 2.0, mid), vec2(10.0, 10.0)); + let hit = ui.interact(cr.expand(2.0), egui::Id::unique(("comp-tab-close", doc.id)), Sense::click()); + hit.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, hit.enabled(), "Close")); + let cc = if hit.hovered() { style.text } else { style.inactive_text }; let k = 3.5; + let painter = ui.painter().with_clip_rect(tab.intersect(clip)); painter.line_segment([cr.center() + vec2(-k, -k), cr.center() + vec2(k, k)], Stroke::new(1.2, cc)); painter.line_segment([cr.center() + vec2(-k, k), cr.center() + vec2(k, -k)], Stroke::new(1.2, cc)); - reg.add_response(&format!("panel.tab.{}.close", PanelKind::Timeline.id()), &close, "Close"); - crate::widgets::focus_outline(ui, &close, t); - if close.clicked() { + reg.add_response(&format!("panel.tab.{timeline}.close"), &hit, "Close"); + if hit.clicked() { out.push(DocAction::Close(doc.id)); } } - crate::widgets::focus_outline(ui, &resp, t); if resp.clicked() { out.push(DocAction::Activate(doc.id)); } if resp.middle_clicked() { out.push(DocAction::Close(doc.id)); } - x += w; + } + if !fit.overflow.is_empty() && fit.overflow_width > 0.0 { + let more = Rect::from_min_size(pos2(x, strip.min.y), vec2(fit.overflow_width, strip.height())); + let resp = ui.interact(more, egui::Id::unique("comp-tabs-more"), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, resp.enabled(), "More compositions")); + reg.add_response(&format!("panel.tab.{timeline}.more"), &resp, "More compositions"); + let color = if resp.hovered() { style.text } else { style.inactive_text }; + ui.painter().with_clip_rect(more.intersect(clip)).text(more.center(), Align2::CENTER_CENTER, "\u{bb}", style.font.clone(), color); + egui::Popup::menu(&resp).show(|ui| { + egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { + for index in &fit.overflow { + let Some(doc) = docs.get(*index) else { continue }; + let item = ui.button(doc_job(doc, &style.font, style.text)); + item.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, item.enabled(), &doc.title)); + reg.add_response(&format!("panel.tab.{timeline}.more.{}", doc.id), &item, &doc.title); + if item.clicked() { + out.push(DocAction::Activate(doc.id)); + ui.close(); + } + } + }); + }); } out } diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs index 0381c83ea..8089211f9 100644 --- a/crates/ui-egui/src/dock_ui.rs +++ b/crates/ui-egui/src/dock_ui.rs @@ -436,13 +436,13 @@ impl EffectcraftApp { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("effectcraft-dock-area")); child.set_clip_rect(body.intersect(ui.clip_rect())); let docs = self.tab_docs(); - let doc_width = dock::doc_tabs_width(ui, &docs); let ime = NativeIme::new(&layout.floating, &self.unpublished_viewports); let mut content = EffectContent { titles: self.tab_titles(), swatches: self.tab_swatches(), docs, - doc_width, + ctx: ctx.clone(), + style: style.clone(), app: self, tokens: t, doc_actions: Vec::new(), @@ -632,8 +632,9 @@ struct EffectContent<'a> { titles: Vec<(PanelKind, String)>, swatches: Vec<(PanelKind, egui::Color32)>, docs: Vec, - /// The width the comp tabs take in the Timeline's tab strip. - doc_width: f32, + /// Measures tab labels for the group header's width. + ctx: egui::Context, + style: DockStyle, doc_actions: Vec, /// Viewer locks clicked. locks: Vec, @@ -748,10 +749,37 @@ fn window_bodies(window: &Floating) -> usize { } impl EffectContent<'_> { - fn header_width(&self, active: PanelKind, strip_width: f32) -> f32 { - let lock = if has_lock(active) { LOCK_W } else { 0.0 }; - let docs = if active == PanelKind::Timeline && !self.docs.is_empty() { self.doc_width } else { 0.0 }; - (docs + lock + MENU_W).min(strip_width * 2.0 / 3.0).max(MENU_W) + /// The header holds the shown panel's lock and ≡ at the strip's end and, in the Timeline's + /// group, the comp tabs before them. craft-ui puts the header after the group's drag bar, so + /// the comp tabs follow the panel tabs only when the header takes all the room the panel tabs + /// and the bar leave. The panel tabs keep their full width; comps without room go to ». + fn header_width(&self, panels: &[PanelKind], active: PanelKind, strip_width: f32) -> f32 { + let tools = if has_lock(active) { LOCK_W } else { 0.0 } + MENU_W; + if self.docs.is_empty() || !panels.contains(&PanelKind::Timeline) { + return tools.min(strip_width * 2.0 / 3.0).max(MENU_W); + } + let tabs: f32 = panels.iter().map(|p| self.tab_width(p)).sum(); + // Half a point keeps rounding from shrinking the panel tabs. + let room = strip_width - tabs - self.style.drag_bar_width - 0.5; + room.max(tools + dock::DOC_OVERFLOW).min(strip_width).max(0.0) + } + + /// A panel tab's width as craft-ui lays it out: its label and the tab padding. + fn tab_width(&self, panel: &PanelKind) -> f32 { + let logical = self.logical_label(panel); + let (font, color) = (&self.style.font, self.style.text); + dock::text_width(&self.ctx, self.tab_job(panel, &logical, font, color)) + dock::TAB_PADDING + } + + /// A panel tab's text: what the panel shows, after the label swatch of its comp or layer. + fn tab_job(&self, panel: &PanelKind, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::text::LayoutJob { + let title = self.titles.iter().find(|(p, _)| p == panel).map_or(logical, |(_, s)| s.as_str()); + let mut job = egui::text::LayoutJob::default(); + if let Some((_, swatch)) = self.swatches.iter().find(|(p, _)| p == panel) { + job.append("■ ", 0.0, egui::TextFormat::simple(font.clone(), *swatch)); + } + job.append(title, 0.0, egui::TextFormat::simple(font.clone(), color)); + job } } @@ -785,23 +813,22 @@ impl DockContent for EffectContent<'_> { } /// The tab names what the panel shows ("Composition Intro"), after the label swatch of its - /// comp or layer. With comps open, the Timeline's own tab is just "Timeline"; its comp tabs - /// are in the group header. + /// comp or layer. With comps open, the Timeline's own tab is just "Timeline"; each comp has a + /// tab of its own after the group's panel tabs. fn label(&mut self, _ui: &egui::Ui, panel: &PanelKind, logical: &str, font: &egui::FontId, color: egui::Color32) -> egui::WidgetText { - let title = self.titles.iter().find(|(p, _)| p == panel).map_or(logical, |(_, s)| s.as_str()); - let Some((_, swatch)) = self.swatches.iter().find(|(p, _)| p == panel) else { return title.into() }; - let mut job = egui::text::LayoutJob::default(); - job.append("■ ", 0.0, egui::TextFormat::simple(font.clone(), *swatch)); - job.append(title, 0.0, egui::TextFormat::simple(font.clone(), color)); - job.into() + if self.swatches.iter().any(|(p, _)| p == panel) { + return self.tab_job(panel, logical, font, color).into(); + } + self.titles.iter().find(|(p, _)| p == panel).map_or(logical, |(_, s)| s.as_str()).into() } - fn group_header_width(&self, _panels: &[PanelKind], active: &PanelKind, _floating: bool, strip_width: f32) -> f32 { - self.header_width(*active, strip_width) + fn group_header_width(&self, panels: &[PanelKind], active: &PanelKind, _floating: bool, strip_width: f32) -> f32 { + self.header_width(panels, *active, strip_width) } - /// The Timeline's comp tabs, the viewer lock and the ≡ panel menu of the shown panel. - fn group_header(&mut self, ui: &mut egui::Ui, _panels: &[PanelKind], active: &PanelKind, _floating: bool) { + /// The Timeline's comp tabs (in its group, whichever panel is shown), then the viewer lock + /// and the ≡ panel menu of the shown panel. + fn group_header(&mut self, ui: &mut egui::Ui, panels: &[PanelKind], active: &PanelKind, _floating: bool) { let t = self.tokens; let header = ui.max_rect(); let menu = Rect::from_min_max(pos2((header.max.x - MENU_W).max(header.min.x), header.min.y), header.max); @@ -814,9 +841,10 @@ impl DockContent for EffectContent<'_> { self.locks.push(*active); } } - if *active == PanelKind::Timeline && !self.docs.is_empty() { + if panels.contains(&PanelKind::Timeline) && !self.docs.is_empty() { let strip = Rect::from_min_max(header.min, pos2(end.max(header.min.x), header.max.y)); - self.doc_actions.extend(dock::doc_tabs(ui, strip, &self.docs, &t, &mut self.app.auto)); + let selected = *active == PanelKind::Timeline; + self.doc_actions.extend(dock::doc_tabs(ui, strip, &self.docs, selected, &self.style, &mut self.app.auto)); } let rect = Rect::from_center_size(menu.center(), vec2(18.0, 18.0)); let response = ui.interact(rect, egui::Id::unique(("panel-menu-button", active.id())), Sense::click()); diff --git a/crates/ui-egui/tests/it/ui_tabs.rs b/crates/ui-egui/tests/it/ui_tabs.rs index 52d3258e1..65b429f44 100644 --- a/crates/ui-egui/tests/it/ui_tabs.rs +++ b/crates/ui-egui/tests/it/ui_tabs.rs @@ -424,3 +424,45 @@ fn a_tab_dropped_on_a_frame_joins_it() { fn group_of(w: &Workspace, p: PanelKind) -> Option> { Some(w.group_of(p)).filter(|g| !g.is_empty()) } + +fn has(h: &Harness<'_, EffectcraftApp>, id: &str) -> bool { + h.state().auto.previous.iter().chain(h.state().auto.elements.iter()).any(|e| e.id == id) +} + +#[test] +fn open_comps_are_tabs_in_the_timeline_row_and_the_rest_are_in_its_overflow_menu() { + let mut s = Session::default(); + let names = ["Opening Titles", "Lower Third Left", "Lower Third Right", "Product Shot", "End Card Wide", "Logo Sting Short"]; + let comps: Vec = names + .iter() + .map(|name| s.execute("comp.new", json!({"name": name, "width": 320, "height": 180, "duration": 4})).unwrap()["comp"].as_u64().unwrap()) + .collect(); + s.state.open_comps = vec![ItemId(comps[0])]; + s.state.active_comp = Some(ItemId(comps[0])); + let mut h = Harness::builder().with_size(egui::vec2(1000.0, 800.0)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let alone = rect(&h, "panel.tab.RenderQueue"); + h.state_mut().session.state.open_comps = comps.iter().map(|c| ItemId(*c)).collect(); + h.run_steps(3); + // The panel tabs keep their width; the comp tabs follow them in the same row, in order. + let (tl, rq) = (rect(&h, "panel.tab.Timeline"), rect(&h, "panel.tab.RenderQueue")); + assert!((rq.width() - alone.width()).abs() < 0.5, "Render Queue {rq:?} was {alone:?}"); + let shown: Vec = comps.iter().copied().filter(|c| has(&h, &format!("panel.tab.Timeline.{c}"))).collect(); + assert!(shown.contains(&comps[0]) && shown.len() < comps.len(), "{shown:?}"); + let mut x = rq.max.x; + for c in &shown { + let tab = rect(&h, &format!("panel.tab.Timeline.{c}")); + assert!(tl.y_range().contains(tab.center().y) && tab.min.x >= x - 0.5, "comp {c} at {tab:?} after x {x}"); + x = tab.max.x; + } + // A comp with no room is in the » menu; picking it shows it, and its tab joins the row. + let hidden = comps.iter().copied().find(|c| !shown.contains(c)).unwrap(); + let more = rect(&h, "panel.tab.Timeline.more"); + assert!(more.min.x >= x - 0.5 && tl.y_range().contains(more.center().y), "{more:?}"); + click_n(&mut h, more.center(), 1); + let item = rect(&h, &format!("panel.tab.Timeline.more.{hidden}")); + click_n(&mut h, item.center(), 1); + assert_eq!(h.state().session.state.active_comp, Some(ItemId(hidden))); + assert_eq!(open_comps(&h), comps); + assert!(has(&h, &format!("panel.tab.Timeline.{hidden}"))); +}