Compare commits

...
Author SHA1 Message Date
Claude e31d855d57 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/ui-egui/src/canvas.rs
#	crates/ui-egui/src/canvas/tabs_tests.rs
2026-10-09 00:23:44 +00:00
Claude 04678ee240 Document tabs: a drop on an overflowing strip opens at the right position
With tabs overflowing into the » menu, the tab strip kept only the shown
tabs' rectangles, so a file dropped on the strip opened at the shown
tab's count rather than its document's position. The strip now keeps
each shown tab's document index, and the drop slot and insertion line
use it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01UHgFEHEsefoQoBParH47JA
2026-10-08 18:55:07 +00:00
LordFarquaadtheCreator 97e6578de1 Document tabs: elide and overflow into a » menu when they are too many (#1276)
The document tab strip laid every tab out at its natural width from the strip's
left edge and never clamped it, so once the tabs were wider than the window the
last ones ran off the strip: only a sliver stayed visible, and picking it
showed the document without bringing its tab into view. The dock *panel* strips
solved the same problem in #151 (elide, then a » menu that keeps the selected
tab on the strip); the document strips never got wired to it.

Both document strips now go through `tab_strip::fit`:

- tabs that don't fit shrink — long titles elide with '…' and show the full
  title on hover — and the ones that still don't fit move into a » menu at the
  end of the strip;
- the active document's tab always stays on the strip, and activating a
  document from the menu brings its tab straight onto it;
