From 1c7fbfd503c7318d41cb8c27f0d840741f0536f9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 02:23:51 +0000 Subject: [PATCH] Timeline: each open comp is a tab in the group's tab row, with an overflow menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comp tabs follow the panel tabs and are drawn like them (craft-ui's tab widget and the dock style's font and colours). The panel tabs keep their full width; comps without room are listed in a » menu at the end of the row, and picking one shows it. The shown comp's tab always stays in the row, and the comp tabs stay while another panel of the group is shown. --- crates/ui-egui/src/dock.rs | 146 ++++++++++++++++++++--------- crates/ui-egui/src/dock_ui.rs | 72 +++++++++----- crates/ui-egui/tests/it/ui_tabs.rs | 42 +++++++++ 3 files changed, 195 insertions(+), 65 deletions(-) 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}"))); +}