authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 18:02:12-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-02 19:55:45-07:00
log1de0431d8bcf7c9e558be7762f0996e76eb899b2
treeb7f53d59c1576871771688cab411271525fda038
parent218d66206d78eb0a122f2a2ab226c0b7a232dcfd
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

feat: the toolbar after Clover's review: one Clipboard menu, a whole text group, Back, Forward and zoom in the tab row, and My Notebooks

- Paste, Paste Options, Cut and Copy are one Clipboard menu at every width. - The text group runs Styles, font, size, B I U, then Text Effects, highlighter, font colour and Format Painter on one face with no divider; that tail folds inside the group, which the ui kit now allows: a row folds groups inside its boxes sized by their children. - Styles gets an "Aa" icon. A group's trailing "more" arrow is a 14-pt button, so the group ends as tight as one ending in an icon. - Page Color follows the drawing tools; the row ends in sync status and the view menu, which also lists zoom and Page Color. - Back and Forward stand before the notebook button at the tab row's start and, with the sidebar open, at its top right. The sidebar reads "My Notebooks", and its button shows a close cross over the tree's icon column. - Zoom is a joined − 100% + control left of the search box; clicking the level edits it (Enter zooms to the percentage, Escape keeps the zoom). It folds away first. - The toolbar row ends 4 pt short of the 38-pt title bar its buttons centre in, so the tabs sit closer, on every platform. - Presses inside a toolbar group, a faded button included, never drag or zoom the window; on macOS the view tells AppKit it never moves the window, and a double click follows Desktop & Dock's minimize or zoom setting. The least window on macOS is 615 pt, from 626. Assisted-by: claude-opus-5.5

14 files changed, 561 insertions(+), 291 deletions(-)