- files still opening in the background (#210) share the same index space and
  the same menu;
- tabs that fit keep their exact natural widths and the strip shows no menu,
  as before.

The » button is now a shared `tab_strip::overflow_button`, used by the panel
strips too. "More documents" is translated in every catalog.

Tests: `canvas/tabs_tests.rs` drives the whole app with 14 open documents
(Pro and Studio themes) and asserts the tabs stay inside the strip, the menu
holds the rest, and the active document's tab is the one on the strip.
2026-10-08 18:53:06 +00:00
3 changed files with 34 additions and 12 deletions

No files matched your search

+14 -7
View File
@@ -1253,7 +1253,7 @@ fn tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
let cut = name_g.size().x + 0.5 < natural_w - STUDIO_TAB_PAD + STUDIO_TAB_GAP - meta_g.size().x;
let resp = ui.interact(r, ui.id().with(("dtab", i)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, &st.doc.name));
doc_tabs.push(r);
doc_tabs.push((i, r));
if sel {
ui.painter().rect_filled(r, t.radius_sm, t.card);
ui.painter().rect_stroke(r, t.radius_sm, Stroke::new(1.0, t.card_border), egui::StrokeKind::Inside);
@@ -1476,7 +1476,7 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
resp.context_menu(|ui| {
tab_action = tab_context_menu(ui, i, tab_count);
});
doc_tabs.push(r);
doc_tabs.push((i, r));
}
for &(i, r) in placed.iter().filter(|(i, _)| *i >= tab_count) {
let Some((job, _, frac)) = opening.get(i - tab_count) else { continue };
@@ -1535,14 +1535,19 @@ fn pro_tabs(app: &mut PhotocraftApp, ui: &mut egui::Ui) -> TabStrip {
#[derive(Clone, Debug, PartialEq)]
pub struct TabStrip {
pub rect: Rect,
/// The document tabs, left to right.
pub tabs: Vec<Rect>,
/// The document tabs shown, left to right, with their document index. When the tabs overflow
/// into the » menu, some documents have no tab here.
pub tabs: Vec<(usize, Rect)>,
}
impl TabStrip {
/// The tab position a drop at `x` opens at: before the first tab whose middle is right of it.
/// The document position a drop at `x` opens at: before the first shown tab whose middle is
/// right of it, else after the last shown tab.
pub fn slot(&self, x: f32) -> usize {
self.tabs.iter().filter(|r| r.center().x < x).count()
match self.tabs.iter().find(|(_, r)| r.center().x >= x) {
Some(&(i, _)) => i,
None => self.tabs.last().map_or(0, |&(i, _)| i.saturating_add(1)),
}
}
}
@@ -1554,7 +1559,9 @@ fn drop_slot_line(app: &mut PhotocraftApp, ui: &egui::Ui) {
let at = app.services.cursor_pos.as_mut().and_then(|f| f(ui.ctx()));
let crate::file_open::DropTarget::Tabs(slot) = app.drop_target(ui.ctx(), at) else { return };
let Some(tabs) = app.tab_strip.as_ref().map(|s| &s.tabs) else { return };
let Some((r, after)) = tabs.get(slot).map(|r| (*r, false)).or_else(|| tabs.last().map(|r| (*r, true))) else { return };
let Some((r, after)) = tabs.iter().find(|(i, _)| *i == slot).map(|&(_, r)| (r, false)).or_else(|| tabs.last().map(|&(_, r)| (r, true))) else {
return;
};
crate::widgets::drop_line(ui, r, after, true, &crate::theme::Tokens::get(ui.ctx()));
}
+2 -2
View File
@@ -41,7 +41,7 @@ fn overflowing_tabs_stay_on_the_strip_and_the_rest_move_to_the_menu() {
assert_eq!(h.state().session.documents().len(), 14);
let s = strip(&h);
assert!(s.tabs.len() < 14, "some tabs must overflow into the menu: {} shown", s.tabs.len());
for r in &s.tabs {
for (_, r) in &s.tabs {
assert!(s.rect.contains_rect(*r), "a tab runs past the strip: {r:?} in {:?}", s.rect);
}
assert!(has_menu(&h), "the strip carries a » menu while tabs are hidden");
@@ -78,7 +78,7 @@ fn studio_tabs_overflow_into_the_same_menu() {
let h = harness(14, ThemeKind::Studio);
let s = strip(&h);
assert!(s.tabs.len() < 14, "{} shown", s.tabs.len());
for r in &s.tabs {
for (_, r) in &s.tabs {
assert!(s.rect.contains_rect(*r), "a tab runs past the strip: {r:?} in {:?}", s.rect);
}
assert!(has_menu(&h), "the Studio strip carries a » menu too");
+18 -3
View File
@@ -441,7 +441,7 @@ fn files_dropped_on_the_tabs_open_at_that_slot() {
// One tab at (100, 20)–(200, 46): its left half is slot 0, its right half and beyond slot 1.
let tab = egui::Rect::from_min_max(egui::pos2(100.0, 20.0), egui::pos2(200.0, 46.0));
let strip = egui::Rect::from_min_max(egui::pos2(92.0, 14.0), egui::pos2(700.0, 50.0));
app.tab_strip = Some(crate::canvas::TabStrip { rect: strip, tabs: vec![tab] });
app.tab_strip = Some(crate::canvas::TabStrip { rect: strip, tabs: vec![(0, tab)] });
assert_eq!(app.drop_target(&ctx, Some(egui::pos2(120.0, 30.0))), crate::file_open::DropTarget::Tabs(0));
assert_eq!(app.drop_target(&ctx, Some(egui::pos2(180.0, 30.0))), crate::file_open::DropTarget::Tabs(1));
assert_eq!(app.drop_target(&ctx, Some(egui::pos2(600.0, 30.0))), crate::file_open::DropTarget::Tabs(1));
@@ -452,12 +452,27 @@ fn files_dropped_on_the_tabs_open_at_that_slot() {
assert_eq!(app.ui.views.len(), 3);
assert_eq!(app.ui.views[2].zoom, 3.0, "views move with their documents");
// Past the last tab: opened at the end.
let tabs = (0..3).map(|i| tab.translate(egui::vec2(104.0 * i as f32, 0.0))).collect();
let tabs = (0..3).map(|i| (i, tab.translate(egui::vec2(104.0 * i as f32, 0.0)))).collect();
app.tab_strip = Some(crate::canvas::TabStrip { rect: strip, tabs });
app.open_dropped(&ctx, vec![dropped("last.png", Ok(b"x".to_vec()))], Some(egui::pos2(600.0, 30.0)));
assert_eq!(app.session.documents().last().map(|d| d.doc.name.as_str()), Some("last.png"));
}
#[test]
fn a_drop_slot_counts_documents_not_the_tabs_shown() {
// #1276: when the tabs overflow into the » menu, the shown tabs aren't documents 0, 1, 2…
// Documents 4, 5 and 6 shown: a drop before document 5's tab opens at position 5.
let tab = |i: usize, x: f32| (i, egui::Rect::from_min_max(egui::pos2(x, 20.0), egui::pos2(x + 100.0, 46.0)));
let strip = crate::canvas::TabStrip {
rect: egui::Rect::from_min_max(egui::pos2(0.0, 14.0), egui::pos2(700.0, 50.0)),
tabs: vec![tab(4, 0.0), tab(5, 104.0), tab(6, 208.0)],
};
assert_eq!(strip.slot(20.0), 4);
assert_eq!(strip.slot(130.0), 5);
assert_eq!(strip.slot(500.0), 7, "past the last shown tab: after it");
assert_eq!(crate::canvas::TabStrip { rect: strip.rect, tabs: vec![] }.slot(10.0), 0);
}
/// The whole app (eframe harness, real layout): over the tab strip, the pointer read from the OS
/// service picks a slot; files dropped there open (in the background) at it; dropped on the canvas they're placed in Free Transform, and Esc takes the place back.
#[test]
@@ -483,7 +498,7 @@ fn drag_and_drop_in_the_running_app() {
let strip = h.state().tab_strip.clone().expect("the tab strip is drawn");
assert_eq!(strip.tabs.len(), 2);
// Hovering over the first tab's left half: slot 0.
let over_first = egui::pos2(strip.tabs[0].left() + 4.0, strip.tabs[0].center().y);
let over_first = egui::pos2(strip.tabs[0].1.left() + 4.0, strip.tabs[0].1.center().y);
pointer.set(over_first);
h.input_mut().hovered_files.push(egui::HoveredFile { path: Some(file.clone()), ..Default::default() });
h.step();