diff --git a/arc/platforms.md b/arc/platforms.md
index fbc4f26f3ab0e80c58a99eb76cd28a346ece5e1b..c85198ec19f76fce307aeddfdf116f84c7e65945 100644
--- a/arc/platforms.md
+++ b/arc/platforms.md
@@ -31,9 +31,12 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
### macOS
- The toolbar's row is the title bar. An empty `NSToolbar`, unified compact
- from macOS 11, with the title hidden makes the title bar the row's height,
- and AppKit places the traffic lights in it; a press in the row's gaps drags
- the window. The title is still set, for the Window menu, Mission Control and
+ from macOS 11, with the title hidden makes the title bar 38 pt, and AppKit
+ centres the traffic lights in it beside the row's buttons; the row ends 4 pt
+ short of it, so the tabs stand as near the buttons as a tab bar would. A
+ press in the row's empty space drags the window and a double press zooms or
+ minimizes it, as Desktop & Dock says; presses in a group of buttons do
+ neither, so AppKit is told the view never moves the window. The title is still set, for the Window menu, Mission Control and
VoiceOver. `--screenshot` paints the lights where the hidden window's AppKit
put them. The app draws the row as part of the same frame as the rest of the
chrome. Under the whole
diff --git a/arc/ui.md b/arc/ui.md
index c336e10f3dc702fa9b3b6b4175e5be26bec6abbf..2969a6b60ec8904d75e821587f7d0ca8e92be516 100644
--- a/arc/ui.md
+++ b/arc/ui.md
@@ -81,7 +81,8 @@ frame N
pixels, by its label, as a fraction of an ancestor, or by its children. When
siblings overflow, space sized from an ancestor gives way first. Then a row
*folds* its groups, boxes with a full and a folded form (`Spec::fold`), by
- priority. Only then do boxes give up room by their *strictness*, the least
+ priority, reaching into its boxes sized by their children, so a group can
+ fold inside another. Only then do boxes give up room by their *strictness*, the least
strict first. That one knob covers most of what flexbox is usually needed
for. A box filling across a parent sized by its children stretches to what
its siblings make it, and a popup that isn't strict gives way to the window,
diff --git a/crates/snowbound/assets/icons/styles.svg b/crates/snowbound/assets/icons/styles.svg
index e272c36985720fd9760a08ba8a0403e2063c4a66..b2a467e87ff8b4e25829dd354846b97d8866e07a 100644
--- a/crates/snowbound/assets/icons/styles.svg
+++ b/crates/snowbound/assets/icons/styles.svg
@@ -1,8 +1,4 @@
diff --git a/crates/snowbound/src/aqua.rs b/crates/snowbound/src/aqua.rs
index b168d63705560d4cc1e60df9e994eafd66d1231d..398be5fde606b9af2d36e26e59f663b3d44426da 100644
--- a/crates/snowbound/src/aqua.rs
+++ b/crates/snowbound/src/aqua.rs
@@ -77,7 +77,7 @@ pub fn cover_border_line(ui: &mut ui::Ui, width: f32) {
ui::Spec {
flags: ui::Flags::FLOAT,
size: [ui::px(width), ui::px(1.0)],
- position: [0.0, crate::TITLE + crate::TAB_ROW - 1.0],
+ position: [0.0, crate::TOOLBAR + crate::TAB_ROW - 1.0],
fill: Some(draw::srgb(grey, grey, grey)),
..ui::Spec::default()
},
@@ -85,7 +85,8 @@ pub fn cover_border_line(ui: &mut ui::Ui, width: f32) {
}
/// `-mouseDownCanMoveWindow` for the content view: a textured window drags from any press
-/// on a view that allows it, asking once. The app drags from its toolbar's empty space
+/// on a view that allows it, and a title bar the view lies under drags and zooms from it,
+/// buttons drawn there included. The app drags and zooms from its toolbar's empty space
/// itself, through `-performWindowDragWithEvent:`.
pub extern "C" fn no_window_drags(_: &AnyObject, _: Sel) -> Bool {
Bool::NO
diff --git a/crates/snowbound/src/macos.rs b/crates/snowbound/src/macos.rs
index e4132cb98efad68d3087947c0a74bbbc05915230..b5f28af68cea96f891da8e5f5508d69ddc7034fd 100644
--- a/crates/snowbound/src/macos.rs
+++ b/crates/snowbound/src/macos.rs
@@ -459,7 +459,7 @@ pub fn install_backdrop(window: &Window) -> bool {
// Before 10.10 AppKit has no materials; 10.6's textured window stands in, its gradient
// running through the toolbar and the tab row to the notebook's frame.
let Some(effect) = AnyClass::get("NSVisualEffectView") else {
- return crate::aqua::textured(window, crate::TITLE + crate::TAB_ROW);
+ return crate::aqua::textured(window, crate::TOOLBAR + crate::TAB_ROW);
};
let view = &*ns_view(window);
unsafe {
@@ -1068,15 +1068,27 @@ pub fn set_min_size(window: &Window, size: [f32; 2]) {
window.set_min_inner_size(Some(winit::dpi::LogicalSize::new(size[0], size[1])));
}
-/// Zooms the window once the current event is handled: AppKit's zoom animation runs its
-/// own loop, and started from inside winit's handler it would hold every resize until the
-/// end, stretching the last frame instead of drawing each step.
+/// Zooms or minimizes the window, as a double click on a title bar does, once the current
+/// event is handled: AppKit's zoom animation runs its own loop, and started from inside
+/// winit's handler it would hold every resize until the end, stretching the last frame
+/// instead of drawing each step.
pub fn zoom(window: &Window) {
let window = ns_window(window);
+ // What a double click on a title bar does, as Desktop & Dock's setting says.
+ let action: Option> = unsafe {
+ let defaults: Retained =
+ msg_send_id![class!(NSUserDefaults), standardUserDefaults];
+ msg_send_id![&defaults, stringForKey: &*NSString::from_str("AppleActionOnDoubleClick")]
+ };
+ let selector = match action.map(|action| action.to_string()).as_deref() {
+ Some("None") => return,
+ Some("Minimize") => sel!(miniaturize:),
+ _ => sel!(zoom:),
+ };
unsafe {
let _: () = msg_send![
&window,
- performSelector: sel!(zoom:),
+ performSelector: selector,
withObject: std::ptr::null::(),
afterDelay: 0.0f64
];
@@ -1096,12 +1108,10 @@ pub fn install_text_input(window: &Window) {
sel!(insertText:replacementRange:),
insert_text as unsafe extern "C" fn(_, _, _, _),
);
- if crate::aqua::before_lion() {
- class.add_method(
- sel!(mouseDownCanMoveWindow),
- crate::aqua::no_window_drags as extern "C" fn(_, _) -> _,
- );
- }
+ class.add_method(
+ sel!(mouseDownCanMoveWindow),
+ crate::aqua::no_window_drags as extern "C" fn(_, _) -> _,
+ );
class.register()
});
assert_eq!(class.superclass(), Some(view.class()));
diff --git a/crates/snowbound/src/main.rs b/crates/snowbound/src/main.rs
index 5de8217f40978ccbef2ae5ea47aafd41d16d5c31..9aebf0d717d3552bb12c4f7dbafb5852e298beaa 100644
--- a/crates/snowbound/src/main.rs
+++ b/crates/snowbound/src/main.rs
@@ -134,9 +134,13 @@ use winit::{
window::CursorIcon,
};
-/// Height of the toolbar's row, which is the title bar where the platform lets it: a
-/// unified compact toolbar's on macOS.
+/// Height of the title bar the toolbar's buttons centre in: a unified compact toolbar's on
+/// macOS, whose traffic lights they line up with.
const TITLE: f32 = 38.0;
+/// Height of the toolbar's row, which is the title bar where the platform lets it. Its foot
+/// is short of the title bar's, so the tabs below stand as near its buttons as a tab bar
+/// under a compact toolbar does.
+const TOOLBAR: f32 = TITLE - 4.0;
/// Space between toolbar groups.
const GAP: f32 = 6.0;
/// A tool face's room beside its buttons.
@@ -898,6 +902,8 @@ struct State {
strip_press: Option,
/// The strip is held and the window moves once the pointer does.
strip_held: bool,
+ /// The zoom typed in the zoom's field while it is open.
+ zoom_typed: Option,
/// Where a replay asked the next frame to be written.
snapshot: Option,
/// A replay waiting for nothing to be on its way, and where it wants the accessibility
@@ -1247,6 +1253,7 @@ impl State {
pointer: [0.0; 2],
strip_press: None,
strip_held: false,
+ zoom_typed: None,
snapshot: None,
replay_settle: None,
frames: std::env::var_os("SNOWBOUND_FRAMES").map(|dir| (dir.into(), Instant::now())),
@@ -1514,7 +1521,7 @@ impl State {
if !self.navigation_bar_right {
self.sidebar(&theme, sidebar);
}
- // Where the notebook button floats over the tab row while the sidebar is shut.
+ // Where the buttons float over the tab row while the sidebar is shut.
let beside = 1.0 - sidebar / sidebar::WIDTH;
self.ui.open(
"main",
@@ -1544,14 +1551,19 @@ impl State {
);
if !self.temporary {
// Past the row's padding, the frame's corner and the first tab's shadow, the tabs'
- // outlines start where the button's square ends; the room shrinks on the sidebar's
- // easing, so the tabs ease with it.
- let room = TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0];
- let room = if self.navigation_bar_right { 0.0 } else { room };
+ // outlines start where Back and Forward end, and on the left the notebook button's
+ // square after them; that room shrinks on the sidebar's easing, as they move into
+ // its header, so the tabs ease with it.
+ let room = sidebar::NAV + TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0];
+ let room = if self.navigation_bar_right {
+ room - TAB_ROW
+ } else {
+ room * beside
+ };
self.ui.leaf(
"rail",
Spec {
- size: [px(room * beside), px(1.0)],
+ size: [px(room), px(1.0)],
..Spec::default()
},
);
@@ -1675,6 +1687,7 @@ impl State {
#[cfg(feature = "live")]
self.avatars();
if self.session.is_some() {
+ self.zoom(&theme)?;
self.page_tools(&theme);
}
let button = if self.navigation_bar_right && !self.temporary {
@@ -1737,7 +1750,7 @@ impl State {
if self.navigation_bar_right {
self.sidebar(&theme, sidebar);
}
- self.sidebar_button(height);
+ self.sidebar_button(height, sidebar);
self.ui.close();
self.context_menu();
return Ok((section, open_tab, None));
@@ -1750,7 +1763,7 @@ impl State {
if self.navigation_bar_right {
self.sidebar(&theme, sidebar);
}
- self.sidebar_button(height);
+ self.sidebar_button(height, sidebar);
self.ui.close();
self.context_menu();
return Ok((section, open_tab, None));
@@ -1836,7 +1849,7 @@ impl State {
self.sidebar(&theme, sidebar);
}
self.task_pane(&theme);
- self.sidebar_button(height);
+ self.sidebar_button(height, sidebar);
self.ui.close();
self.context_menu();
// No tab stands on the frame in Full Page View.
@@ -2025,7 +2038,7 @@ impl State {
} else {
Flags::default()
},
- size: [fill(), px(TITLE)],
+ size: [fill(), px(TOOLBAR)],
pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0],
gap: GAP,
role: Some(accesskit::Role::Toolbar),
@@ -2042,7 +2055,7 @@ impl State {
self.ui.open(
"bar",
Spec {
- size: [fill(), px(TITLE)],
+ size: [fill(), px(TOOLBAR)],
fill: Some(theme.strip),
pad: [0.0, edge],
offset: [0.0, -edge],
@@ -2070,7 +2083,7 @@ impl State {
"tools",
Spec {
flags: Flags::CLIP,
- size: [fill(), px(TITLE)],
+ size: [fill(), px(TOOLBAR)],
pad: [face.pad[0], edge],
offset: [0.0, -edge],
gap: if theme.tool[3] > 0.0 && !panel {
@@ -2121,7 +2134,6 @@ impl State {
use Entry::{Open, Rule, Run};
use canvas::editor::{Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, Toggle};
use commands::{Choice, Id as Cmd};
- use ui::shell::TOOL;
// A focused picture has no text to show a format for.
let state = self.format_state();
let statuses: Vec<_> = commands::COMMANDS
@@ -2145,90 +2157,73 @@ impl State {
let engine = &self.view.engine;
let session = self.session.as_ref();
let update = self.updates.status();
- let label = format!("{:.0}%", self.view.zoom() * 100.0);
let sheet = self.gallery_sheet();
let drawing_pens = self.pens();
let ui = &mut self.ui;
let text = theme.text;
let tags: Vec<_> = self.tags.iter().enumerate().collect();
+ let face = tool_face(theme, grouped(theme), FACE_RISE);
let mut choice = group(
ui,
"navigate",
6,
+ face.clone(),
|ui| {
let mut choice = None;
- for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] {
+ for id in [Cmd::Undo, Cmd::Redo] {
choice = tool(ui, id, status_of(id)).or(choice);
}
choice
},
|ui| {
- let entries = [Run(Cmd::Redo), Rule, Run(Cmd::Back), Run(Cmd::Forward)];
- dropdown(ui, "menu", Head::Split(Cmd::Undo), &entries, status_of)
+ dropdown(
+ ui,
+ "menu",
+ Head::Split(Cmd::Undo),
+ &[Run(Cmd::Redo)],
+ status_of,
+ )
},
);
- choice = group(
- ui,
+ ui.open(
"clipboard",
- 5,
- |ui| {
- divider(ui, theme);
- let mut choice = None;
- let paste = ui.id("paste");
- if !status_of(Cmd::Paste).enabled {
- ui::shell::unavailable(ui, "paste", art::PASTE, text, true);
- } else if ui::shell::split_button(
- ui,
- "paste",
- title(Cmd::Paste),
- art::PASTE,
- None,
- None,
- toolbar_popup("paste"),
- )
- .clicked
- {
- choice = Some(Choice::Command(Cmd::Paste));
- }
- tip(ui, Cmd::Paste);
- // Pasting keeps only the text until #25.
- let options = [
- ("Keep Source Formatting", false),
- ("Merge Formatting", false),
- ("Keep Text Only", true),
- ("Picture", false),
- ];
- let mut items = vec![ui::popup::Item {
- text: "Paste Options",
- heading: true,
- ..Default::default()
- }];
- items.extend(options.map(|(text, enabled)| ui::popup::Item {
- text,
- disabled: !enabled,
- ..Default::default()
- }));
- let anchor = ui::Anchor::Below(ui.rect(paste).unwrap_or_default());
- if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() {
- choice = Some(Choice::Command(Cmd::Paste));
- }
- for id in [Cmd::Cut, Cmd::Copy, Cmd::FormatPainter] {
- choice = tool(ui, id, status_of(id)).or(choice);
- }
- choice
+ Spec {
+ flags: Flags::CLICKABLE,
+ ..face.clone()
},
- |ui| {
- let entries = [
- Run(Cmd::Cut),
- Run(Cmd::Copy),
- Run(Cmd::FormatPainter),
- Rule,
- Open("paste", Cmd::Paste),
- ];
- dropdown(ui, "menu", Head::Split(Cmd::Paste), &entries, status_of)
- },
- )
- .or(choice);
+ );
+ divider(ui, theme);
+ let entries = [
+ Run(Cmd::Paste),
+ Open("paste", Cmd::Paste),
+ Rule,
+ Run(Cmd::Cut),
+ Run(Cmd::Copy),
+ ];
+ let head = Head::Menu("Clipboard", art::PASTE);
+ choice = dropdown(ui, "menu", head, &entries, status_of).or(choice);
+ // Pasting keeps only the text until #25.
+ let options = [
+ ("Keep Source Formatting", false),
+ ("Merge Formatting", false),
+ ("Keep Text Only", true),
+ ("Picture", false),
+ ];
+ let mut items = vec![ui::popup::Item {
+ text: "Paste Options",
+ heading: true,
+ ..Default::default()
+ }];
+ items.extend(options.map(|(text, enabled)| ui::popup::Item {
+ text,
+ disabled: !enabled,
+ ..Default::default()
+ }));
+ let anchor = ui::Anchor::Below(ui.rect(ui.id("menu")).unwrap_or_default());
+ if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() {
+ choice = Some(Choice::Command(Cmd::Paste));
+ }
+ ui.close();
let font = state.font.clone().unwrap_or_default();
let size = state
.font_size
@@ -2236,12 +2231,13 @@ impl State {
ui.open(
"font",
Spec {
+ flags: Flags::CLICKABLE,
gap: 1.0,
- ..tool_face(theme, grouped(theme), FACE_RISE)
+ ..face.clone()
},
);
divider(ui, theme);
- // Styles sits left of the font box (Clover, 2026-09-30), and like it never folds.
+ // Styles, the font and the size never fold.
let styles = toolbar_popup("styles");
let anchor = if status_of(Cmd::Styles).enabled {
ui::shell::menu_button(ui, "styles", art::STYLES, None, styles)
@@ -2342,14 +2338,22 @@ impl State {
let id = Cmd::Toggle(toggle);
choice = tool(ui, id, status_of(id)).or(choice);
}
- ui.close();
+ let effects = [
+ Run(Cmd::Toggle(Toggle::Strikethrough)),
+ Run(Cmd::Toggle(Toggle::Subscript)),
+ Run(Cmd::Toggle(Toggle::Superscript)),
+ Rule,
+ Run(Cmd::ClearFormatting),
+ ];
+ // The rest of the group folds inside it, on its face.
choice = group(
ui,
"character",
8,
+ Spec::default(),
|ui| {
- divider(ui, theme);
- let mut choice = None;
+ let head = Head::Menu("Text Effects", art::STRIKETHROUGH);
+ let mut choice = dropdown(ui, "script", head, &effects, status_of);
// Each button applies its menu's last pick: the highlighter shows it in its
// artwork, the font colour in a bar.
for (part, id, swatches, columns, none, bar) in [
@@ -2410,37 +2414,23 @@ impl State {
});
}
}
- dropdown(
- ui,
- "script",
- Head::Menu("Text Effects", art::STRIKETHROUGH),
- &[
- Run(Cmd::Toggle(Toggle::Strikethrough)),
- Run(Cmd::Toggle(Toggle::Subscript)),
- Run(Cmd::Toggle(Toggle::Superscript)),
- Rule,
- Run(Cmd::ClearFormatting),
- ],
- status_of,
- )
- .or(choice)
+ tool(ui, Cmd::FormatPainter, status_of(Cmd::FormatPainter)).or(choice)
},
|ui| {
- let entries = [
+ let mut entries = effects.to_vec();
+ entries.extend([
+ Rule,
Open("highlight", Cmd::Highlight),
Run(Cmd::FontColor),
Open("color", Cmd::FontColor),
Rule,
- Run(Cmd::Toggle(Toggle::Strikethrough)),
- Run(Cmd::Toggle(Toggle::Subscript)),
- Run(Cmd::Toggle(Toggle::Superscript)),
- Rule,
- Run(Cmd::ClearFormatting),
- ];
+ Run(Cmd::FormatPainter),
+ ]);
dropdown(ui, "menu", Head::Split(Cmd::Highlight), &entries, status_of)
},
)
.or(choice);
+ ui.close();
let alignments = [Alignment::Left, Alignment::Center, Alignment::Right].map(Cmd::Align);
let paragraph = [
Rule,
@@ -2455,6 +2445,7 @@ impl State {
ui,
"paragraph",
7,
+ face.clone(),
|ui| {
divider(ui, theme);
let mut choice = None;
@@ -2603,6 +2594,7 @@ impl State {
ui,
"tags",
9,
+ face.clone(),
|ui| {
divider(ui, theme);
let mut choice = None;
@@ -2659,6 +2651,7 @@ impl State {
ui,
"insert",
4,
+ face.clone(),
|ui| {
divider(ui, theme);
let mut choice = None;
@@ -2742,6 +2735,7 @@ impl State {
ui,
part,
priority,
+ face.clone(),
|ui| {
divider(ui, theme);
let mut choice = None;
@@ -2760,6 +2754,7 @@ impl State {
ui,
"draw",
2,
+ face.clone(),
|ui| {
divider(ui, theme);
let mut choice = tool(ui, Cmd::SelectType, status_of(Cmd::SelectType));
@@ -2805,6 +2800,40 @@ impl State {
|_| None,
)
.or(choice);
+ // Folded, Page Color stays in the view's menu at the row's end.
+ choice = group(
+ ui,
+ "page color",
+ 1,
+ face.clone(),
+ |ui| {
+ divider(ui, theme);
+ if !status_of(Cmd::PageColor).enabled {
+ ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true);
+ tip(ui, Cmd::PageColor);
+ return None;
+ }
+ let menu = toolbar_popup("page color");
+ let anchor = ui::shell::menu_button(ui, "page color", art::PAGE_COLOR, None, menu);
+ tip(ui, Cmd::PageColor);
+ background::menu(
+ ui,
+ menu,
+ anchor,
+ &mut self.thumbnails,
+ &mut self.templates,
+ // The page's own colour aside.
+ canvas::gpu::Paper {
+ color: ui.theme.paper,
+ ink: ui.theme.paper_ink,
+ },
+ self.view.editor.page_color(),
+ self.view.editor.rule_lines(),
+ )
+ },
+ |_| None,
+ )
+ .or(choice);
ui.leaf(
"space",
Spec {
@@ -2815,7 +2844,14 @@ impl State {
if let Some(session) = session {
sync::control(ui, session, &update, theme);
}
+ // The view's commands, with zoom and Page Color for where theirs have folded away.
let views = [
+ Run(Cmd::ZoomIn),
+ Run(Cmd::ZoomOut),
+ Run(Cmd::ActualSize),
+ Rule,
+ Open("page color", Cmd::PageColor),
+ Rule,
Run(Cmd::Sidebar),
Run(Cmd::PageList),
Run(Cmd::PagesMatchTheme),
@@ -2824,95 +2860,151 @@ impl State {
Run(Cmd::HideSpelling),
Run(Cmd::Spelling),
];
- choice = group(
- ui,
+ ui.open(
"view",
- 1,
- |ui| {
- divider(ui, theme);
- let mut choice = None;
- if status_of(Cmd::PageColor).enabled {
- let anchor = ui::shell::menu_button(
- ui,
- "page color",
- art::PAGE_COLOR,
- None,
- toolbar_popup("page color"),
- );
- tip(ui, Cmd::PageColor);
- choice = background::menu(
- ui,
- toolbar_popup("page color"),
- anchor,
- &mut self.thumbnails,
- &mut self.templates,
- // The page's own colour aside.
- canvas::gpu::Paper {
- color: ui.theme.paper,
- ink: ui.theme.paper_ink,
- },
- self.view.editor.page_color(),
- self.view.editor.rule_lines(),
- )
- .or(choice.take());
- } else {
- ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true);
- tip(ui, Cmd::PageColor);
- }
- choice = tool(ui, Cmd::ZoomOut, status_of(Cmd::ZoomOut)).or(choice);
- let level = ui.leaf(
- "level",
- Spec {
- flags: Flags::CLICKABLE,
- size: [px(44.0), px(TOOL)],
- text: Some(&label),
- hover_fill: Some(theme.hover()),
- radius: 4.0,
- center: true,
- role: Some(accesskit::Role::Button),
- ..Spec::default()
+ Spec {
+ flags: Flags::CLICKABLE,
+ ..face.clone()
+ },
+ );
+ let head = Head::More("More View Options");
+ choice = dropdown(ui, "more", head, &views, status_of).or(choice);
+ ui.close();
+ if let Some(choice) = choice {
+ self.choose(choice);
+ }
+ }
+
+ /// The page's zoom, left of the search box: Zoom Out, the level and Zoom In joined as one
+ /// control, which folds away first where the tab row lacks room. Clicking the level opens
+ /// it as a field, where Enter zooms to the percentage typed and Escape keeps the zoom.
+ fn zoom(&mut self, theme: &Theme) -> Result<(), Box> {
+ use commands::{Choice, Id as Cmd};
+ use ui::shell::TOOL;
+ const LEVEL: f32 = 44.0;
+ let format = self.format_state();
+ let [out, into] =
+ [Cmd::ZoomOut, Cmd::ZoomIn].map(|id| self.status(&Choice::Command(id), &format));
+ let level = format!("{:.0}%", self.view.zoom() * 100.0);
+ let field = self.ui.id("zoom field");
+ if self.ui.focused() != Some(field) {
+ self.zoom_typed = None;
+ }
+ self.ui.open(
+ "zoom",
+ Spec {
+ flags: Flags::CLICKABLE,
+ fold: Some(0),
+ ..Spec::default()
+ },
+ );
+ self.ui.open(
+ "full",
+ Spec {
+ size: [children(), px(TAB_ROW)],
+ // As far from the search box as the page list's buttons are apart.
+ pad: [3.0, (TAB_ROW - TOOL) / 2.0],
+ ..Spec::default()
+ },
+ );
+ let typing = self.zoom_typed.is_some();
+ self.ui.open(
+ "control",
+ Spec {
+ size: [children(), px(TOOL)],
+ fill: Some(theme.base),
+ border: Some(if typing { theme.accent } else { theme.chip }),
+ radius: 4.0,
+ ..Spec::default()
+ },
+ );
+ let line = |ui: &mut Ui, part| {
+ ui.leaf(
+ part,
+ Spec {
+ size: [px(1.0), px(TOOL)],
+ fill: Some(theme.chip),
+ ..Spec::default()
+ },
+ );
+ };
+ let mut choice = tool(&mut self.ui, Cmd::ZoomOut, out);
+ line(&mut self.ui, "before");
+ let mut zoom = None;
+ if let Some(typed) = &mut self.zoom_typed {
+ let signal = ui::text_field(
+ &mut self.ui,
+ field,
+ typed,
+ "",
+ Spec {
+ size: [px(LEVEL), px(TOOL)],
+ pad: [4.0, 0.0],
+ ..Spec::default()
+ },
+ );
+ name(&mut self.ui, field, "Zoom");
+ let key = signal.events.iter().find_map(|event| match event {
+ ui::Event::Key {
+ key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)),
+ ..
+ } => Some(*key),
+ _ => None,
+ });
+ if let Some(key) = key {
+ let percent = typed.trim().trim_end_matches('%').trim().parse::();
+ zoom = percent.ok().filter(|_| key == NamedKey::Enter);
+ self.zoom_typed = None;
+ self.ui.set_focus(Some(page()));
+ }
+ } else {
+ let id = self.ui.id("level");
+ let signal = self.ui.leaf(
+ "level",
+ Spec {
+ // Zoom applies where its buttons do.
+ flags: if out.enabled {
+ Flags::CLICKABLE
+ } else {
+ Flags::default()
},
- );
- if level.clicked {
- choice = Some(Choice::Command(Cmd::ActualSize));
- }
- tip(ui, Cmd::ActualSize);
- if let Some(node) = ui.access(ui.id("level")) {
- node.set_value(label.as_str());
- }
- choice = tool(ui, Cmd::ZoomIn, status_of(Cmd::ZoomIn)).or(choice);
- dropdown(
- ui,
- "view",
- Head::More("More View Options"),
- &views,
- status_of,
- )
- .or(choice)
- },
- |ui| {
- let mut entries = vec![
- Run(Cmd::ZoomIn),
- Run(Cmd::ZoomOut),
- Run(Cmd::ActualSize),
- Rule,
- Open("page color", Cmd::PageColor),
- Rule,
- ];
- entries.extend(views);
- dropdown(
- ui,
- "menu",
- Head::Menu("View", art::ZOOM_IN),
- &entries,
- status_of,
- )
- },
- )
- .or(choice);
+ size: [px(LEVEL), px(TOOL)],
+ text: Some(&level),
+ color: Some(if out.enabled {
+ theme.text
+ } else {
+ theme.text_dim
+ }),
+ hover_fill: Some(theme.hover()),
+ center: true,
+ role: Some(accesskit::Role::Button),
+ ..Spec::default()
+ },
+ );
+ if let Some(node) = self.ui.access(id) {
+ node.set_label("Zoom");
+ node.set_value(level.as_str());
+ }
+ if signal.clicked {
+ self.zoom_typed = Some(level.trim_end_matches('%').to_owned());
+ self.ui.focus_all(field);
+ }
+ }
+ line(&mut self.ui, "after");
+ choice = tool(&mut self.ui, Cmd::ZoomIn, into).or(choice);
+ self.ui.close();
+ self.ui.close();
+ self.ui.open("folded", Spec::default());
+ self.ui.close();
+ self.ui.close();
if let Some(choice) = choice {
self.choose(choice);
}
+ if let Some(percent) = zoom {
+ let response = self.view.set_zoom(percent / 100.0)?;
+ self.respond(response);
+ }
+ Ok(())
}
/// The search box and the page list's buttons, above the list. Where the tab row lacks
@@ -2920,10 +3012,12 @@ impl State {
/// on the page it stays.
fn page_tools(&mut self, theme: &Theme) {
let finding = self.search.finding;
+ // It folds after the zoom.
self.ui.open(
"tools",
Spec {
- fold: (!finding).then_some(0),
+ flags: Flags::CLICKABLE,
+ fold: (!finding).then_some(1),
..Spec::default()
},
);
@@ -5131,19 +5225,21 @@ fn pen_gallery(
)
}
-/// A toolbar group that folds by `priority`: `full` builds its full form, `folded` the form
-/// it folds to. Returns what either chose.
+/// A toolbar group on `face` that folds by `priority`: `full` builds its full form, `folded`
+/// the form it folds to. Returns what either chose. Like every group, it takes presses
+/// between its buttons, so only the row's empty space drags and zooms the window.
fn group(
ui: &mut Ui,
part: &str,
priority: u32,
+ face: Spec<'static>,
full: impl FnOnce(&mut Ui) -> Option,
folded: impl FnOnce(&mut Ui) -> Option,
) -> Option {
- let face = tool_face(&ui.theme, grouped(&ui.theme), FACE_RISE);
ui.open(
part,
Spec {
+ flags: Flags::CLICKABLE,
fold: Some(priority),
..face
},
@@ -5239,13 +5335,9 @@ fn dropdown(
anchor
}
// A menu of nothing that applies fades as a button would.
- Head::Menu(label, _) | Head::Tools(label, _) | Head::More(label) if !usable => {
- let (icon, arrow) = match head {
- Head::Menu(_, icon) | Head::Tools(_, icon) => (icon, true),
- _ => (ui::shell::CHEVRON, false),
- };
+ Head::Menu(label, icon) | Head::Tools(label, icon) if !usable => {
let tint = ui.theme.text;
- ui::shell::unavailable(ui, part, icon, tint, arrow);
+ ui::shell::unavailable(ui, part, icon, tint, true);
name(ui, button, label);
ui::Anchor::Below(ui.rect(button).unwrap_or_default())
}
@@ -5264,18 +5356,9 @@ fn dropdown(
anchor
}
Head::More(label) => {
- let open = ui.popup_open(menu);
- let text = ui.theme.text;
- if ui::shell::tool_button(ui, part, ui::shell::CHEVRON, text, Some(open)).pressed {
- ui.open_popup(menu);
- }
- if let Some(node) = ui.access(button) {
- node.set_label(label);
- node.clear_toggled();
- node.set_has_popup(accesskit::HasPopup::Menu);
- node.set_expanded(open);
- }
- ui::Anchor::Below(ui.rect(button).unwrap_or_default())
+ let anchor = ui::shell::more_button(ui, part, menu, usable);
+ name(ui, button, label);
+ anchor
}
};
let keys: Vec<_> = entries
diff --git a/crates/snowbound/src/screenshot/snow_leopard.rs b/crates/snowbound/src/screenshot/snow_leopard.rs
index d6d9595a44ed811a4caa2288bdc964f822a16828..aad5fcb5d37b7fb0ccadf6c073d5ce2bb33ad448 100644
--- a/crates/snowbound/src/screenshot/snow_leopard.rs
+++ b/crates/snowbound/src/screenshot/snow_leopard.rs
@@ -181,7 +181,7 @@ fn window(
) -> Result, Box> {
let [highlight, top, bottom, body] = GREYS[usize::from(!key)];
// The gradient runs from under the highlight to the top content border's last row.
- let end = TITLE_LINE + (crate::TITLE + crate::TAB_ROW) as usize - 1;
+ let end = TITLE_LINE + (crate::TOOLBAR + crate::TAB_ROW) as usize - 1;
let total = height + TITLE_LINE;
let mut pixels: Vec<[f32; 4]> = (0..total)
.flat_map(|y| {
diff --git a/crates/snowbound/src/sidebar.rs b/crates/snowbound/src/sidebar.rs
index 6d4420d2dda45b678d4063af838fdff7fbf8b96e..8468784e67f1e36f26cf919e51165b3272c1d218 100644
--- a/crates/snowbound/src/sidebar.rs
+++ b/crates/snowbound/src/sidebar.rs
@@ -18,6 +18,13 @@ const RAIL: f32 = crate::TAB_ROW;
/// on the row's foot, so their labels sit 1 to 3 pixels low.
const DROP: f32 = 2.0;
const ROW: f32 = 24.0;
+/// The tree's margin, and how far inside a row its icon starts and its label after that; the
+/// header's title and the notebook button's icon, while the sidebar is open, line up with them.
+const MARGIN: f32 = 4.0;
+const ROW_PAD: f32 = 6.0;
+const ICON: f32 = 16.0;
+/// The Back and Forward buttons' box, with room beside them as the notebook button's square has.
+pub const NAV: f32 = 2.0 * ui::shell::TOOL + 1.0 + (RAIL - ui::shell::TOOL);
/// How far each level of the tree sits inside its parent.
const INDENT: f32 = 16.0;
@@ -136,7 +143,8 @@ pub fn header() -> Id {
/// The sidebar's header row, as tall as the tab row and dragging the window when `drags`, then
/// with `rows` the tree of `notebooks` with the open section marked. The notebook button
-/// floats over the header's end at the window's edge, the `right` one or the left.
+/// floats over the header's icon on the left, or its end on the `right`, where the Back and
+/// Forward buttons otherwise do.
fn sidebar(
ui: &mut Ui,
tree: &mut Tree,
@@ -155,36 +163,36 @@ fn sidebar(
Flags::default()
},
size: [fill(), px(RAIL)],
- pad: [crate::FRAME, (RAIL - ui::shell::TOOL) / 2.0 + DROP],
- gap: 6.0,
+ pad: [MARGIN + ROW_PAD, (RAIL - ui::shell::TOOL) / 2.0 + DROP],
+ gap: ROW_PAD,
+ ..Spec::default()
+ },
+ );
+ ui.leaf(
+ "icon",
+ Spec {
+ size: [px(ICON), px(ui::shell::TOOL)],
..Spec::default()
},
);
- // The notebook button's square, which floats over this place.
- let toggle = |ui: &mut Ui| {
- ui.leaf(
- "toggle",
- Spec {
- size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)],
- ..Spec::default()
- },
- );
- };
- if !right {
- toggle(ui);
- }
ui.leaf(
"title",
Spec {
size: [fill(), px(ui::shell::TOOL)],
- text: Some("Notebooks"),
+ text: Some("My Notebooks"),
color: Some(theme.text_dim),
..Spec::default()
},
);
- if right {
- toggle(ui);
- }
+ // Room for what floats over the header's end.
+ let end = if right { RAIL } else { NAV + MARGIN };
+ ui.leaf(
+ "end",
+ Spec {
+ size: [px(end - MARGIN - 2.0 * ROW_PAD), px(1.0)],
+ ..Spec::default()
+ },
+ );
ui.close();
if !rows {
return;
@@ -195,7 +203,7 @@ fn sidebar(
flags: Flags::SCROLL | Flags::CLIP,
axis: Axis::Y,
size: [fill(), fill()],
- pad: [4.0, 2.0],
+ pad: [MARGIN, 2.0],
..Spec::default()
},
);
@@ -248,7 +256,7 @@ fn sidebar(
Spec {
axis: Axis::Y,
size: [fill(), ui::children()],
- pad: [4.0, 6.0],
+ pad: [MARGIN, 6.0],
..Spec::default()
},
);
@@ -648,9 +656,13 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
fill: lit,
hover_fill: Some(ui::mix(lit.unwrap_or(theme.sidebar), theme.hover(), 0.6)),
radius: 4.0,
- pad: [6.0 + INDENT * row.depth as f32, 0.0],
+ pad: [ROW_PAD + INDENT * row.depth as f32, 0.0],
// The field's text stands where the label did.
- gap: if row.renamed { 6.0 - rename::PAD } else { 6.0 },
+ gap: if row.renamed {
+ ROW_PAD - rename::PAD
+ } else {
+ ROW_PAD
+ },
role: Some(accesskit::Role::TreeItem),
..Spec::default()
},
@@ -670,7 +682,7 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
ui.leaf(
"icon",
Spec {
- size: [px(16.0), px(ROW)],
+ size: [px(ICON), px(ROW)],
icon: Some(icon),
color: Some([red, green, blue, alpha]),
..Spec::default()
@@ -764,7 +776,7 @@ impl crate::State {
..Spec::default()
},
);
- crate::name(&mut self.ui, rows_id, "Notebooks");
+ crate::name(&mut self.ui, rows_id, "My Notebooks");
let shown = match (&self.session, &self.locked) {
(Some(session), _) => Some((&session.library, session.tabs[session.tab].path.as_str())),
(None, Some(locked)) => Some((&locked.library, locked.path.as_str())),
@@ -840,42 +852,71 @@ impl crate::State {
}
}
- /// The notebook button, floating at the body's corner over the section tabs' row, `height`
- /// tall as it eases, or over the sidebar's header while that is open.
- pub(crate) fn sidebar_button(&mut self, height: f32) {
+ /// Back, Forward and the notebook button, floating at the body's corner over the section
+ /// tabs' row, `height` tall as it eases. While the sidebar, `width` wide as it eases, is
+ /// open on the left, the notebook button closes it from its header's icon, and Back and
+ /// Forward ride its end.
+ pub(crate) fn sidebar_button(&mut self, height: f32, width: f32) {
+ use crate::commands::{Choice, Id as Cmd};
if self.temporary {
return;
}
- // On the right it stands at the body's far edge, as laid out last frame.
- let x = match self.navigation_bar_right {
- true => self
+ let (nav, toggle) = if self.navigation_bar_right {
+ // The notebook button stands at the body's far edge, as laid out last frame.
+ let edge = self
.ui
.rect(self.ui.current())
- .map_or(0.0, |[left, _, right, _]| right - left - RAIL),
- false => 0.0,
+ .map_or(0.0, |[left, _, right, _]| right - left - RAIL);
+ (0.0, edge)
+ } else {
+ let open = MARGIN + ROW_PAD - (RAIL - ICON) / 2.0;
+ let toggle = NAV + (open - NAV) * width / WIDTH;
+ ((width - NAV - MARGIN).max(0.0), toggle)
};
+ let pad = [
+ (RAIL - ui::shell::TOOL) / 2.0,
+ (RAIL - ui::shell::TOOL) / 2.0 + DROP,
+ ];
+ self.ui.open(
+ "back and forward",
+ Spec {
+ flags: Flags::FLOAT | Flags::CLIP,
+ size: [px(NAV), px(height)],
+ position: [nav, 0.0],
+ pad,
+ gap: 1.0,
+ ..Spec::default()
+ },
+ );
+ let format = self.format_state();
+ for id in [Cmd::Back, Cmd::Forward] {
+ let status = self.status(&Choice::Command(id), &format);
+ if let Some(choice) = crate::tool(&mut self.ui, id, status) {
+ self.choose(choice);
+ }
+ }
+ self.ui.close();
self.ui.open(
"toggle",
Spec {
flags: Flags::FLOAT | Flags::CLIP,
size: [px(RAIL), px(height)],
- position: [x, 0.0],
- pad: [
- (RAIL - ui::shell::TOOL) / 2.0,
- (RAIL - ui::shell::TOOL) / 2.0 + DROP,
- ],
+ position: [toggle, 0.0],
+ pad,
..Spec::default()
},
);
- let tint = crate::notebook_color(
- &self.ui.theme,
- self.notebook().and_then(|library| library.color()),
- );
- if ui::shell::tool_button(&mut self.ui, "button", art::NOTEBOOK, tint, None).clicked {
+ let (icon, tint) = if self.sidebar {
+ (art::CLOSE, self.ui.theme.text)
+ } else {
+ let color = self.notebook().and_then(|library| library.color());
+ (art::NOTEBOOK, crate::notebook_color(&self.ui.theme, color))
+ };
+ if ui::shell::tool_button(&mut self.ui, "button", icon, tint, None).clicked {
self.sidebar = !self.sidebar;
self.save_settings();
}
- crate::tip(&mut self.ui, crate::commands::Id::Sidebar);
+ crate::tip(&mut self.ui, Cmd::Sidebar);
self.ui.close();
}
diff --git a/crates/snowbound/src/windows.rs b/crates/snowbound/src/windows.rs
index 7155c4659bcdd3936245bac1dc2af5dd689eab6b..f0af37c762dd10773c166aaee2def7efd274e490 100644
--- a/crates/snowbound/src/windows.rs
+++ b/crates/snowbound/src/windows.rs
@@ -805,7 +805,7 @@ pub fn window_controls(ui: &mut Ui, window: &Window, end: bool) {
let size = if found {
[bounds.right - bounds.left, bounds.bottom - bounds.top].map(|side| side as f32 / scale)
} else {
- [3.0 * CAPTION_BUTTON, crate::TITLE]
+ [3.0 * CAPTION_BUTTON, crate::TOOLBAR]
};
let offset = [GAP, -(crate::TITLE - ui::shell::TOOL) / 2.0];
if system && !eleven() {
diff --git a/crates/snowbound/tests/replay.rs b/crates/snowbound/tests/replay.rs
index b262aca3f4adf27fe38189b70b686c872c49a373..79ca0e308730a3001109642b6767aa49fa738d2e 100644
--- a/crates/snowbound/tests/replay.rs
+++ b/crates/snowbound/tests/replay.rs
@@ -364,7 +364,7 @@ fn renaming_a_section_from_its_tab_types_into_the_sidebar_at_once() {
let scratch = Scratch::new("rename-shut-sidebar");
let notebook =
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
- let mut steps = vec!["move 53 53", "press right", "release right", "settle"];
+ let mut steps = vec!["move 110 53", "press right", "release right", "settle"];
steps.extend(["key Down", "key Enter", "settle"]);
steps.extend(["type Renamed", "accessibility typed"]);
let [typed] = replay(&scratch, Some(¬ebook), &steps)
diff --git a/crates/ui/src/layout.rs b/crates/ui/src/layout.rs
index 385398abda719214bcd30cf53e9346eb2db461c6..4ccf9e13777784efceb427cf6709a06999c346fe 100644
--- a/crates/ui/src/layout.rs
+++ b/crates/ui/src/layout.rs
@@ -90,13 +90,9 @@ pub(crate) fn solve(
// goes back to the space.
let spare: f32 = space.iter().map(|child| nodes[*child].computed[axis]).sum();
while excess > spare && axis == 0 {
- let Some(group) = children
- .iter()
- .copied()
- .filter(|child| {
- nodes[*child].fold.is_some() && !nodes[nodes[*child].children[0]].hidden
- })
- .min_by_key(|child| nodes[*child].fold)
+ let mut groups = Vec::new();
+ unfolded(nodes, &children, &mut groups);
+ let Some(group) = groups.into_iter().min_by_key(|group| nodes[*group].fold)
else {
break;
};
@@ -104,8 +100,14 @@ pub(crate) fn solve(
nodes[full].hidden = true;
nodes[folded].hidden = false;
let width = nodes[folded].computed[0] + 2.0 * nodes[group].pad[0];
- excess -= nodes[group].computed[0] - width;
- nodes[group].computed[0] = width;
+ let freed = nodes[group].computed[0] - width;
+ excess -= freed;
+ // A group inside boxes sized by their children narrows them too.
+ let mut inside = group;
+ while inside != index {
+ nodes[inside].computed[0] -= freed;
+ inside = nodes[inside].parent;
+ }
}
excess = give_back(nodes, &space, axis, excess);
let mut tiers: Vec = sized
@@ -313,6 +315,21 @@ fn in_flow(nodes: &[Built], index: usize) -> impl Iterator + '_ {
})
}
+/// The groups among `boxes` still in their full form, and those inside any of them that is a
+/// row sized by its children, whose width follows theirs.
+fn unfolded(nodes: &[Built], boxes: &[usize], groups: &mut Vec) {
+ for &child in boxes {
+ let node = &nodes[child];
+ if node.fold.is_some() {
+ if !nodes[node.children[0]].hidden {
+ groups.push(child);
+ }
+ } else if node.size[0].size == Size::Children && node.axis == Axis::X {
+ unfolded(nodes, &in_flow(nodes, child).collect::>(), groups);
+ }
+ }
+}
+
/// The children's extent on `axis`: summed with gaps along the flow, otherwise the largest.
fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 {
let sizes = in_flow(nodes, index).map(|child| nodes[child].computed[axis]);
diff --git a/crates/ui/src/lib.rs b/crates/ui/src/lib.rs
index b363eb19e347b84623cd61aa4a910501e844473c..cc5ac585f3fc7c7613f8c06bad9a3c1681075c9e 100644
--- a/crates/ui/src/lib.rs
+++ b/crates/ui/src/lib.rs
@@ -390,7 +390,8 @@ pub struct Spec<'a> {
pub anchor: Option,
/// Makes the box a group of a row in two forms, its first two children, both built every
/// frame: its full form, and the one it folds to where the row lacks room. The row folds
- /// its groups by ascending priority until it fits, once the space sized by ancestors has
+ /// its groups, and those in rows of its own sized by their children, by ascending
+ /// priority until it fits, once the space sized by ancestors has
/// yielded and before any other box gives up room. The form not shown takes no room,
/// paint or input, and its boxes take the group's rectangle, so what opens from them
/// opens from the form shown.
diff --git a/crates/ui/src/shell.rs b/crates/ui/src/shell.rs
index e530d0523391bf391f5b4f7a41557d2c379d1533..baf8c87c4f12bd65d724e4f87378441f9d100ed4 100644
--- a/crates/ui/src/shell.rs
+++ b/crates/ui/src/shell.rs
@@ -18,6 +18,8 @@ const TAB_ROUNDING: f32 = 4.0;
pub const TOOL: f32 = 22.0;
/// Width of a menu arrow beside a toolbar button.
const ARROW: f32 = 10.0;
+/// Width of a button showing only a menu arrow, which ends its group as tight as an icon does.
+const MORE: f32 = 14.0;
/// Where a section tab's label starts inside it.
pub const TAB_PAD: f32 = 10.0;
/// How far section tabs fade out at an end of their row they are cut at, and how near it a
@@ -483,6 +485,48 @@ pub fn split_button(
ui.signal(button)
}
+/// A narrow button showing only a menu arrow, which opens popup `menu` of more of a group's
+/// commands at the group's end; where not `enabled` it shows faded and takes no clicks.
+pub fn more_button(ui: &mut Ui, part: impl Hash, menu: Id, enabled: bool) -> Anchor {
+ let theme = ui.theme.clone();
+ let open = ui.popup_open(menu);
+ let id = ui.open(
+ part,
+ Spec {
+ flags: if enabled {
+ Flags::CLICKABLE
+ } else {
+ Flags::default()
+ },
+ size: [px(MORE), px(TOOL)],
+ icon: Some(CHEVRON),
+ color: Some(if enabled {
+ theme.text_dim
+ } else {
+ faded(theme.text_dim)
+ }),
+ fill: open.then(|| theme.hover()).or(rest(&theme)),
+ hover_fill: enabled.then(|| theme.hover()),
+ radius: 4.0,
+ center: true,
+ role: Some(Role::Button),
+ ..Spec::default()
+ },
+ );
+ if let Some(node) = ui.access(id) {
+ node.set_has_popup(HasPopup::Menu);
+ node.set_expanded(open);
+ if !enabled {
+ node.set_disabled();
+ }
+ }
+ ui.close();
+ if enabled && ui.signal(id).pressed {
+ ui.open_popup(menu);
+ }
+ Anchor::Below(ui.rect(id).unwrap_or_default())
+}
+
/// A button showing `icon` and a menu arrow, one control that opens popup `menu`, a toggle
/// lit while `on` as `tool_button`'s. Returns where the menu opens so its icons line up
/// under the button's.
diff --git a/crates/ui/src/tests.rs b/crates/ui/src/tests.rs
index cbe8407f16ce449388a8673dc7692b63ec661e0a..d2748f686f5f10a09b4dc2b5b0443cf640e41577 100644
--- a/crates/ui/src/tests.rs
+++ b/crates/ui/src/tests.rs
@@ -232,6 +232,79 @@ fn a_row_is_narrowest_with_every_group_folded_and_every_box_squeezed() {
assert_eq!(rects[4], Some([40.0, 0.0, 60.0, 20.0]));
}
+#[test]
+fn a_group_inside_a_box_sized_by_its_children_folds_with_the_row() {
+ let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
+ let build = |ui: &mut Ui, width: f32| {
+ frame(ui, |ui| {
+ ui.open(
+ "row",
+ Spec {
+ size: [px(width), px(20.0)],
+ ..Spec::default()
+ },
+ );
+ ui.open("outer", Spec::default());
+ ui.leaf(
+ "fixed",
+ Spec {
+ size: [px(50.0), px(20.0)],
+ ..Spec::default()
+ },
+ );
+ ui.open(
+ "group",
+ Spec {
+ fold: Some(0),
+ ..Spec::default()
+ },
+ );
+ for (form, width) in [("full", 100.0), ("folded", 20.0)] {
+ ui.open(form, Spec::default());
+ ui.leaf(
+ "button",
+ Spec {
+ size: [px(width), px(20.0)],
+ ..Spec::default()
+ },
+ );
+ ui.close();
+ }
+ ui.close();
+ ui.close();
+ ui.open(
+ "later",
+ Spec {
+ fold: Some(1),
+ ..Spec::default()
+ },
+ );
+ for (form, width) in [("full", 30.0), ("folded", 30.0)] {
+ ui.open(form, Spec::default());
+ ui.leaf(
+ "button",
+ Spec {
+ size: [px(width), px(20.0)],
+ ..Spec::default()
+ },
+ );
+ ui.close();
+ }
+ ui.close();
+ ui.close();
+ });
+ };
+ let [outer, after] = ["outer", "later"].map(|part| Id::ROOT.child("row").child(part));
+ build(&mut ui, 180.0);
+ assert_eq!(ui.rect(outer), Some([0.0, 0.0, 150.0, 20.0]));
+ // 50 + 100 + 30 overflows 179, so the group folds before the later one of higher priority,
+ // and the box around it narrows to 70.
+ build(&mut ui, 179.0);
+ assert_eq!(ui.rect(outer), Some([0.0, 0.0, 70.0, 20.0]));
+ assert_eq!(ui.rect(after), Some([70.0, 0.0, 100.0, 20.0]));
+ assert_eq!(ui.narrowest(Id::ROOT.child("row")), Some(100.0));
+}
+
#[test]
fn a_box_sized_by_its_children_yields_what_they_yield() {
let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);