arc/platforms.md+6-3
......@@ -31,9 +31,12 @@ keyboard, the toolbar and the macOS menu bar all run commands from it.
3131### macOS
3232
3333- The toolbar's row is the title bar. An empty `NSToolbar`, unified compact
34 from macOS 11, with the title hidden makes the title bar the row's height,
35 and AppKit places the traffic lights in it; a press in the row's gaps drags
36 the window. The title is still set, for the Window menu, Mission Control and
34 from macOS 11, with the title hidden makes the title bar 38 pt, and AppKit
35 centres the traffic lights in it beside the row's buttons; the row ends 4 pt
36 short of it, so the tabs stand as near the buttons as a tab bar would. A
37 press in the row's empty space drags the window and a double press zooms or
38 minimizes it, as Desktop & Dock says; presses in a group of buttons do
39 neither, so AppKit is told the view never moves the window. The title is still set, for the Window menu, Mission Control and
3740 VoiceOver. `--screenshot` paints the lights where the hidden window's AppKit
3841 put them. The app draws the row as part of the same frame as the rest of the
3942 chrome. Under the whole
arc/ui.md+2-1
......@@ -81,7 +81,8 @@ frame N
8181 pixels, by its label, as a fraction of an ancestor, or by its children. When
8282 siblings overflow, space sized from an ancestor gives way first. Then a row
8383 *folds* its groups, boxes with a full and a folded form (`Spec::fold`), by
84 priority. Only then do boxes give up room by their *strictness*, the least
84 priority, reaching into its boxes sized by their children, so a group can
85 fold inside another. Only then do boxes give up room by their *strictness*, the least
8586 strict first. That one knob covers most of what flexbox is usually needed
8687 for. A box filling across a parent sized by its children stretches to what
8788 its siblings make it, and a popup that isn't strict gives way to the window,
crates/snowbound/assets/icons/styles.svg+2-6
......@@ -1,8 +1,4 @@
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
2 <defs>
3 <linearGradient id="g0" x2="0" y2="1"><stop offset="0" stop-color="#79c2ff"/><stop offset="1" stop-color="#1f7cf0"/></linearGradient>
4 </defs>
5 <path d="M2.8 2.35H13.2A0.8 0.8 0 0 1 14 3.15V5.15A0.8 0.8 0 0 1 13.2 5.95H2.8A0.8 0.8 0 0 1 2 5.15V3.15A0.8 0.8 0 0 1 2.8 2.35Z" fill="#000000" fill-opacity="0.16"/>
6 <path class="accent" d="M2.8 1.6H13.2A0.8 0.8 0 0 1 14 2.4V4.4A0.8 0.8 0 0 1 13.2 5.2H2.8A0.8 0.8 0 0 1 2 4.4V2.4A0.8 0.8 0 0 1 2.8 1.6Z" fill="url(#g0)" stroke="#1560c4" stroke-width="0.8" stroke-linecap="round" stroke-linejoin="round"/>
7 <path d="M2 7.5H11V9H2ZM2 10.5H14V11.5H2ZM2 13H12V14H2Z" fill="currentColor"/>
2 <path d="M1.4 13.4L4.8 2.6L8.2 13.4M2.6 9.7H7" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/>
3 <path class="accent" d="M14 11.15A2.1 2.1 0 1 1 9.8 11.15A2.1 2.1 0 1 1 14 11.15ZM14 8.7V13.3" fill="none" stroke="#1f6fe0" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"/>
84</svg>
crates/snowbound/src/aqua.rs+3-2
......@@ -77,7 +77,7 @@ pub fn cover_border_line(ui: &mut ui::Ui, width: f32) {
7777 ui::Spec {
7878 flags: ui::Flags::FLOAT,
7979 size: [ui::px(width), ui::px(1.0)],
80 position: [0.0, crate::TITLE + crate::TAB_ROW - 1.0],
80 position: [0.0, crate::TOOLBAR + crate::TAB_ROW - 1.0],
8181 fill: Some(draw::srgb(grey, grey, grey)),
8282 ..ui::Spec::default()
8383 },
......@@ -85,7 +85,8 @@ pub fn cover_border_line(ui: &mut ui::Ui, width: f32) {
8585}
8686
8787/// `-mouseDownCanMoveWindow` for the content view: a textured window drags from any press
88/// on a view that allows it, asking once. The app drags from its toolbar's empty space
88/// on a view that allows it, and a title bar the view lies under drags and zooms from it,
89/// buttons drawn there included. The app drags and zooms from its toolbar's empty space
8990/// itself, through `-performWindowDragWithEvent:`.
9091pub extern "C" fn no_window_drags(_: &AnyObject, _: Sel) -> Bool {
9192 Bool::NO
crates/snowbound/src/macos.rs+21-11
......@@ -459,7 +459,7 @@ pub fn install_backdrop(window: &Window) -> bool {
459459 // Before 10.10 AppKit has no materials; 10.6's textured window stands in, its gradient
460460 // running through the toolbar and the tab row to the notebook's frame.
461461 let Some(effect) = AnyClass::get("NSVisualEffectView") else {
462 return crate::aqua::textured(window, crate::TITLE + crate::TAB_ROW);
462 return crate::aqua::textured(window, crate::TOOLBAR + crate::TAB_ROW);
463463 };
464464 let view = &*ns_view(window);
465465 unsafe {
......@@ -1068,15 +1068,27 @@ pub fn set_min_size(window: &Window, size: [f32; 2]) {
10681068 window.set_min_inner_size(Some(winit::dpi::LogicalSize::new(size[0], size[1])));
10691069}
10701070
1071/// Zooms the window once the current event is handled: AppKit's zoom animation runs its
1072/// own loop, and started from inside winit's handler it would hold every resize until the
1073/// end, stretching the last frame instead of drawing each step.
1071/// Zooms or minimizes the window, as a double click on a title bar does, once the current
1072/// event is handled: AppKit's zoom animation runs its own loop, and started from inside
1073/// winit's handler it would hold every resize until the end, stretching the last frame
1074/// instead of drawing each step.
10741075pub fn zoom(window: &Window) {
10751076 let window = ns_window(window);
1077 // What a double click on a title bar does, as Desktop & Dock's setting says.
1078 let action: Option<Retained<NSString>> = unsafe {
1079 let defaults: Retained<AnyObject> =
1080 msg_send_id![class!(NSUserDefaults), standardUserDefaults];
1081 msg_send_id![&defaults, stringForKey: &*NSString::from_str("AppleActionOnDoubleClick")]
1082 };
1083 let selector = match action.map(|action| action.to_string()).as_deref() {
1084 Some("None") => return,
1085 Some("Minimize") => sel!(miniaturize:),
1086 _ => sel!(zoom:),
1087 };
10761088 unsafe {
10771089 let _: () = msg_send![
10781090 &window,
1079 performSelector: sel!(zoom:),
1091 performSelector: selector,
10801092 withObject: std::ptr::null::<AnyObject>(),
10811093 afterDelay: 0.0f64
10821094 ];
......@@ -1096,12 +1108,10 @@ pub fn install_text_input(window: &Window) {
10961108 sel!(insertText:replacementRange:),
10971109 insert_text as unsafe extern "C" fn(_, _, _, _),
10981110 );
1099 if crate::aqua::before_lion() {
1100 class.add_method(
1101 sel!(mouseDownCanMoveWindow),
1102 crate::aqua::no_window_drags as extern "C" fn(_, _) -> _,
1103 );
1104 }
1111 class.add_method(
1112 sel!(mouseDownCanMoveWindow),
1113 crate::aqua::no_window_drags as extern "C" fn(_, _) -> _,
1114 );
11051115 class.register()
11061116 });
11071117 assert_eq!(class.superclass(), Some(view.class()));
crates/snowbound/src/main.rs+293-210
......@@ -134,9 +134,13 @@ use winit::{
134134 window::CursorIcon,
135135};
136136
137/// Height of the toolbar's row, which is the title bar where the platform lets it: a
138/// unified compact toolbar's on macOS.
137/// Height of the title bar the toolbar's buttons centre in: a unified compact toolbar's on
138/// macOS, whose traffic lights they line up with.
139139const TITLE: f32 = 38.0;
140/// Height of the toolbar's row, which is the title bar where the platform lets it. Its foot
141/// is short of the title bar's, so the tabs below stand as near its buttons as a tab bar
142/// under a compact toolbar does.
143const TOOLBAR: f32 = TITLE - 4.0;
140144/// Space between toolbar groups.
141145const GAP: f32 = 6.0;
142146/// A tool face's room beside its buttons.
......@@ -898,6 +902,8 @@ struct State {
898902 strip_press: Option<Instant>,
899903 /// The strip is held and the window moves once the pointer does.
900904 strip_held: bool,
905 /// The zoom typed in the zoom's field while it is open.
906 zoom_typed: Option<String>,
901907 /// Where a replay asked the next frame to be written.
902908 snapshot: Option<PathBuf>,
903909 /// A replay waiting for nothing to be on its way, and where it wants the accessibility
......@@ -1247,6 +1253,7 @@ impl State {
12471253 pointer: [0.0; 2],
12481254 strip_press: None,
12491255 strip_held: false,
1256 zoom_typed: None,
12501257 snapshot: None,
12511258 replay_settle: None,
12521259 frames: std::env::var_os("SNOWBOUND_FRAMES").map(|dir| (dir.into(), Instant::now())),
......@@ -1514,7 +1521,7 @@ impl State {
15141521 if !self.navigation_bar_right {
15151522 self.sidebar(&theme, sidebar);
15161523 }
1517 // Where the notebook button floats over the tab row while the sidebar is shut.
1524 // Where the buttons float over the tab row while the sidebar is shut.
15181525 let beside = 1.0 - sidebar / sidebar::WIDTH;
15191526 self.ui.open(
15201527 "main",
......@@ -1544,14 +1551,19 @@ impl State {
15441551 );
15451552 if !self.temporary {
15461553 // Past the row's padding, the frame's corner and the first tab's shadow, the tabs'
1547 // outlines start where the button's square ends; the room shrinks on the sidebar's
1548 // easing, so the tabs ease with it.
1549 let room = TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0];
1550 let room = if self.navigation_bar_right { 0.0 } else { room };
1554 // outlines start where Back and Forward end, and on the left the notebook button's
1555 // square after them; that room shrinks on the sidebar's easing, as they move into
1556 // its header, so the tabs ease with it.
1557 let room = sidebar::NAV + TAB_ROW - FRAME - self.rounding() - ui::SHADOW[0];
1558 let room = if self.navigation_bar_right {
1559 room - TAB_ROW
1560 } else {
1561 room * beside
1562 };
15511563 self.ui.leaf(
15521564 "rail",
15531565 Spec {
1554 size: [px(room * beside), px(1.0)],
1566 size: [px(room), px(1.0)],
15551567 ..Spec::default()
15561568 },
15571569 );
......@@ -1675,6 +1687,7 @@ impl State {
16751687 #[cfg(feature = "live")]
16761688 self.avatars();
16771689 if self.session.is_some() {
1690 self.zoom(&theme)?;
16781691 self.page_tools(&theme);
16791692 }
16801693 let button = if self.navigation_bar_right && !self.temporary {
......@@ -1737,7 +1750,7 @@ impl State {
17371750 if self.navigation_bar_right {
17381751 self.sidebar(&theme, sidebar);
17391752 }
1740 self.sidebar_button(height);
1753 self.sidebar_button(height, sidebar);
17411754 self.ui.close();
17421755 self.context_menu();
17431756 return Ok((section, open_tab, None));
......@@ -1750,7 +1763,7 @@ impl State {
17501763 if self.navigation_bar_right {
17511764 self.sidebar(&theme, sidebar);
17521765 }
1753 self.sidebar_button(height);
1766 self.sidebar_button(height, sidebar);
17541767 self.ui.close();
17551768 self.context_menu();
17561769 return Ok((section, open_tab, None));
......@@ -1836,7 +1849,7 @@ impl State {
18361849 self.sidebar(&theme, sidebar);
18371850 }
18381851 self.task_pane(&theme);
1839 self.sidebar_button(height);
1852 self.sidebar_button(height, sidebar);
18401853 self.ui.close();
18411854 self.context_menu();
18421855 // No tab stands on the frame in Full Page View.
......@@ -2025,7 +2038,7 @@ impl State {
20252038 } else {
20262039 Flags::default()
20272040 },
2028 size: [fill(), px(TITLE)],
2041 size: [fill(), px(TOOLBAR)],
20292042 pad: [0.0, (TITLE - ui::shell::TOOL) / 2.0],
20302043 gap: GAP,
20312044 role: Some(accesskit::Role::Toolbar),
......@@ -2042,7 +2055,7 @@ impl State {
20422055 self.ui.open(
20432056 "bar",
20442057 Spec {
2045 size: [fill(), px(TITLE)],
2058 size: [fill(), px(TOOLBAR)],
20462059 fill: Some(theme.strip),
20472060 pad: [0.0, edge],
20482061 offset: [0.0, -edge],
......@@ -2070,7 +2083,7 @@ impl State {
20702083 "tools",
20712084 Spec {
20722085 flags: Flags::CLIP,
2073 size: [fill(), px(TITLE)],
2086 size: [fill(), px(TOOLBAR)],
20742087 pad: [face.pad[0], edge],
20752088 offset: [0.0, -edge],
20762089 gap: if theme.tool[3] > 0.0 && !panel {
......@@ -2121,7 +2134,6 @@ impl State {
21212134 use Entry::{Open, Rule, Run};
21222135 use canvas::editor::{Alignment, BULLET_LIBRARY, ListStyle, NUMBER_LIBRARY, Toggle};
21232136 use commands::{Choice, Id as Cmd};
2124 use ui::shell::TOOL;
21252137 // A focused picture has no text to show a format for.
21262138 let state = self.format_state();
21272139 let statuses: Vec<_> = commands::COMMANDS
......@@ -2145,90 +2157,73 @@ impl State {
21452157 let engine = &self.view.engine;
21462158 let session = self.session.as_ref();
21472159 let update = self.updates.status();
2148 let label = format!("{:.0}%", self.view.zoom() * 100.0);
21492160 let sheet = self.gallery_sheet();
21502161 let drawing_pens = self.pens();
21512162 let ui = &mut self.ui;
21522163 let text = theme.text;
21532164 let tags: Vec<_> = self.tags.iter().enumerate().collect();
2165 let face = tool_face(theme, grouped(theme), FACE_RISE);
21542166 let mut choice = group(
21552167 ui,
21562168 "navigate",
21572169 6,
2170 face.clone(),
21582171 |ui| {
21592172 let mut choice = None;
2160 for id in [Cmd::Back, Cmd::Forward, Cmd::Undo, Cmd::Redo] {
2173 for id in [Cmd::Undo, Cmd::Redo] {
21612174 choice = tool(ui, id, status_of(id)).or(choice);
21622175 }
21632176 choice
21642177 },
21652178 |ui| {
2166 let entries = [Run(Cmd::Redo), Rule, Run(Cmd::Back), Run(Cmd::Forward)];
2167 dropdown(ui, "menu", Head::Split(Cmd::Undo), &entries, status_of)
2168 },
2169 );
2170 choice = group(
2171 ui,
2172 "clipboard",
2173 5,
2174 |ui| {
2175 divider(ui, theme);
2176 let mut choice = None;
2177 let paste = ui.id("paste");
2178 if !status_of(Cmd::Paste).enabled {
2179 ui::shell::unavailable(ui, "paste", art::PASTE, text, true);
2180 } else if ui::shell::split_button(
2179 dropdown(
21812180 ui,
2182 "paste",
2183 title(Cmd::Paste),
2184 art::PASTE,
2185 None,
2186 None,
2187 toolbar_popup("paste"),
2181 "menu",
2182 Head::Split(Cmd::Undo),
2183 &[Run(Cmd::Redo)],
2184 status_of,
21882185 )
2189 .clicked
2190 {
2191 choice = Some(Choice::Command(Cmd::Paste));
2192 }
2193 tip(ui, Cmd::Paste);
2194 // Pasting keeps only the text until #25.
2195 let options = [
2196 ("Keep Source Formatting", false),
2197 ("Merge Formatting", false),
2198 ("Keep Text Only", true),
2199 ("Picture", false),
2200 ];
2201 let mut items = vec![ui::popup::Item {
2202 text: "Paste Options",
2203 heading: true,
2204 ..Default::default()
2205 }];
2206 items.extend(options.map(|(text, enabled)| ui::popup::Item {
2207 text,
2208 disabled: !enabled,
2209 ..Default::default()
2210 }));
2211 let anchor = ui::Anchor::Below(ui.rect(paste).unwrap_or_default());
2212 if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() {
2213 choice = Some(Choice::Command(Cmd::Paste));
2214 }
2215 for id in [Cmd::Cut, Cmd::Copy, Cmd::FormatPainter] {
2216 choice = tool(ui, id, status_of(id)).or(choice);
2217 }
2218 choice
22192186 },
2220 |ui| {
2221 let entries = [
2222 Run(Cmd::Cut),
2223 Run(Cmd::Copy),
2224 Run(Cmd::FormatPainter),
2225 Rule,
2226 Open("paste", Cmd::Paste),
2227 ];
2228 dropdown(ui, "menu", Head::Split(Cmd::Paste), &entries, status_of)
2187 );
2188 ui.open(
2189 "clipboard",
2190 Spec {
2191 flags: Flags::CLICKABLE,
2192 ..face.clone()
22292193 },
2230 )
2231 .or(choice);
2194 );
2195 divider(ui, theme);
2196 let entries = [
2197 Run(Cmd::Paste),
2198 Open("paste", Cmd::Paste),
2199 Rule,
2200 Run(Cmd::Cut),
2201 Run(Cmd::Copy),
2202 ];
2203 let head = Head::Menu("Clipboard", art::PASTE);
2204 choice = dropdown(ui, "menu", head, &entries, status_of).or(choice);
2205 // Pasting keeps only the text until #25.
2206 let options = [
2207 ("Keep Source Formatting", false),
2208 ("Merge Formatting", false),
2209 ("Keep Text Only", true),
2210 ("Picture", false),
2211 ];
2212 let mut items = vec![ui::popup::Item {
2213 text: "Paste Options",
2214 heading: true,
2215 ..Default::default()
2216 }];
2217 items.extend(options.map(|(text, enabled)| ui::popup::Item {
2218 text,
2219 disabled: !enabled,
2220 ..Default::default()
2221 }));
2222 let anchor = ui::Anchor::Below(ui.rect(ui.id("menu")).unwrap_or_default());
2223 if ui::popup::menu(ui, toolbar_popup("paste"), anchor, &items, None).is_some() {
2224 choice = Some(Choice::Command(Cmd::Paste));
2225 }
2226 ui.close();
22322227 let font = state.font.clone().unwrap_or_default();
22332228 let size = state
22342229 .font_size
......@@ -2236,12 +2231,13 @@ impl State {
22362231 ui.open(
22372232 "font",
22382233 Spec {
2234 flags: Flags::CLICKABLE,
22392235 gap: 1.0,
2240 ..tool_face(theme, grouped(theme), FACE_RISE)
2236 ..face.clone()
22412237 },
22422238 );
22432239 divider(ui, theme);
2244 // Styles sits left of the font box (Clover, 2026-09-30), and like it never folds.
2240 // Styles, the font and the size never fold.
22452241 let styles = toolbar_popup("styles");
22462242 let anchor = if status_of(Cmd::Styles).enabled {
22472243 ui::shell::menu_button(ui, "styles", art::STYLES, None, styles)
......@@ -2342,14 +2338,22 @@ impl State {
23422338 let id = Cmd::Toggle(toggle);
23432339 choice = tool(ui, id, status_of(id)).or(choice);
23442340 }
2345 ui.close();
2341 let effects = [
2342 Run(Cmd::Toggle(Toggle::Strikethrough)),
2343 Run(Cmd::Toggle(Toggle::Subscript)),
2344 Run(Cmd::Toggle(Toggle::Superscript)),
2345 Rule,
2346 Run(Cmd::ClearFormatting),
2347 ];
2348 // The rest of the group folds inside it, on its face.
23462349 choice = group(
23472350 ui,
23482351 "character",
23492352 8,
2353 Spec::default(),
23502354 |ui| {
2351 divider(ui, theme);
2352 let mut choice = None;
2355 let head = Head::Menu("Text Effects", art::STRIKETHROUGH);
2356 let mut choice = dropdown(ui, "script", head, &effects, status_of);
23532357 // Each button applies its menu's last pick: the highlighter shows it in its
23542358 // artwork, the font colour in a bar.
23552359 for (part, id, swatches, columns, none, bar) in [
......@@ -2410,37 +2414,23 @@ impl State {
24102414 });
24112415 }
24122416 }
2413 dropdown(
2414 ui,
2415 "script",
2416 Head::Menu("Text Effects", art::STRIKETHROUGH),
2417 &[
2418 Run(Cmd::Toggle(Toggle::Strikethrough)),
2419 Run(Cmd::Toggle(Toggle::Subscript)),
2420 Run(Cmd::Toggle(Toggle::Superscript)),
2421 Rule,
2422 Run(Cmd::ClearFormatting),
2423 ],
2424 status_of,
2425 )
2426 .or(choice)
2417 tool(ui, Cmd::FormatPainter, status_of(Cmd::FormatPainter)).or(choice)
24272418 },
24282419 |ui| {
2429 let entries = [
2420 let mut entries = effects.to_vec();
2421 entries.extend([
2422 Rule,
24302423 Open("highlight", Cmd::Highlight),
24312424 Run(Cmd::FontColor),
24322425 Open("color", Cmd::FontColor),
24332426 Rule,
2434 Run(Cmd::Toggle(Toggle::Strikethrough)),
2435 Run(Cmd::Toggle(Toggle::Subscript)),
2436 Run(Cmd::Toggle(Toggle::Superscript)),
2437 Rule,
2438 Run(Cmd::ClearFormatting),
2439 ];
2427 Run(Cmd::FormatPainter),
2428 ]);
24402429 dropdown(ui, "menu", Head::Split(Cmd::Highlight), &entries, status_of)
24412430 },
24422431 )
24432432 .or(choice);
2433 ui.close();
24442434 let alignments = [Alignment::Left, Alignment::Center, Alignment::Right].map(Cmd::Align);
24452435 let paragraph = [
24462436 Rule,
......@@ -2455,6 +2445,7 @@ impl State {
24552445 ui,
24562446 "paragraph",
24572447 7,
2448 face.clone(),
24582449 |ui| {
24592450 divider(ui, theme);
24602451 let mut choice = None;
......@@ -2603,6 +2594,7 @@ impl State {
26032594 ui,
26042595 "tags",
26052596 9,
2597 face.clone(),
26062598 |ui| {
26072599 divider(ui, theme);
26082600 let mut choice = None;
......@@ -2659,6 +2651,7 @@ impl State {
26592651 ui,
26602652 "insert",
26612653 4,
2654 face.clone(),
26622655 |ui| {
26632656 divider(ui, theme);
26642657 let mut choice = None;
......@@ -2742,6 +2735,7 @@ impl State {
27422735 ui,
27432736 part,
27442737 priority,
2738 face.clone(),
27452739 |ui| {
27462740 divider(ui, theme);
27472741 let mut choice = None;
......@@ -2760,6 +2754,7 @@ impl State {
27602754 ui,
27612755 "draw",
27622756 2,
2757 face.clone(),
27632758 |ui| {
27642759 divider(ui, theme);
27652760 let mut choice = tool(ui, Cmd::SelectType, status_of(Cmd::SelectType));
......@@ -2805,6 +2800,40 @@ impl State {
28052800 |_| None,
28062801 )
28072802 .or(choice);
2803 // Folded, Page Color stays in the view's menu at the row's end.
2804 choice = group(
2805 ui,
2806 "page color",
2807 1,
2808 face.clone(),
2809 |ui| {
2810 divider(ui, theme);
2811 if !status_of(Cmd::PageColor).enabled {
2812 ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true);
2813 tip(ui, Cmd::PageColor);
2814 return None;
2815 }
2816 let menu = toolbar_popup("page color");
2817 let anchor = ui::shell::menu_button(ui, "page color", art::PAGE_COLOR, None, menu);
2818 tip(ui, Cmd::PageColor);
2819 background::menu(
2820 ui,
2821 menu,
2822 anchor,
2823 &mut self.thumbnails,
2824 &mut self.templates,
2825 // The page's own colour aside.
2826 canvas::gpu::Paper {
2827 color: ui.theme.paper,
2828 ink: ui.theme.paper_ink,
2829 },
2830 self.view.editor.page_color(),
2831 self.view.editor.rule_lines(),
2832 )
2833 },
2834 |_| None,
2835 )
2836 .or(choice);
28082837 ui.leaf(
28092838 "space",
28102839 Spec {
......@@ -2815,7 +2844,14 @@ impl State {
28152844 if let Some(session) = session {
28162845 sync::control(ui, session, &update, theme);
28172846 }
2847 // The view's commands, with zoom and Page Color for where theirs have folded away.
28182848 let views = [
2849 Run(Cmd::ZoomIn),
2850 Run(Cmd::ZoomOut),
2851 Run(Cmd::ActualSize),
2852 Rule,
2853 Open("page color", Cmd::PageColor),
2854 Rule,
28192855 Run(Cmd::Sidebar),
28202856 Run(Cmd::PageList),
28212857 Run(Cmd::PagesMatchTheme),
......@@ -2824,95 +2860,151 @@ impl State {
28242860 Run(Cmd::HideSpelling),
28252861 Run(Cmd::Spelling),
28262862 ];
2827 choice = group(
2828 ui,
2863 ui.open(
28292864 "view",
2830 1,
2831 |ui| {
2832 divider(ui, theme);
2833 let mut choice = None;
2834 if status_of(Cmd::PageColor).enabled {
2835 let anchor = ui::shell::menu_button(
2836 ui,
2837 "page color",
2838 art::PAGE_COLOR,
2839 None,
2840 toolbar_popup("page color"),
2841 );
2842 tip(ui, Cmd::PageColor);
2843 choice = background::menu(
2844 ui,
2845 toolbar_popup("page color"),
2846 anchor,
2847 &mut self.thumbnails,
2848 &mut self.templates,
2849 // The page's own colour aside.
2850 canvas::gpu::Paper {
2851 color: ui.theme.paper,
2852 ink: ui.theme.paper_ink,
2853 },
2854 self.view.editor.page_color(),
2855 self.view.editor.rule_lines(),
2856 )
2857 .or(choice.take());
2858 } else {
2859 ui::shell::unavailable(ui, "page color", art::PAGE_COLOR, text, true);
2860 tip(ui, Cmd::PageColor);
2861 }
2862 choice = tool(ui, Cmd::ZoomOut, status_of(Cmd::ZoomOut)).or(choice);
2863 let level = ui.leaf(
2864 "level",
2865 Spec {
2866 flags: Flags::CLICKABLE,
2867 size: [px(44.0), px(TOOL)],
2868 text: Some(&label),
2869 hover_fill: Some(theme.hover()),
2870 radius: 4.0,
2871 center: true,
2872 role: Some(accesskit::Role::Button),
2873 ..Spec::default()
2874 },
2875 );
2876 if level.clicked {
2877 choice = Some(Choice::Command(Cmd::ActualSize));
2878 }
2879 tip(ui, Cmd::ActualSize);
2880 if let Some(node) = ui.access(ui.id("level")) {
2881 node.set_value(label.as_str());
2882 }
2883 choice = tool(ui, Cmd::ZoomIn, status_of(Cmd::ZoomIn)).or(choice);
2884 dropdown(
2885 ui,
2886 "view",
2887 Head::More("More View Options"),
2888 &views,
2889 status_of,
2890 )
2891 .or(choice)
2865 Spec {
2866 flags: Flags::CLICKABLE,
2867 ..face.clone()
28922868 },
2893 |ui| {
2894 let mut entries = vec![
2895 Run(Cmd::ZoomIn),
2896 Run(Cmd::ZoomOut),
2897 Run(Cmd::ActualSize),
2898 Rule,
2899 Open("page color", Cmd::PageColor),
2900 Rule,
2901 ];
2902 entries.extend(views);
2903 dropdown(
2904 ui,
2905 "menu",
2906 Head::Menu("View", art::ZOOM_IN),
2907 &entries,
2908 status_of,
2909 )
2869 );
2870 let head = Head::More("More View Options");
2871 choice = dropdown(ui, "more", head, &views, status_of).or(choice);
2872 ui.close();
2873 if let Some(choice) = choice {
2874 self.choose(choice);
2875 }
2876 }
2877
2878 /// The page's zoom, left of the search box: Zoom Out, the level and Zoom In joined as one
2879 /// control, which folds away first where the tab row lacks room. Clicking the level opens
2880 /// it as a field, where Enter zooms to the percentage typed and Escape keeps the zoom.
2881 fn zoom(&mut self, theme: &Theme) -> Result<(), Box<dyn Error>> {
2882 use commands::{Choice, Id as Cmd};
2883 use ui::shell::TOOL;
2884 const LEVEL: f32 = 44.0;
2885 let format = self.format_state();
2886 let [out, into] =
2887 [Cmd::ZoomOut, Cmd::ZoomIn].map(|id| self.status(&Choice::Command(id), &format));
2888 let level = format!("{:.0}%", self.view.zoom() * 100.0);
2889 let field = self.ui.id("zoom field");
2890 if self.ui.focused() != Some(field) {
2891 self.zoom_typed = None;
2892 }
2893 self.ui.open(
2894 "zoom",
2895 Spec {
2896 flags: Flags::CLICKABLE,
2897 fold: Some(0),
2898 ..Spec::default()
29102899 },
2911 )
2912 .or(choice);
2900 );
2901 self.ui.open(
2902 "full",
2903 Spec {
2904 size: [children(), px(TAB_ROW)],
2905 // As far from the search box as the page list's buttons are apart.
2906 pad: [3.0, (TAB_ROW - TOOL) / 2.0],
2907 ..Spec::default()
2908 },
2909 );
2910 let typing = self.zoom_typed.is_some();
2911 self.ui.open(
2912 "control",
2913 Spec {
2914 size: [children(), px(TOOL)],
2915 fill: Some(theme.base),
2916 border: Some(if typing { theme.accent } else { theme.chip }),
2917 radius: 4.0,
2918 ..Spec::default()
2919 },
2920 );
2921 let line = |ui: &mut Ui, part| {
2922 ui.leaf(
2923 part,
2924 Spec {
2925 size: [px(1.0), px(TOOL)],
2926 fill: Some(theme.chip),
2927 ..Spec::default()
2928 },
2929 );
2930 };
2931 let mut choice = tool(&mut self.ui, Cmd::ZoomOut, out);
2932 line(&mut self.ui, "before");
2933 let mut zoom = None;
2934 if let Some(typed) = &mut self.zoom_typed {
2935 let signal = ui::text_field(
2936 &mut self.ui,
2937 field,
2938 typed,
2939 "",
2940 Spec {
2941 size: [px(LEVEL), px(TOOL)],
2942 pad: [4.0, 0.0],
2943 ..Spec::default()
2944 },
2945 );
2946 name(&mut self.ui, field, "Zoom");
2947 let key = signal.events.iter().find_map(|event| match event {
2948 ui::Event::Key {
2949 key: Key::Named(key @ (NamedKey::Enter | NamedKey::Escape)),
2950 ..
2951 } => Some(*key),
2952 _ => None,
2953 });
2954 if let Some(key) = key {
2955 let percent = typed.trim().trim_end_matches('%').trim().parse::<f32>();
2956 zoom = percent.ok().filter(|_| key == NamedKey::Enter);
2957 self.zoom_typed = None;
2958 self.ui.set_focus(Some(page()));
2959 }
2960 } else {
2961 let id = self.ui.id("level");
2962 let signal = self.ui.leaf(
2963 "level",
2964 Spec {
2965 // Zoom applies where its buttons do.
2966 flags: if out.enabled {
2967 Flags::CLICKABLE
2968 } else {
2969 Flags::default()
2970 },
2971 size: [px(LEVEL), px(TOOL)],
2972 text: Some(&level),
2973 color: Some(if out.enabled {
2974 theme.text
2975 } else {
2976 theme.text_dim
2977 }),
2978 hover_fill: Some(theme.hover()),
2979 center: true,
2980 role: Some(accesskit::Role::Button),
2981 ..Spec::default()
2982 },
2983 );
2984 if let Some(node) = self.ui.access(id) {
2985 node.set_label("Zoom");
2986 node.set_value(level.as_str());
2987 }
2988 if signal.clicked {
2989 self.zoom_typed = Some(level.trim_end_matches('%').to_owned());
2990 self.ui.focus_all(field);
2991 }
2992 }
2993 line(&mut self.ui, "after");
2994 choice = tool(&mut self.ui, Cmd::ZoomIn, into).or(choice);
2995 self.ui.close();
2996 self.ui.close();
2997 self.ui.open("folded", Spec::default());
2998 self.ui.close();
2999 self.ui.close();
29133000 if let Some(choice) = choice {
29143001 self.choose(choice);
29153002 }
3003 if let Some(percent) = zoom {
3004 let response = self.view.set_zoom(percent / 100.0)?;
3005 self.respond(response);
3006 }
3007 Ok(())
29163008 }
29173009
29183010 /// The search box and the page list's buttons, above the list. Where the tab row lacks
......@@ -2920,10 +3012,12 @@ impl State {
29203012 /// on the page it stays.
29213013 fn page_tools(&mut self, theme: &Theme) {
29223014 let finding = self.search.finding;
3015 // It folds after the zoom.
29233016 self.ui.open(
29243017 "tools",
29253018 Spec {
2926 fold: (!finding).then_some(0),
3019 flags: Flags::CLICKABLE,
3020 fold: (!finding).then_some(1),
29273021 ..Spec::default()
29283022 },
29293023 );
......@@ -5131,19 +5225,21 @@ fn pen_gallery(
51315225 )
51325226}
51335227
5134/// A toolbar group that folds by `priority`: `full` builds its full form, `folded` the form
5135/// it folds to. Returns what either chose.
5228/// A toolbar group on `face` that folds by `priority`: `full` builds its full form, `folded`
5229/// the form it folds to. Returns what either chose. Like every group, it takes presses
5230/// between its buttons, so only the row's empty space drags and zooms the window.
51365231fn group(
51375232 ui: &mut Ui,
51385233 part: &str,
51395234 priority: u32,
5235 face: Spec<'static>,
51405236 full: impl FnOnce(&mut Ui) -> Option<commands::Choice>,
51415237 folded: impl FnOnce(&mut Ui) -> Option<commands::Choice>,
51425238) -> Option<commands::Choice> {
5143 let face = tool_face(&ui.theme, grouped(&ui.theme), FACE_RISE);
51445239 ui.open(
51455240 part,
51465241 Spec {
5242 flags: Flags::CLICKABLE,
51475243 fold: Some(priority),
51485244 ..face
51495245 },
......@@ -5239,13 +5335,9 @@ fn dropdown(
52395335 anchor
52405336 }
52415337 // A menu of nothing that applies fades as a button would.
5242 Head::Menu(label, _) | Head::Tools(label, _) | Head::More(label) if !usable => {
5243 let (icon, arrow) = match head {
5244 Head::Menu(_, icon) | Head::Tools(_, icon) => (icon, true),
5245 _ => (ui::shell::CHEVRON, false),
5246 };
5338 Head::Menu(label, icon) | Head::Tools(label, icon) if !usable => {
52475339 let tint = ui.theme.text;
5248 ui::shell::unavailable(ui, part, icon, tint, arrow);
5340 ui::shell::unavailable(ui, part, icon, tint, true);
52495341 name(ui, button, label);
52505342 ui::Anchor::Below(ui.rect(button).unwrap_or_default())
52515343 }
......@@ -5264,18 +5356,9 @@ fn dropdown(
52645356 anchor
52655357 }
52665358 Head::More(label) => {
5267 let open = ui.popup_open(menu);
5268 let text = ui.theme.text;
5269 if ui::shell::tool_button(ui, part, ui::shell::CHEVRON, text, Some(open)).pressed {
5270 ui.open_popup(menu);
5271 }
5272 if let Some(node) = ui.access(button) {
5273 node.set_label(label);
5274 node.clear_toggled();
5275 node.set_has_popup(accesskit::HasPopup::Menu);
5276 node.set_expanded(open);
5277 }
5278 ui::Anchor::Below(ui.rect(button).unwrap_or_default())
5359 let anchor = ui::shell::more_button(ui, part, menu, usable);
5360 name(ui, button, label);
5361 anchor
52795362 }
52805363 };
52815364 let keys: Vec<_> = entries
crates/snowbound/src/screenshot/snow_leopard.rs+1-1
......@@ -181,7 +181,7 @@ fn window(
181181) -> Result<Vec<[f32; 4]>, Box<dyn Error>> {
182182 let [highlight, top, bottom, body] = GREYS[usize::from(!key)];
183183 // The gradient runs from under the highlight to the top content border's last row.
184 let end = TITLE_LINE + (crate::TITLE + crate::TAB_ROW) as usize - 1;
184 let end = TITLE_LINE + (crate::TOOLBAR + crate::TAB_ROW) as usize - 1;
185185 let total = height + TITLE_LINE;
186186 let mut pixels: Vec<[f32; 4]> = (0..total)
187187 .flat_map(|y| {
crates/snowbound/src/sidebar.rs+86-45
......@@ -18,6 +18,13 @@ const RAIL: f32 = crate::TAB_ROW;
1818/// on the row's foot, so their labels sit 1 to 3 pixels low.
1919const DROP: f32 = 2.0;
2020const ROW: f32 = 24.0;
21/// The tree's margin, and how far inside a row its icon starts and its label after that; the
22/// header's title and the notebook button's icon, while the sidebar is open, line up with them.
23const MARGIN: f32 = 4.0;
24const ROW_PAD: f32 = 6.0;
25const ICON: f32 = 16.0;
26/// The Back and Forward buttons' box, with room beside them as the notebook button's square has.
27pub const NAV: f32 = 2.0 * ui::shell::TOOL + 1.0 + (RAIL - ui::shell::TOOL);
2128/// How far each level of the tree sits inside its parent.
2229const INDENT: f32 = 16.0;
2330
......@@ -136,7 +143,8 @@ pub fn header() -> Id {
136143
137144/// The sidebar's header row, as tall as the tab row and dragging the window when `drags`, then
138145/// with `rows` the tree of `notebooks` with the open section marked. The notebook button
139/// floats over the header's end at the window's edge, the `right` one or the left.
146/// floats over the header's icon on the left, or its end on the `right`, where the Back and
147/// Forward buttons otherwise do.
140148fn sidebar(
141149 ui: &mut Ui,
142150 tree: &mut Tree,
......@@ -155,36 +163,36 @@ fn sidebar(
155163 Flags::default()
156164 },
157165 size: [fill(), px(RAIL)],
158 pad: [crate::FRAME, (RAIL - ui::shell::TOOL) / 2.0 + DROP],
159 gap: 6.0,
166 pad: [MARGIN + ROW_PAD, (RAIL - ui::shell::TOOL) / 2.0 + DROP],
167 gap: ROW_PAD,
168 ..Spec::default()
169 },
170 );
171 ui.leaf(
172 "icon",
173 Spec {
174 size: [px(ICON), px(ui::shell::TOOL)],
160175 ..Spec::default()
161176 },
162177 );
163 // The notebook button's square, which floats over this place.
164 let toggle = |ui: &mut Ui| {
165 ui.leaf(
166 "toggle",
167 Spec {
168 size: [px(RAIL - crate::FRAME), px(ui::shell::TOOL)],
169 ..Spec::default()
170 },
171 );
172 };
173 if !right {
174 toggle(ui);
175 }
176178 ui.leaf(
177179 "title",
178180 Spec {
179181 size: [fill(), px(ui::shell::TOOL)],
180 text: Some("Notebooks"),
182 text: Some("My Notebooks"),
181183 color: Some(theme.text_dim),
182184 ..Spec::default()
183185 },
184186 );
185 if right {
186 toggle(ui);
187 }
187 // Room for what floats over the header's end.
188 let end = if right { RAIL } else { NAV + MARGIN };
189 ui.leaf(
190 "end",
191 Spec {
192 size: [px(end - MARGIN - 2.0 * ROW_PAD), px(1.0)],
193 ..Spec::default()
194 },
195 );
188196 ui.close();
189197 if !rows {
190198 return;
......@@ -195,7 +203,7 @@ fn sidebar(
195203 flags: Flags::SCROLL | Flags::CLIP,
196204 axis: Axis::Y,
197205 size: [fill(), fill()],
198 pad: [4.0, 2.0],
206 pad: [MARGIN, 2.0],
199207 ..Spec::default()
200208 },
201209 );
......@@ -248,7 +256,7 @@ fn sidebar(
248256 Spec {
249257 axis: Axis::Y,
250258 size: [fill(), ui::children()],
251 pad: [4.0, 6.0],
259 pad: [MARGIN, 6.0],
252260 ..Spec::default()
253261 },
254262 );
......@@ -648,9 +656,13 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
648656 fill: lit,
649657 hover_fill: Some(ui::mix(lit.unwrap_or(theme.sidebar), theme.hover(), 0.6)),
650658 radius: 4.0,
651 pad: [6.0 + INDENT * row.depth as f32, 0.0],
659 pad: [ROW_PAD + INDENT * row.depth as f32, 0.0],
652660 // The field's text stands where the label did.
653 gap: if row.renamed { 6.0 - rename::PAD } else { 6.0 },
661 gap: if row.renamed {
662 ROW_PAD - rename::PAD
663 } else {
664 ROW_PAD
665 },
654666 role: Some(accesskit::Role::TreeItem),
655667 ..Spec::default()
656668 },
......@@ -670,7 +682,7 @@ fn tree_row(ui: &mut Ui, tree: &mut Tree, id: Id, row: Row) -> (Signal, bool) {
670682 ui.leaf(
671683 "icon",
672684 Spec {
673 size: [px(16.0), px(ROW)],
685 size: [px(ICON), px(ROW)],
674686 icon: Some(icon),
675687 color: Some([red, green, blue, alpha]),
676688 ..Spec::default()
......@@ -764,7 +776,7 @@ impl crate::State {
764776 ..Spec::default()
765777 },
766778 );
767 crate::name(&mut self.ui, rows_id, "Notebooks");
779 crate::name(&mut self.ui, rows_id, "My Notebooks");
768780 let shown = match (&self.session, &self.locked) {
769781 (Some(session), _) => Some((&session.library, session.tabs[session.tab].path.as_str())),
770782 (None, Some(locked)) => Some((&locked.library, locked.path.as_str())),
......@@ -840,42 +852,71 @@ impl crate::State {
840852 }
841853 }
842854
843 /// The notebook button, floating at the body's corner over the section tabs' row, `height`
844 /// tall as it eases, or over the sidebar's header while that is open.
845 pub(crate) fn sidebar_button(&mut self, height: f32) {
855 /// Back, Forward and the notebook button, floating at the body's corner over the section
856 /// tabs' row, `height` tall as it eases. While the sidebar, `width` wide as it eases, is
857 /// open on the left, the notebook button closes it from its header's icon, and Back and
858 /// Forward ride its end.
859 pub(crate) fn sidebar_button(&mut self, height: f32, width: f32) {
860 use crate::commands::{Choice, Id as Cmd};
846861 if self.temporary {
847862 return;
848863 }
849 // On the right it stands at the body's far edge, as laid out last frame.
850 let x = match self.navigation_bar_right {
851 true => self
864 let (nav, toggle) = if self.navigation_bar_right {
865 // The notebook button stands at the body's far edge, as laid out last frame.
866 let edge = self
852867 .ui
853868 .rect(self.ui.current())
854 .map_or(0.0, |[left, _, right, _]| right - left - RAIL),
855 false => 0.0,
869 .map_or(0.0, |[left, _, right, _]| right - left - RAIL);
870 (0.0, edge)
871 } else {
872 let open = MARGIN + ROW_PAD - (RAIL - ICON) / 2.0;
873 let toggle = NAV + (open - NAV) * width / WIDTH;
874 ((width - NAV - MARGIN).max(0.0), toggle)
856875 };
876 let pad = [
877 (RAIL - ui::shell::TOOL) / 2.0,
878 (RAIL - ui::shell::TOOL) / 2.0 + DROP,
879 ];
880 self.ui.open(
881 "back and forward",
882 Spec {
883 flags: Flags::FLOAT | Flags::CLIP,
884 size: [px(NAV), px(height)],
885 position: [nav, 0.0],
886 pad,
887 gap: 1.0,
888 ..Spec::default()
889 },
890 );
891 let format = self.format_state();
892 for id in [Cmd::Back, Cmd::Forward] {
893 let status = self.status(&Choice::Command(id), &format);
894 if let Some(choice) = crate::tool(&mut self.ui, id, status) {
895 self.choose(choice);
896 }
897 }
898 self.ui.close();
857899 self.ui.open(
858900 "toggle",
859901 Spec {
860902 flags: Flags::FLOAT | Flags::CLIP,
861903 size: [px(RAIL), px(height)],
862 position: [x, 0.0],
863 pad: [
864 (RAIL - ui::shell::TOOL) / 2.0,
865 (RAIL - ui::shell::TOOL) / 2.0 + DROP,
866 ],
904 position: [toggle, 0.0],
905 pad,
867906 ..Spec::default()
868907 },
869908 );
870 let tint = crate::notebook_color(
871 &self.ui.theme,
872 self.notebook().and_then(|library| library.color()),
873 );
874 if ui::shell::tool_button(&mut self.ui, "button", art::NOTEBOOK, tint, None).clicked {
909 let (icon, tint) = if self.sidebar {
910 (art::CLOSE, self.ui.theme.text)
911 } else {
912 let color = self.notebook().and_then(|library| library.color());
913 (art::NOTEBOOK, crate::notebook_color(&self.ui.theme, color))
914 };
915 if ui::shell::tool_button(&mut self.ui, "button", icon, tint, None).clicked {
875916 self.sidebar = !self.sidebar;
876917 self.save_settings();
877918 }
878 crate::tip(&mut self.ui, crate::commands::Id::Sidebar);
919 crate::tip(&mut self.ui, Cmd::Sidebar);
879920 self.ui.close();
880921 }
881922
crates/snowbound/src/windows.rs+1-1
......@@ -805,7 +805,7 @@ pub fn window_controls(ui: &mut Ui, window: &Window, end: bool) {
805805 let size = if found {
806806 [bounds.right - bounds.left, bounds.bottom - bounds.top].map(|side| side as f32 / scale)
807807 } else {
808 [3.0 * CAPTION_BUTTON, crate::TITLE]
808 [3.0 * CAPTION_BUTTON, crate::TOOLBAR]
809809 };
810810 let offset = [GAP, -(crate::TITLE - ui::shell::TOOL) / 2.0];
811811 if system && !eleven() {
crates/snowbound/tests/replay.rs+1-1
......@@ -364,7 +364,7 @@ fn renaming_a_section_from_its_tab_types_into_the_sidebar_at_once() {
364364 let scratch = Scratch::new("rename-shut-sidebar");
365365 let notebook =
366366 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/cross-container/candidate");
367 let mut steps = vec!["move 53 53", "press right", "release right", "settle"];
367 let mut steps = vec!["move 110 53", "press right", "release right", "settle"];
368368 steps.extend(["key Down", "key Enter", "settle"]);
369369 steps.extend(["type Renamed", "accessibility typed"]);
370370 let [typed] = replay(&scratch, Some(&notebook), &steps)
crates/ui/src/layout.rs+26-9
......@@ -90,13 +90,9 @@ pub(crate) fn solve(
9090 // goes back to the space.
9191 let spare: f32 = space.iter().map(|child| nodes[*child].computed[axis]).sum();
9292 while excess > spare && axis == 0 {
93 let Some(group) = children
94 .iter()
95 .copied()
96 .filter(|child| {
97 nodes[*child].fold.is_some() && !nodes[nodes[*child].children[0]].hidden
98 })
99 .min_by_key(|child| nodes[*child].fold)
93 let mut groups = Vec::new();
94 unfolded(nodes, &children, &mut groups);
95 let Some(group) = groups.into_iter().min_by_key(|group| nodes[*group].fold)
10096 else {
10197 break;
10298 };
......@@ -104,8 +100,14 @@ pub(crate) fn solve(
104100 nodes[full].hidden = true;
105101 nodes[folded].hidden = false;
106102 let width = nodes[folded].computed[0] + 2.0 * nodes[group].pad[0];
107 excess -= nodes[group].computed[0] - width;
108 nodes[group].computed[0] = width;
103 let freed = nodes[group].computed[0] - width;
104 excess -= freed;
105 // A group inside boxes sized by their children narrows them too.
106 let mut inside = group;
107 while inside != index {
108 nodes[inside].computed[0] -= freed;
109 inside = nodes[inside].parent;
110 }
109111 }
110112 excess = give_back(nodes, &space, axis, excess);
111113 let mut tiers: Vec<f32> = sized
......@@ -313,6 +315,21 @@ fn in_flow(nodes: &[Built], index: usize) -> impl Iterator<Item = usize> + '_ {
313315 })
314316}
315317
318/// The groups among `boxes` still in their full form, and those inside any of them that is a
319/// row sized by its children, whose width follows theirs.
320fn unfolded(nodes: &[Built], boxes: &[usize], groups: &mut Vec<usize>) {
321 for &child in boxes {
322 let node = &nodes[child];
323 if node.fold.is_some() {
324 if !nodes[node.children[0]].hidden {
325 groups.push(child);
326 }
327 } else if node.size[0].size == Size::Children && node.axis == Axis::X {
328 unfolded(nodes, &in_flow(nodes, child).collect::<Vec<_>>(), groups);
329 }
330 }
331}
332
316333/// The children's extent on `axis`: summed with gaps along the flow, otherwise the largest.
317334fn flow(nodes: &[Built], index: usize, axis: usize) -> f32 {
318335 let sizes = in_flow(nodes, index).map(|child| nodes[child].computed[axis]);
crates/ui/src/lib.rs+2-1
......@@ -390,7 +390,8 @@ pub struct Spec<'a> {
390390 pub anchor: Option<Anchor>,
391391 /// Makes the box a group of a row in two forms, its first two children, both built every
392392 /// frame: its full form, and the one it folds to where the row lacks room. The row folds
393 /// its groups by ascending priority until it fits, once the space sized by ancestors has
393 /// its groups, and those in rows of its own sized by their children, by ascending
394 /// priority until it fits, once the space sized by ancestors has
394395 /// yielded and before any other box gives up room. The form not shown takes no room,
395396 /// paint or input, and its boxes take the group's rectangle, so what opens from them
396397 /// opens from the form shown.
crates/ui/src/shell.rs+44
......@@ -18,6 +18,8 @@ const TAB_ROUNDING: f32 = 4.0;
1818pub const TOOL: f32 = 22.0;
1919/// Width of a menu arrow beside a toolbar button.
2020const ARROW: f32 = 10.0;
21/// Width of a button showing only a menu arrow, which ends its group as tight as an icon does.
22const MORE: f32 = 14.0;
2123/// Where a section tab's label starts inside it.
2224pub const TAB_PAD: f32 = 10.0;
2325/// 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(
483485 ui.signal(button)
484486}
485487
488/// A narrow button showing only a menu arrow, which opens popup `menu` of more of a group's
489/// commands at the group's end; where not `enabled` it shows faded and takes no clicks.
490pub fn more_button(ui: &mut Ui, part: impl Hash, menu: Id, enabled: bool) -> Anchor {
491 let theme = ui.theme.clone();
492 let open = ui.popup_open(menu);
493 let id = ui.open(
494 part,
495 Spec {
496 flags: if enabled {
497 Flags::CLICKABLE
498 } else {
499 Flags::default()
500 },
501 size: [px(MORE), px(TOOL)],
502 icon: Some(CHEVRON),
503 color: Some(if enabled {
504 theme.text_dim
505 } else {
506 faded(theme.text_dim)
507 }),
508 fill: open.then(|| theme.hover()).or(rest(&theme)),
509 hover_fill: enabled.then(|| theme.hover()),
510 radius: 4.0,
511 center: true,
512 role: Some(Role::Button),
513 ..Spec::default()
514 },
515 );
516 if let Some(node) = ui.access(id) {
517 node.set_has_popup(HasPopup::Menu);
518 node.set_expanded(open);
519 if !enabled {
520 node.set_disabled();
521 }
522 }
523 ui.close();
524 if enabled && ui.signal(id).pressed {
525 ui.open_popup(menu);
526 }
527 Anchor::Below(ui.rect(id).unwrap_or_default())
528}
529
486530/// A button showing `icon` and a menu arrow, one control that opens popup `menu`, a toggle
487531/// lit while `on` as `tool_button`'s. Returns where the menu opens so its icons line up
488532/// under the button's.
crates/ui/src/tests.rs+73
......@@ -232,6 +232,79 @@ fn a_row_is_narrowest_with_every_group_folded_and_every_box_squeezed() {
232232 assert_eq!(rects[4], Some([40.0, 0.0, 60.0, 20.0]));
233233}
234234
235#[test]
236fn a_group_inside_a_box_sized_by_its_children_folds_with_the_row() {
237 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);
238 let build = |ui: &mut Ui, width: f32| {
239 frame(ui, |ui| {
240 ui.open(
241 "row",
242 Spec {
243 size: [px(width), px(20.0)],
244 ..Spec::default()
245 },
246 );
247 ui.open("outer", Spec::default());
248 ui.leaf(
249 "fixed",
250 Spec {
251 size: [px(50.0), px(20.0)],
252 ..Spec::default()
253 },
254 );
255 ui.open(
256 "group",
257 Spec {
258 fold: Some(0),
259 ..Spec::default()
260 },
261 );
262 for (form, width) in [("full", 100.0), ("folded", 20.0)] {
263 ui.open(form, Spec::default());
264 ui.leaf(
265 "button",
266 Spec {
267 size: [px(width), px(20.0)],
268 ..Spec::default()
269 },
270 );
271 ui.close();
272 }
273 ui.close();
274 ui.close();
275 ui.open(
276 "later",
277 Spec {
278 fold: Some(1),
279 ..Spec::default()
280 },
281 );
282 for (form, width) in [("full", 30.0), ("folded", 30.0)] {
283 ui.open(form, Spec::default());
284 ui.leaf(
285 "button",
286 Spec {
287 size: [px(width), px(20.0)],
288 ..Spec::default()
289 },
290 );
291 ui.close();
292 }
293 ui.close();
294 ui.close();
295 });
296 };
297 let [outer, after] = ["outer", "later"].map(|part| Id::ROOT.child("row").child(part));
298 build(&mut ui, 180.0);
299 assert_eq!(ui.rect(outer), Some([0.0, 0.0, 150.0, 20.0]));
300 // 50 + 100 + 30 overflows 179, so the group folds before the later one of higher priority,
301 // and the box around it narrows to 70.
302 build(&mut ui, 179.0);
303 assert_eq!(ui.rect(outer), Some([0.0, 0.0, 70.0, 20.0]));
304 assert_eq!(ui.rect(after), Some([70.0, 0.0, 100.0, 20.0]));
305 assert_eq!(ui.narrowest(Id::ROOT.child("row")), Some(100.0));
306}
307
235308#[test]
236309fn a_box_sized_by_its_children_yields_what_they_yield() {
237310 let mut ui = Ui::new(Theme::dark(), DOUBLE_CLICK);