Compare commits

...

37 Commits

Author SHA1 Message Date
jyviko 7321a54a24 Merge main into the window chrome branch after control landed (#12940)
#13316, #13338, #13384 and #13407 landed on main as squashed commits. This merge keeps only this branch's own changes on top of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:38:10 -05:00
jyviko 2724151d04 Native menu: the actions with a fixed id are a slice, not a sized array (#12940)
The list grows with the menu, so it follows the repo's rule for growing lists (cargo xtask lists).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:26:03 -05:00
jyviko d83cc2e48b Window chrome: a menu of the bar that is off stays off; replace is for submenus (#12940)
The bar's own menus take their item's enabled flag. replace() refuses a menu of the bar
(it keeps the system entries the layout gave it), now documented and tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:13 -05:00
jyviko 4b6efddb3c Native menu: its control ids do the same over the control channel (#12940)
Agent Chat sends ui.agent_chat {toggle}, which the control channel now takes (show or hide as
the panel's button does). ui.help runs every item the Help menu sends (docs, messages and
install_cli too) and answers an error for an unknown item instead of a silent ok. A test
runs the menu's control ids through the control channel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:13 -05:00
jyviko de146e349b Native menu: entries use the control channel's ids, tested on every system (#12940)
Quit is app.quit, Settings and Keyboard Shortcuts are ui.prefs {open}, Agent Chat is
ui.agent_chat and Help's items are ui.help {item}; the menus are menu.file, menu.file.recent,
menu.edit, menu.window and menu.help. The id round trip and the shortcut table are tested
without a window on every system; the module builds there for its tests only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:13 -05:00
jyviko d83d6b3c1e Window chrome: the menu bar's layout is pure and tested on every system (#12940)
MenuTable lays out the whole bar (the app menu's system entries, the Window menu's extras,
submenus) and replaces a submenu's entries itself; NativeMenu only turns rows into AppKit
items. Nothing is dead code off macOS, and the layout and replace tests run everywhere.
The Window menu's id is menu.window; menu ids are never dispatched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:13 -05:00
jyviko 50c78b30dd Window chrome: the application bar is laid out by TitleBar (#12940)
app_bar drew its own drag area, traffic-light inset and caption buttons beside the shared
TitleBar; it now runs TitleBar::show and answers the returned WindowAction through
titlebar::run, so the title bar tests pin what SolidCraft draws.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:13 -05:00
jyviko 26db4c7777 Window chrome: the macOS menu bar from the kit's menu items (#12940)
window_chrome::native_menu builds the menu bar from ui_kit::Item trees (the app menu's system
layout, the system's Window entries) and returns the chosen entry as (id, params); the app-menu
title comes from the app. SolidCraft's native_menu.rs builds its Items from FileAction with
command ids or ui.* ids and maps the ids back, with the same entries, shortcuts and labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:10:13 -05:00
jyviko 363ec793dc Window chrome: the title bar and window settings from plain inputs (#12940)
window_chrome holds what any Craft app's window needs without SolveApp: viewport(title, app_id,
custom_titlebar) with SolidCraft's window settings, TitleBar (traffic-light inset, drag area,
caption buttons) that returns a WindowAction for the app to run, and the resize zones and hit
test. titlebar.rs is SolidCraft's thin use of it: Close asks about unsaved designs first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:10:13 -05:00
jyviko bd4f6e857d Control: tests for the screenshot gate, a hidden window and frames nobody waits for (#12946)
Shots::issue takes a frame on the third frame and 150 ms after the request,
or 500 ms later for a view that keeps moving; a hidden window refuses a
request and answers one waiting; with nothing pending the frames that came
are dropped. Each fails on a one-line mutation of screenshot.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:10:03 -05:00
jyviko 31c6f39e98 Control: the app gives the screenshot reply for no frame (#12946)
The shared text named SolidCraft's ui.render. Shots::new takes the app's
words; SolidCraft passes its reply unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:51 -05:00
jyviko 731749224d Control: ui.screenshot and ui.resize are their own shared crate, solidcraft-control-egui (#12946)
d8d35d454 made egui and image dependencies of solidcraft-control, so the
headless engine, the CLI and the MCP bridge compiled egui. The screenshot
and window modules move unchanged to solidcraft-control-egui; ui-egui
uses them from there. cargo tree finds no egui under engine, mcp or control.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:51 -05:00
jyviko a54ff93c59 Control: ui.screenshot and ui.resize are shared over egui (#12946)
The window's screenshots in flight (the frame waits, the AO-settled
shot, the hidden-window and chat-turn refusals, the 8 s deadline) move
from SolveApp to solidcraft_control::screenshot::Shots, with the PNG
encoding; ui.resize and the hidden-window test go to window. SolidCraft
passes its AO settle, its issue report's frames and its vfs write, so a
second app can serve ui.screenshot with the shared crate alone. The issue
report and tests reach the queue as app.shots; replies are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:50 -05:00
jyviko d2b9daeba8 Control: the shared server speaks the app's words and listens on loopback only (#12946)
The change event, the revision method, the docs path in the unauthorized
reply and the document noun come from the app's Protocol; SolidCraft's
replies are byte-identical. The design watch is the DocumentWatch.
Hub::listen refuses a listener off loopback, so no app can put the
channel on the network; SolidCraft binds 127.0.0.1 as before.
control_pending.rs is formatted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:09:50 -05:00
jyviko 223ccd0004 Control: the loopback control server is shared; SolidCraft gives it its Protocol (#12946)
The token-admitted JSON-lines server (framing and line limit, connection
cap, control.running/pending/capabilities, busy and timeout replies,
control.subscribe events), ControlRequest and the design watch move to
solidcraft-control as `server::Hub`. What a request means (its name in
messages, its effect, command id, step count, the batch wait, the
capabilities) is the app's `server::Protocol`; SolidCraft's is in
apps/solidcraft/src/control_server.rs with its listeners and token files.
The wire format is unchanged and the server's tests move with it.

Also from review: Effect::when_done and the timeout text take the app's
word for its document ("design" for SolidCraft), the Listen docs name no
SolidCraft preference, and bench_assistant.rs is formatted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:09:50 -05:00
jyviko 331218f23b Control: the token files and Effect are a shared crate, solidcraft-control (#12946)
The per-launch token, its files, the discovery file and the client's token
lookup move from the engine to crates/control, with no engine dependency.
Which app they belong to (name, data folder, environment, UTC format,
default port) is an `App` value; SolidCraft's is engine::control_auth::APP.
`Effect` moves with them; which requests do what stays in the engine's
control_pending. Callers switch paths; files and messages are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:09:50 -05:00
jyviko c361491d57 Docs: the architecture table lists the shared crates and the MCP split (#12947)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:50 -05:00
jyviko 24ba6f29ca MCP: the shared server tests what it owns, on its own (#12947)
Batches (mixed, notifications only, the 1000 limit), an unasked response,
the protocol version fallback, null arguments, enum mismatches, the empty
default lists and the list-then-call order Tools::call relies on are tested
in solidcraft-mcp-server with a fake Tools; the protocol-only lines move out
of SolidCraft's wrapper tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:09:50 -05:00
jyviko 3edfed7219 MCP: the server machinery is a shared crate, solidcraft-mcp-server (#12947)
JSON-RPC over stdio, the MCP lifecycle, tool and resource methods, tool
definitions with their annotations, the argument check and the text and
image results move to crates/mcp-server, which has no engine or GPU
dependency and is classed Shared. SolidCraft's tools, instructions,
resources and the agent's folders stay in crates/mcp and register into it
through the Tools trait; its public API, tools list and replies are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:09:50 -05:00
jyviko 0383f87e57 Theme: format the field contrast test (#13304)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:06:19 -05:00
jyviko bb91986df3 Theme: the icon lint and the docs read palettes.rs at crates/theme (#13304)
lint_icons.py compared the icon review page with assets/styles/ui/palettes.rs, which moved into
solidcraft-theme; it reads crates/theme/src/palettes.rs now (cargo xtask icons passes again), and
the READMEs, docs and test comments name the new path. set_custom and scoped_tokens say they set
tokens only, so a host with an icon renderer sets its palette too; the field contrast test applies
each built-in theme with set_file_palette, as the app does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:06:19 -05:00
jyviko af73d7bd90 Shared UI crates: the token contract as a shared crate, solidcraft-theme (#13304)
Tokens with SolidCraft's light and dark values in code (palettes.rs moves in from
assets/styles/ui, with the eight primary strokes it shared with the icon artwork written as
icon_strokes), colour vision, TabAccent, contrast checks, apply/visuals and the per-thread and
per-context theme state move into crates/theme, class Shared. It holds no data files and does not
depend on theme files or icon roles; with nothing set, components draw with the built-in tokens.

The theme system stays in ui-egui's theme module: the custom-theme glue (prepared theme, tab
targets and their fade, blur intent), publish_tab (was TabAccent::publish, which now stores its
result through TabAccent::set_current and set_published), set_file_palette, ViewportTheme.
ui-egui's build script no longer generates the primary-stroke constants; an icon test holds the
crate's literals equal to palette.toml's. Call sites use solidcraft_theme paths with no
re-export (one rename pass). Tokens, theme files and rendered pixels are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:06:19 -05:00
jyviko 7d86e00ca7 Theme: the icon palette leaves Tokens; the icon renderer owns it (#13304)
A theme file's icon palette was a field of Tokens, so the token set depended on the icon roles
ui-egui's build script generates. It is now the icon renderer's per-thread state
(icon_svg::set_theme_palette), set alongside the theme's palette by theme::set_file_palette, and
theme_files resolves it with resolve_icon_palette (the same overlay merge as the tokens) and
exports it as an argument. Rendered pixels, theme files and exports are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 08:04:35 -05:00
jyviko e894f4a12f i18n tests: the painted-English sweeps install the catalogs themselves (#13290)
no_english_is_painted_where_the_language_has_a_translation and
the_sweep_reports_english_only_where_a_translation_exists read translations without installing the
app's catalogs, so they passed only when another test in the same process had installed them first
(run alone, every id read as itself).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 07:56:31 -05:00
jyviko 7b9e66929b Shared UI crates: ui-text, the message catalogs' machinery as a shared crate (#13290)
bidi, Catalog, Locale, lookup, formatting, labels and language choice move from ui-egui's
i18n into solidcraft-ui-text, a Shared crate any front end can use. The host installs its
catalogs once; with none installed every id reads as itself. SolidCraft's 35 catalogs, the
build script that merges them, and the adapters that read engine types (diagnostic,
tr_engine_error, and the given-name and browser-label vocabulary as the LocaleNames trait)
stay in the app's i18n. The app installs its catalogs when it starts, when an app is built
and in the widget test context. Call sites use the crate's path, with no re-export at the
old one; messages, ids and fallbacks are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:56:31 -05:00
jyviko bd3a94e946 Material editor: the Remove buttons are translated (material.editor.remove)
The editor's two Remove buttons called tr("Remove"), which looks up an id, so they read
"Remove" in every language. They use a new id, material.editor.remove, whose text in each
locale is the one that locale already gives the plain Remove button
prefs.network_host_remove (33 of 35 catalogs have it; the other two show English, as now).
The label-literal guard found it once the file's import named tr directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:43 -05:00
jyviko b0d7219115 Colour: the sRGB rule and the analysis palette are a shared crate, solidcraft-color (#13291)
geom's srgb module (the one sRGB transfer function every encode and decode goes through)
moves whole into solidcraft-color with its tests, and so does AnalysisColors, the analysis
palette the theme fills and the renderer reads. render, doc, io, engine, gpu and ui-egui use
the crate; there is no re-export at the old paths. The theme's unused material_color wrapper
is removed, so no non-test theme code names solidcraft_engine. Values are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:43 -05:00
jyviko a7598a92c6 UI kit: length fields take unit scaling and length parsing from the host (#13270)
The kit's length field converted millimetres to and from the design's units through three
prefs helpers. Those are the host's unit rules, so Expressions gains unit_scale,
length_number and parse_length_mm, and length_drag reads them from the context.
DocExpressions implements them with the same prefs helpers, so the app is unchanged. With
nothing installed the field edits plain millimetres. No kit file calls prefs outside
tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:43 -05:00
jyviko 5c4932ba42 UI kit: continuity rows leave the kit, the navigator figure takes its axes as input (#13261)
continuity_row and its explained and tip variants are CAD controls over the kernel's
Continuity: they move beside their callers in dialogs_surface, still drawn with the kit's
choice_row. The Preferences navigator figure takes its axes (screen direction and depth)
as NavigatorAxis input; SolidCraft computes them from its default camera. dashed_line is
painter geometry and moves into ui_kit::dash with its test. No kit file uses the engine,
the record or the viewport outside tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:43 -05:00
jyviko ee72a98948 UI kit: layout mode and card geometry in the kit, input shield set by the host (#13260)
LayoutMode (with its per-context publish and current), the card gap and the panic-free
clamp move into the kit's containers and panels, and Cards into stages. Cards::new takes
the workspace width instead of reading the ribbon's constant. The flag that says a report
covers the 3D view is now the kit's (input_blocked, set_input_blocked); the app's CadInput
guard sets it, and issue_report's own copy is gone. Serde names are unchanged, so saved
preferences load as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:21 -05:00
jyviko 2fba25f564 UI kit: navigation-bar values and the horizontal wheel in the kit (#13259)
The bar's edge, size, idle and button-kind values move beside the bar's drawing in
ui_kit::navigation as NavigationEdge, NavigationSize, NavigationIdle and NavigationKind (the
kit already has a container Edge). Serde names are unchanged, so saved preferences load as
before. take_horizontal_wheel moves into the theme picker, its only caller. SolidCraft's
slot table stays in navigation_bar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko 7b90cdf3fb UI kit: number parsing and recent colours in the kit, expression support passed in by the host (#13258)
parse_number (decimal point or comma, Unicode minus) moves from prefs into the kit's
fields, and recent_colors moves whole into ui_kit::recent_colors beside the colour picker.
The kit no longer knows the expression language: a host installs an Expressions (is this
a literal, is this a unit, complete or step this text) per egui context, and fields read
it for label drags and completion. SolidCraft installs DocExpressions, built on doc::expr
and params_dialog::complete, when the app first draws into a context. Behaviour in the app
is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko 8b7586ba87 UI kit: the kit owns the menu entry and the list width (#13257)
Item (a command or ui.* action with its label, shortcut, icon, params, children, swatch
and tip) and LIST_W move from context_menu into ui_kit::menu, with the same serde form, so
the control channel's menu report is byte-identical. context_menu keeps building
SolidCraft's entries (cmd, and act over Item::action) and returns kit Items. No kit file
refers to context_menu any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko b23d1ada5d UI kit: Preferences search is a kit module, settings_search (#13256)
prefs_search moves whole into ui_kit::settings_search: it filters any settings page the
kit draws, and SolidCraft's Preferences window is one use of it. It publishes its handles
into the widget registry and reads the same wall clock as before (web_time), so no kit
file calls the app for search any more. Every caller uses the new path; state ids, handle
names, matches and scores are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko 9a0d9ce243 Theme: the tab accent takes the tab, the sketch and the tool as data (#12938)
TabAccent::publish takes the open tab, whether a sketch is being edited and whether a tool
or dialog is active, instead of SolveApp; SolveApp::publish_tab_accent works the three out
in one place. scoped_panel_tokens is public, so a host can draw any component in another
palette or accent. The theme and its tests no longer name SolveApp, Services or workspace.
Accents are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko be226abee7 UI kit: the widget registry is a shared crate, ui-registry (#12936)
The handle, rect and count registry that UI tests, scenarios and the control channel read
moves out of scenario into solidcraft-ui-registry, the first Shared crate. The kit publishes
into it directly; scenario re-exports it, so scenarios, scripts and the rest of ui-egui are
unchanged and every handle keeps its name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
jyviko 414b8bb139 Layers: a Shared crate class, and cargo xtask shared builds each one on its own (#12935)
A Shared crate depends only on other Shared crates, solidcore and external crates (UI
toolkits included); any layered or testkit crate may use one. The layer check reports a
Shared crate's dependency on an app crate, of any kind. cargo xtask shared checks each
Shared crate's manifest (workspace lints, licence, README) and that no include_str! or
include_bytes! reaches outside it, then builds and tests it with its Shared dependencies in
a scratch workspace of its own. cargo xtask ci runs it after the layer check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKQRurMYJST9eSZWKzW
2026-11-08 07:49:09 -05:00
12 changed files with 1555 additions and 681 deletions
Generated
+1
View File
@@ -4405,6 +4405,7 @@ dependencies = [
"image",
"log",
"minisign-verify",
"muda",
"nix 0.31.3",
"pollster 0.4.0",
"proc-macro2",
+2 -12
View File
@@ -35,7 +35,7 @@ mod native_crash;
mod native_crash_macos;
#[cfg(target_os = "windows")]
mod native_frame;
#[cfg(target_os = "macos")]
#[cfg(any(target_os = "macos", test))]
mod native_menu;
mod nav_bridge;
#[cfg(target_os = "macos")]
@@ -913,17 +913,7 @@ fn app_viewport(custom_titlebar: bool) -> Result<egui::ViewportBuilder, eframe::
#[cfg(not(target_os = "macos"))]
let icon =
egui::IconData { rgba: app_icon::rgba(64).map_err(|e| eframe::Error::AppCreation(std::io::Error::other(e).into()))?, width: 64, height: 64 };
Ok(egui::ViewportBuilder::default()
.with_title("SolidCraft")
.with_icon(icon)
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id(APP_ID))
Ok(solidcraft_ui_egui::window_chrome::viewport("SolidCraft", APP_ID, custom_titlebar).with_icon(icon))
}
/// The window's app id: it names the desktop entry, so it is the packages' reverse-DNS id.
+347 -162
View File
@@ -1,137 +1,221 @@
//! macOS system menus, owned and updated on eframe's main thread.
//! SolidCraft's macOS menu bar: its entries as kit menu items
//! (`solidcraft_ui_egui::window_chrome::native_menu`), each with a command id or a `ui.*` id, run
//! through the same actions as the application bar's File menu (`toolbar::FileAction`).
//!
//! Entries that are control actions use their ids and do the same there: `app.quit`,
//! `ui.prefs {open}` (Settings and Keyboard Shortcuts), `ui.agent_chat {toggle}`, `ui.help {item}`. Undo and Redo are the commands
//! `edit.undo` and `edit.redo`. The other entries' ids are defined here: `ui.file.new`,
//! `ui.file.open`, `ui.file.open_recent {path}`, `ui.file.clear_recent`, `ui.file.save`,
//! `ui.file.save_as`, `ui.file.save_json`, `ui.file.export {format}`,
//! `ui.file.export_mesh {format}`. Menus are `menu.file`, `menu.file.recent`, `menu.edit`,
//! `menu.window` and `menu.help`.
//!
//! The menu bar itself is macOS's; the entries and their ids build everywhere, so their tests run
//! on every system.
use std::sync::mpsc::{Receiver, sync_channel};
use muda::{
AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
accelerator::{Accelerator, Code, Modifiers},
use serde_json::{Value, json};
use solidcraft_ui_egui::{
SolveApp,
toolbar::FileAction,
ui_kit::Item,
window_chrome::native_menu::{self, MenuBar},
};
use solidcraft_ui_egui::{SolveApp, toolbar::FileAction};
/// The Open Recent submenu, refilled when the recent designs change.
const RECENT: &str = "menu.file.recent";
const OPEN_RECENT: &str = "ui.file.open_recent";
/// Recent designs listed.
#[cfg(target_os = "macos")]
const RECENT_COUNT: usize = 32;
/// File > Insert entries.
const INSERTS: [(&str, FileAction); 3] = [
("Insert STEP\u{2026}", FileAction::Command("file.insert_step")),
("Insert Mesh\u{2026}", FileAction::Command("file.insert_mesh")),
("Insert Design", FileAction::Command("component.insert_design")),
];
/// File > Export entries.
const EXPORTS: [(&str, FileAction); 7] = [
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
("Export STEP\u{2026}", FileAction::Export("step")),
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
("Export OBJ\u{2026}", FileAction::Export("obj")),
("Export URDF\u{2026}", FileAction::Export("urdf")),
("Export Design as JSON\u{2026}", FileAction::Export("json")),
];
const DESIGN_PROPERTIES: FileAction = FileAction::Command("document.metadata");
/// The other actions with a fixed id (all but Open Recent), to map an id back to its action.
const FIXED: &[FileAction] = &[
FileAction::Settings,
FileAction::Shortcuts,
FileAction::Quit,
FileAction::New,
FileAction::Open,
FileAction::ClearRecent,
FileAction::Save,
FileAction::SaveAs,
FileAction::SaveJson,
DESIGN_PROPERTIES,
FileAction::Undo,
FileAction::Redo,
FileAction::AgentChat,
];
/// The Help menu's entries: the control channel's `ui.help {item}`.
const HELP_ID: &str = "ui.help";
/// The Help menu's items (`help::run`), top to bottom.
const HELP: [&str; 7] = ["docs", "updates", "shortcuts", "messages", "report", "crashes", "install_cli"];
fn help_label<'a>(app: &'a SolveApp, item: &str) -> &'a str {
match item {
"docs" => app.tr_label("Documentation"),
"updates" => app.tr("update.check_for_updates"),
"shortcuts" => app.tr_label("Keyboard Shortcuts"),
"messages" => app.tr_label("Messages\u{2026}"),
"report" => app.tr_label("Report an Issue\u{2026}"),
"crashes" => app.tr_label("Crash Reports\u{2026}"),
_ => app.tr_label("Install solidcraft-cli command"),
}
}
/// What a menu entry runs.
#[derive(Debug, PartialEq)]
enum Entry {
File(FileAction),
Help(&'static str),
}
/// The entry's id and parameters for `action`.
fn id_of(action: &FileAction) -> (String, Option<Value>) {
let fixed = |id: &str| (id.to_string(), None);
match action {
FileAction::Settings => ("ui.prefs".into(), Some(json!({ "open": "General" }))),
FileAction::Shortcuts => ("ui.prefs".into(), Some(json!({ "open": "Shortcuts" }))),
FileAction::Quit => fixed("app.quit"),
FileAction::New => fixed("ui.file.new"),
FileAction::Open => fixed("ui.file.open"),
FileAction::OpenRecent(path) => (OPEN_RECENT.into(), Some(json!({ "path": path }))),
FileAction::ClearRecent => fixed("ui.file.clear_recent"),
FileAction::Save => fixed("ui.file.save"),
FileAction::SaveAs => fixed("ui.file.save_as"),
FileAction::SaveJson => fixed("ui.file.save_json"),
FileAction::Export(format) => ("ui.file.export".into(), Some(json!({ "format": format }))),
FileAction::ExportMesh(format) => ("ui.file.export_mesh".into(), Some(json!({ "format": format }))),
FileAction::Command(command) => fixed(command),
FileAction::Undo => fixed("edit.undo"),
FileAction::Redo => fixed("edit.redo"),
FileAction::AgentChat => ("ui.agent_chat".into(), Some(json!({ "toggle": true }))),
}
}
/// The entry with `id` and `params`, `None` for one this menu doesn't have.
fn entry_of(id: &str, params: Option<&Value>) -> Option<Entry> {
if id == OPEN_RECENT {
return params.and_then(|p| p.get("path")).and_then(Value::as_str).map(|path| Entry::File(FileAction::OpenRecent(path.to_string())));
}
if id == HELP_ID {
let item = params.and_then(|p| p.get("item")).and_then(Value::as_str);
return HELP.into_iter().find(|h| Some(*h) == item).map(Entry::Help);
}
let wanted = (id.to_string(), params.cloned());
FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).find(|a| id_of(a) == wanted).map(Entry::File)
}
/// The entries with a key, as menu accelerators.
const SHORTCUTS: [(FileAction, &str); 8] = [
(FileAction::Settings, "Cmd+,"),
(FileAction::Quit, "Cmd+Q"),
(FileAction::New, "Cmd+N"),
(FileAction::Open, "Cmd+O"),
(FileAction::Save, "Cmd+S"),
(FileAction::SaveAs, "Cmd+Shift+S"),
(FileAction::Undo, "Cmd+Z"),
(FileAction::Redo, "Cmd+Shift+Z"),
];
fn item(label: &str, action: &FileAction) -> Item {
let (id, params) = id_of(action);
let shortcut = SHORTCUTS.iter().find(|(a, _)| a == action).map_or("", |(_, k)| *k);
let mut item = Item::action(&id, label, "").key(shortcut);
item.params = params;
item
}
fn submenu(id: &str, label: &str, children: Vec<Item>) -> Item {
let mut menu = Item::action(id, label, "");
menu.children = children;
menu
}
fn recent_items(app: &SolveApp, paths: &[String]) -> Vec<Item> {
let mut items: Vec<Item> = paths.iter().map(|path| item(path, &FileAction::OpenRecent(path.clone()))).collect();
items.push(Item::sep());
items.push(item(app.tr("ui.clear_recent"), &FileAction::ClearRecent));
items
}
fn menu_bar(app: &SolveApp, recent: &[String]) -> MenuBar {
let more = |key: &str| format!("{}\u{2026}", app.tr(key));
let mut file = vec![
item(app.tr("ui.new_design"), &FileAction::New),
item(&more("ui.open"), &FileAction::Open),
submenu(RECENT, app.tr("ui.open_recent"), recent_items(app, recent)),
Item::sep(),
];
file.extend(INSERTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
file.extend([
Item::sep(),
item(app.tr("ui.save"), &FileAction::Save),
item(&more("ui.save_as"), &FileAction::SaveAs),
item(app.tr_label("Save As JSON\u{2026}"), &FileAction::SaveJson),
Item::sep(),
]);
file.extend(EXPORTS.iter().map(|(label, action)| item(app.tr_label(label), action)));
file.extend([Item::sep(), item(app.tr_label("Design Properties"), &DESIGN_PROPERTIES)]);
let edit = vec![item(app.tr("ui.undo"), &FileAction::Undo), item(app.tr("ui.redo"), &FileAction::Redo)];
let window = vec![item(app.tr_label("Agent Chat"), &FileAction::AgentChat)];
let help = HELP.iter().map(|h| Item::action(HELP_ID, help_label(app, h), "").with(json!({ "item": h }))).collect();
MenuBar {
title: "SolidCraft".into(),
version: env!("CARGO_PKG_VERSION").into(),
app: vec![item(&more("ui.preferences"), &FileAction::Settings), item(&more("ui.keyboard_shortcuts"), &FileAction::Shortcuts)],
quit: item(&format!("{} SolidCraft", app.tr("ui.quit")), &FileAction::Quit),
menus: vec![
submenu("menu.file", app.tr("ui.file"), file),
submenu("menu.edit", app.tr_label("Edit"), edit),
submenu(native_menu::WINDOW, "Window", window),
submenu("menu.help", app.tr("ui.help"), help),
],
}
}
/// The menu bar, owned and updated on eframe's main thread.
#[cfg(target_os = "macos")]
pub struct NativeMenu {
menu: Menu,
actions: Vec<(MenuItem, FileAction)>,
help_actions: Vec<(MenuItem, &'static str)>,
events: Receiver<MenuEvent>,
recent: Submenu,
menu: native_menu::NativeMenu,
recent_paths: Vec<String>,
}
#[cfg(target_os = "macos")]
impl NativeMenu {
pub fn new(app: &SolveApp, ctx: &egui::Context) -> muda::Result<Self> {
let menu = Menu::new();
let application = Submenu::new("SolidCraft", true);
let file = Submenu::new(app.tr("ui.file"), true);
let edit = Submenu::new(app.tr_label("Edit"), true);
let recent = Submenu::new(app.tr("ui.open_recent"), true);
let window = Submenu::new("Window", true);
let help = Submenu::new(app.tr("ui.help"), true);
let mut help_actions = Vec::new();
let mut actions = Vec::new();
let mut add = |parent: &Submenu, label: &str, action: FileAction, shortcut: Option<Accelerator>| -> muda::Result<()> {
let item = MenuItem::new(label, Self::action_enabled(&action, app, ctx), shortcut);
parent.append(&item)?;
actions.push((item, action));
Ok(())
};
application.append(&PredefinedMenuItem::about(
Some("About SolidCraft"),
Some(AboutMetadata { name: Some("SolidCraft".into()), version: Some(env!("CARGO_PKG_VERSION").into()), ..Default::default() }),
))?;
application.append(&PredefinedMenuItem::separator())?;
add(
&application,
&format!("{}\u{2026}", app.tr("ui.preferences")),
FileAction::Settings,
Some(Accelerator::new(Modifiers::META, Code::Comma)),
)?;
add(&application, &format!("{}\u{2026}", app.tr("ui.keyboard_shortcuts")), FileAction::Shortcuts, None)?;
application.append(&PredefinedMenuItem::separator())?;
application.append(&PredefinedMenuItem::services(None))?;
application.append(&PredefinedMenuItem::separator())?;
application.append_items(&[
&PredefinedMenuItem::hide(Some("Hide SolidCraft")),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
])?;
add(&application, &format!("{} SolidCraft", app.tr("ui.quit")), FileAction::Quit, Some(Accelerator::new(Modifiers::META, Code::KeyQ)))?;
add(&file, app.tr("ui.new_design"), FileAction::New, Some(Accelerator::new(Modifiers::META, Code::KeyN)))?;
add(&file, &format!("{}\u{2026}", app.tr("ui.open")), FileAction::Open, Some(Accelerator::new(Modifiers::META, Code::KeyO)))?;
file.append(&recent)?;
file.append(&PredefinedMenuItem::separator())?;
for (label, command) in
[("Insert STEP\u{2026}", "file.insert_step"), ("Insert Mesh\u{2026}", "file.insert_mesh"), ("Insert Design", "component.insert_design")]
{
add(&file, app.tr_label(label), FileAction::Command(command), None)?;
}
file.append(&PredefinedMenuItem::separator())?;
add(&file, app.tr("ui.save"), FileAction::Save, Some(Accelerator::new(Modifiers::META, Code::KeyS)))?;
add(
&file,
&format!("{}\u{2026}", app.tr("ui.save_as")),
FileAction::SaveAs,
Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyS)),
)?;
add(&file, app.tr_label("Save As JSON\u{2026}"), FileAction::SaveJson, None)?;
file.append(&PredefinedMenuItem::separator())?;
for (label, action) in [
("Export STL\u{2026}", FileAction::ExportMesh("stl")),
("Export 3MF\u{2026}", FileAction::ExportMesh("3mf")),
("Export STEP\u{2026}", FileAction::Export("step")),
("Export Current View as DXF\u{2026}", FileAction::Command("file.export_view_dxf")),
("Export OBJ\u{2026}", FileAction::Export("obj")),
("Export URDF\u{2026}", FileAction::Export("urdf")),
("Export Design as JSON\u{2026}", FileAction::Export("json")),
] {
add(&file, app.tr_label(label), action, None)?;
}
file.append(&PredefinedMenuItem::separator())?;
add(&file, app.tr_label("Design Properties"), FileAction::Command("document.metadata"), None)?;
add(&edit, app.tr("ui.undo"), FileAction::Undo, Some(Accelerator::new(Modifiers::META, Code::KeyZ)))?;
add(&edit, app.tr("ui.redo"), FileAction::Redo, Some(Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyZ)))?;
add(&window, app.tr_label("Agent Chat"), FileAction::AgentChat, None)?;
window.append(&PredefinedMenuItem::separator())?;
window.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::fullscreen(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
])?;
for (action, label) in [
("docs", "Documentation"),
("updates", app.tr("update.check_for_updates")),
("shortcuts", "Keyboard Shortcuts"),
("messages", "Messages\u{2026}"),
("report", "Report an Issue\u{2026}"),
("crashes", "Crash Reports\u{2026}"),
("install_cli", "Install solidcraft-cli command"),
] {
let item = MenuItem::new(app.tr_label(label), true, None);
help.append(&item)?;
help_actions.push((item, action));
}
menu.append_items(&[&application, &file, &edit, &window, &help])?;
let (sender, events) = sync_channel(64);
let ctx = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
let _ = sender.try_send(event);
ctx.request_repaint();
}));
menu.init_for_nsapp();
let mut native = Self { menu, actions, help_actions, events, recent, recent_paths: Vec::new() };
native.update_recent(app)?;
pub fn new(app: &SolveApp, ctx: &egui::Context) -> Result<Self, String> {
let recent_paths: Vec<String> = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
let menu = native_menu::NativeMenu::new(&menu_bar(app, &recent_paths), ctx)?;
let native = Self { menu, recent_paths };
native.update(app, ctx);
Ok(native)
}
pub fn raw_input_hook(&mut self, app: &mut SolveApp, ctx: &egui::Context, raw: &mut egui::RawInput) {
for event in self.events.try_iter().take(64) {
if let Some((_, action)) = self.help_actions.iter().find(|(item, _)| item.id() == &event.id) {
solidcraft_ui_egui::help::run(app, ctx, action);
}
if let Some((_, action)) = self.actions.iter().find(|(item, _)| item.id() == &event.id) {
if matches!(action, FileAction::Undo | FileAction::Redo) && ctx.egui_wants_keyboard_input() {
for (id, params) in self.menu.events() {
match entry_of(&id, params.as_ref()) {
Some(Entry::Help(item)) => solidcraft_ui_egui::help::run(app, ctx, item),
// A focused text field takes its own Undo and Redo.
Some(Entry::File(action @ (FileAction::Undo | FileAction::Redo))) if ctx.egui_wants_keyboard_input() => {
raw.events.push(egui::Event::Key {
key: egui::Key::Z,
physical_key: None,
@@ -139,58 +223,159 @@ impl NativeMenu {
repeat: false,
modifiers: egui::Modifiers { command: true, mac_cmd: true, shift: matches!(action, FileAction::Redo), ..Default::default() },
});
} else {
action.run(app);
}
Some(Entry::File(action)) => action.run(app),
None => log::warn!("native menu: no entry `{id}`"),
}
}
}
fn action_enabled(action: &FileAction, app: &SolveApp, ctx: &egui::Context) -> bool {
action.menu_enabled(app, ctx.egui_wants_keyboard_input())
fn update(&self, app: &SolveApp, ctx: &egui::Context) {
let focus = ctx.egui_wants_keyboard_input();
self.menu.update(|id, params| match entry_of(id, params) {
Some(Entry::File(action)) => action.menu_enabled(app, focus),
Some(Entry::Help(item)) => solidcraft_ui_egui::help::available(app, item),
None => false,
});
}
pub fn tick(&mut self, app: &mut SolveApp, ctx: &egui::Context) {
for (item, action) in &self.actions {
let enabled = Self::action_enabled(action, app, ctx);
if item.is_enabled() != enabled {
item.set_enabled(enabled);
if !self.recent_paths.iter().eq(app.home.recent.iter().take(RECENT_COUNT)) {
self.recent_paths = app.home.recent.iter().take(RECENT_COUNT).cloned().collect();
if let Err(error) = self.menu.replace(RECENT, &recent_items(app, &self.recent_paths)) {
app.set_status(format!("Recent designs menu: {error}"), true);
}
}
for (item, action) in &self.help_actions {
let enabled = solidcraft_ui_egui::help::available(app, action);
if item.is_enabled() != enabled {
item.set_enabled(enabled);
}
}
if !self.recent_paths.iter().eq(app.home.recent.iter().take(32))
&& let Err(error) = self.update_recent(app)
{
app.set_status(format!("Recent designs menu: {error}"), true);
}
}
fn update_recent(&mut self, app: &SolveApp) -> muda::Result<()> {
while self.recent.remove_at(0).is_some() {}
self.actions.retain(|(_, action)| !matches!(action, FileAction::OpenRecent(_) | FileAction::ClearRecent));
self.recent_paths = app.home.recent.iter().take(32).cloned().collect();
for path in &self.recent_paths {
let action = FileAction::OpenRecent(path.clone());
let item = MenuItem::new(path, action.enabled(app), None);
self.recent.append(&item)?;
self.actions.push((item, action));
}
self.recent.append(&PredefinedMenuItem::separator())?;
let clear = MenuItem::new(app.tr("ui.clear_recent"), !self.recent_paths.is_empty(), None);
self.recent.append(&clear)?;
self.actions.push((clear, FileAction::ClearRecent));
Ok(())
self.update(app, ctx);
}
}
impl Drop for NativeMenu {
fn drop(&mut self) {
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
self.menu.remove_for_nsapp();
#[cfg(test)]
mod tests {
use super::*;
/// Every action the menu offers, Open Recent with a path.
fn actions() -> Vec<FileAction> {
let mut all: Vec<FileAction> = FIXED.iter().cloned().chain(INSERTS.map(|(_, a)| a)).chain(EXPORTS.map(|(_, a)| a)).collect();
all.push(FileAction::OpenRecent("/work/a.solidcraft".into()));
all
}
#[test]
fn every_action_and_help_item_round_trips_through_its_id() {
let mut seen = vec![];
for action in actions() {
let (id, params) = id_of(&action);
assert!(!seen.contains(&(id.clone(), params.clone())), "two actions share `{id}` {params:?}");
assert_eq!(entry_of(&id, params.as_ref()), Some(Entry::File(action)), "`{id}` {params:?}");
seen.push((id, params));
}
for h in HELP {
assert_eq!(entry_of(HELP_ID, Some(&json!({ "item": h }))), Some(Entry::Help(h)));
}
// The control channel's ids where it has one.
assert_eq!(id_of(&FileAction::Quit), ("app.quit".to_string(), None));
assert_eq!(id_of(&FileAction::Settings), ("ui.prefs".to_string(), Some(json!({"open": "General"}))));
assert_eq!(id_of(&FileAction::Shortcuts), ("ui.prefs".to_string(), Some(json!({"open": "Shortcuts"}))));
assert_eq!(id_of(&FileAction::AgentChat), ("ui.agent_chat".to_string(), Some(json!({"toggle": true}))));
assert_eq!(entry_of("ui.file.export", Some(&json!({"format": "step"}))), Some(Entry::File(FileAction::Export("step"))));
for (id, params) in [
("ui.file.export", None),
("ui.file.export", Some(json!({"format": "dwg"}))),
(HELP_ID, Some(json!({"item": "nothing"}))),
(HELP_ID, None),
("ui.prefs", None),
("solid.nothing", None),
(OPEN_RECENT, None),
(RECENT, None),
(native_menu::WINDOW, None),
] {
assert_eq!(entry_of(id, params.as_ref()), None, "{id} {params:?}");
}
}
#[test]
fn every_shortcut_is_one_actions_and_the_menu_shows_it() {
for (i, (action, key)) in SHORTCUTS.iter().enumerate() {
assert!(SHORTCUTS.iter().skip(i + 1).all(|(a, k)| a != action && k != key), "{action:?} {key}");
assert_eq!(item("label", action).shortcut, *key);
}
assert_eq!(item("label", &FileAction::SaveJson).shortcut, "");
}
#[cfg(target_os = "macos")]
#[test]
fn menu_shortcuts_parse_as_the_keys_they_were() {
use muda::accelerator::{Accelerator, Code, Modifiers};
let (cmd, shift) = (Modifiers::META, Modifiers::META | Modifiers::SHIFT);
let want = [
Accelerator::new(cmd, Code::Comma),
Accelerator::new(cmd, Code::KeyQ),
Accelerator::new(cmd, Code::KeyN),
Accelerator::new(cmd, Code::KeyO),
Accelerator::new(cmd, Code::KeyS),
Accelerator::new(shift, Code::KeyS),
Accelerator::new(cmd, Code::KeyZ),
Accelerator::new(shift, Code::KeyZ),
];
for ((action, key), want) in SHORTCUTS.iter().zip(want) {
assert_eq!(key.parse::<Accelerator>().ok(), Some(want), "{action:?}");
}
}
#[test]
fn every_entry_of_the_menu_bar_runs_its_action() {
fn leaves(items: &[Item], out: &mut Vec<Item>) {
for item in items {
if item.children.is_empty() {
out.push(item.clone());
} else {
leaves(&item.children, out);
}
}
}
let app = SolveApp::new(solidcraft_engine::Session::default(), Default::default());
let bar = menu_bar(&app, &["/work/a.solidcraft".to_string()]);
let mut entries = vec![];
leaves(&bar.app, &mut entries);
leaves(std::slice::from_ref(&bar.quit), &mut entries);
leaves(&bar.menus, &mut entries);
entries.retain(|i| i.id != "-");
// App menu 2 + Quit; File: New, Open, 1 recent, Clear, 3 inserts, Save, Save As, JSON,
// 7 exports, Properties; Edit 2; Window 1; Help 7.
assert_eq!(entries.len(), 3 + 4 + 3 + 3 + 7 + 1 + 2 + 1 + 7, "{:?}", entries.iter().map(|i| &i.id).collect::<Vec<_>>());
for item in &entries {
assert!(entry_of(&item.id, item.params.as_ref()).is_some(), "`{}` {:?} runs nothing", item.id, item.params);
}
assert!(bar.menus.iter().all(|m| m.id.starts_with("menu.")), "menu ids are never actions");
}
/// The entries whose ids are control actions do over the control channel what they do in
/// the menu.
#[test]
fn shared_ids_mean_the_same_over_the_control_channel() {
use solidcraft_ui_egui::scenario::Harness;
let mut h = Harness::new();
h.app.preferences.ai_features = true;
h.app.agent_chat.open = false;
let (id, params) = id_of(&FileAction::AgentChat);
for open in [true, false] {
let out = h.call(&id, params.clone().unwrap_or_default());
assert_eq!(out["ok"], true, "{out}");
assert_eq!(h.app.agent_chat.open, open, "Agent Chat toggles as in the menu");
}
for action in [FileAction::Settings, FileAction::Shortcuts] {
let (id, params) = id_of(&action);
let out = h.call(&id, params.unwrap_or_default());
assert_eq!(out["ok"], true, "{action:?}: {out}");
}
// install_cli links into the system; its menu and control paths are the same `help::run`.
for item in HELP.into_iter().filter(|h| *h != "install_cli") {
let out = h.call(HELP_ID, json!({ "item": item }));
assert_eq!(out["ok"], true, "{item}: {out}");
}
assert_eq!(h.call(HELP_ID, json!({"item": "nothing"}))["ok"], false, "an unknown help item is an error");
let out = h.call(&id_of(&FileAction::Quit).0, json!({}));
assert_eq!((out["ok"].clone(), h.app.quit_requested), (json!(true), true), "{out}");
}
}
+5
View File
@@ -53,6 +53,11 @@ nix = { workspace = true }
# updated by its installer (MIT; already in the tree as a build dependency).
winreg = "0.56"
[target.'cfg(target_os = "macos")'.dependencies]
# The macOS menu bar (window_chrome::native_menu; MIT OR Apache-2.0, already in the tree through
# the desktop app).
muda = { version = "0.21.2", default-features = false }
[dev-dependencies]
solidcraft-engine = { workspace = true, features = ["bundled-render-library", "profile"] }
# Parse the source guard structurally, including code after inline test modules.
+5 -2
View File
@@ -2387,8 +2387,8 @@ fn set_config_option_checked(app: &mut SolveApp, id: &str, value: &str, listed_o
/// `ui.agent_chat` on the control channel (tests and screenshots): `enabled`, `command`,
/// `connect_at_start` and `open_at_start` (the launch preferences), `instructions` (the custom
/// instructions, #8220), `open`,
/// `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `attach` (a file's path, for the
/// instructions, #8220), `open`, `toggle` (show or hide the panel as its button does, unfolding a
/// folded one), `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `attach` (a file's path, for the
/// next message), `remove_file` (index), `send`, `attach_view`, `attach_selection`, `pick`,
/// `permission` (an option id, or `"cancel"`), `approve` (`true`/`false`, the oldest held
/// request), `cancel` (Stop), `send_now` (steer: stop, then send the queue), `remove_pending` (index), `undo` (the newest message's changes); returns the panel's state.
@@ -2503,6 +2503,9 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
if let Some(o) = p.get("open").and_then(Value::as_bool) {
app.agent_chat.open = o;
}
if p.get("toggle").and_then(Value::as_bool) == Some(true) {
toggle(app);
}
if let Some(s) = p.get("side").and_then(Value::as_str) {
if !matches!(s, "left" | "right") {
return Err("side must be left or right".into());
+10 -9
View File
@@ -1342,17 +1342,18 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
},
"ui.prefs" => wrap(crate::prefs::control(app, p)),
"ui.agent_chat" => wrap(crate::agent_chat::control(app, ctx, p)),
"ui.help" => match s("item") {
Some(i @ ("about" | "updates" | "shortcuts" | "report" | "crashes")) => {
crate::help::run(app, ctx, i);
ok(
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
)
"ui.help" => {
// The Help menu's items (the macOS menu bar sends the same); none only reports.
const ITEMS: [&str; 8] = ["about", "updates", "shortcuts", "report", "crashes", "docs", "messages", "install_cli"];
match s("item") {
Some(i) if !ITEMS.contains(&i) => return err(format!("unknown help item `{i}` ({})", ITEMS.join(", "))),
Some(i) => crate::help::run(app, ctx, i),
None => {}
}
_ => ok(
ok(
json!({"diagnostics": crate::help::diagnostics(app), "version": crate::help::VERSION, "commit": crate::help::COMMIT, "built": crate::help::BUILD_DATE}),
),
},
)
}
"ui.selectOther" => wrap(crate::select_other::control(app, p)),
"ui.menuPick" => {
let Some(key) = s("item") else { return err("missing `item` (id or label)") };
+1
View File
@@ -326,6 +326,7 @@ mod viewport_details;
mod viewport_layout;
mod viewport_status;
mod viewport_status_fixture;
pub mod window_chrome;
pub mod workspace;
use std::sync::mpsc::Receiver;
+18 -264
View File
@@ -1,211 +1,39 @@
//! The window's own title bar on Windows and Linux, where the window has no OS decorations
//! ([`SolveApp::custom_titlebar`]): empty space in the application bar drags the window and a
//! double-click there maximizes or restores it, caption buttons at the far right minimize,
//! maximize/restore and close, and thin invisible zones along the window's edges resize it.
//! Dragging uses the OS's own move loop, so Windows snapping (to the screen edges and corners)
//! works as with a native title bar.
//!
//! The resize zones are as wide as Windows' own resize border at the window's scale
//! ([`resize_border_px`]), corners win where they overlap the edges ([`resize_hit`]). On Windows
//! the desktop app answers the system's hit test with them (`apps/solidcraft/src/native_frame.rs`),
//! so the OS shows its resize cursors and resizes from the first frame; elsewhere egui zones above
//! everything else start an OS resize ([`resize_zones`]). macOS keeps its traffic lights over the bar
//! ([`SolveApp::integrated_titlebar`]); the web build has neither.
//!
//! Adapted from VectorCraft's `crates/ui-egui/src/titlebar.rs` (MIT OR Apache-2.0).
//! SolidCraft's window title bar: the shared window chrome ([`crate::window_chrome`]) with the
//! app's own answers. Closing asks about unsaved designs first ([`window_action`]); on Windows and
//! Linux without OS decorations ([`SolveApp::custom_titlebar`]) the application bar is the title
//! bar, and on macOS it runs under the traffic lights ([`SolveApp::integrated_titlebar`]).
use crate::icons::glyphs::paint_glyph;
use crate::ui_kit::BackingTooltipExt as _;
use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};
use crate::window_chrome::WindowAction;
pub use crate::window_chrome::{Caption, dpi_scaled_size, resize_border_px, resize_hit, resize_zones, toggle_maximize};
use crate::SolveApp;
use solidcraft_theme::Tokens;
/// One caption button: as wide as the native Windows ones.
pub const BUTTON_WIDTH: f32 = 46.0;
/// The three caption buttons together.
pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
/// Room for the macOS traffic lights at the bar's left end.
pub const TRAFFIC_LIGHTS: f32 = 76.0;
/// Windows' resize border at 100 % (96 DPI): `SM_CXSIZEFRAME` + `SM_CXPADDEDBORDER`, 4 + 4 px.
pub const RESIZE_BORDER_AT_96_DPI: f32 = 8.0;
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Caption {
Minimize,
Maximize,
Close,
}
/// Left to right.
pub const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
/// Caption button ids (stable, so tests and agents can find them).
pub fn caption_id(c: Caption) -> Id {
Id::unique(("titlebar-caption", c as u8))
}
fn maximized(ctx: &egui::Context) -> bool {
ctx.input(|i| i.viewport().maximized.unwrap_or(false))
}
pub fn toggle_maximize(ctx: &egui::Context) {
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
}
/// Window actions for the control channel (`ui.window`): minimize, maximize, restore, toggle
/// (maximize or restore), close.
pub fn window_action(app: &mut SolveApp, ctx: &egui::Context, action: &str) -> Result<(), String> {
match action {
"minimize" => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)),
"maximize" => ctx.send_viewport_cmd(ViewportCommand::Maximized(true)),
"restore" => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)),
"toggle" => toggle_maximize(ctx),
"close" => {
// Unsaved designs are asked about first; the window closes once they are answered.
crate::documents::request_quit(app);
if app.quit_requested {
ctx.send_viewport_cmd(ViewportCommand::Close);
}
}
other => return Err(format!("unknown window action `{other}` (minimize, maximize, restore, toggle, close)")),
}
run(app, ctx, WindowAction::parse(action)?);
Ok(())
}
/// The bar's background: a press-and-drag on empty space moves the window, a double-click
/// toggles maximize. Call it before laying out the bar so its buttons, registered later, take
/// their clicks.
pub fn drag_area(ui: &Ui, rect: Rect) {
let ctx = ui.ctx();
let resp = ui.interact(rect, Id::unique("titlebar-drag"), Sense::click_and_drag());
// egui hands a drag that starts on a click-only widget to the drag-sensing background below
// it, so remember whether the press itself landed on bare background.
if ctx.input(|i| i.pointer.any_pressed()) {
let bare = ctx.viewport(|v| v.hits.click.is_some_and(|w| w.id == resp.id));
ctx.data_mut(|d| d.insert_temp(resp.id, bare));
}
if resp.double_clicked() {
toggle_maximize(ctx);
} else if resp.drag_started_by(PointerButton::Primary) && ctx.data(|d| d.get_temp(resp.id).unwrap_or(false)) {
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
}
}
/// Minimize, Maximize/Restore and Close at the right end of `bar` (the application bar's
/// rect), full bar height, flush with the window's edge so a maximized window's corner hits
/// Close.
pub fn caption_buttons(app: &mut SolveApp, ui: &mut Ui, bar: Rect) {
let t = Tokens::get();
let max = maximized(ui.ctx());
let mut clicked = None;
for (i, c) in CAPTIONS.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(bar.right() - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height()));
let resp = ui.interact(r, caption_id(c), Sense::click());
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
(true, _) => t.app_bar_hover_subtle,
(_, true) => t.app_bar_hover_button,
_ if resp.has_focus() => t.app_bar_hover_button,
_ => Color32::TRANSPARENT,
};
ui.painter().rect_filled(r, 0.0, fill);
let ink = t.app_bar_text;
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));
let tip = match c {
Caption::Minimize => "Minimize",
Caption::Maximize if max => "Restore",
Caption::Maximize => "Maximize",
Caption::Close => "Close",
};
if resp.backing_on_hover_text(tip).clicked() {
clicked = Some(c);
}
}
let action = match clicked {
Some(Caption::Minimize) => "minimize",
Some(Caption::Maximize) => "toggle",
Some(Caption::Close) => "close",
None => return,
};
let _ = window_action(app, &ui.ctx().clone(), action);
}
/// The resize border in physical pixels at `scale` (1.0 = 96 DPI): Windows' default, scaled and
/// rounded, never less than a pixel.
pub fn resize_border_px(scale: f32) -> f32 {
let s = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
(RESIZE_BORDER_AT_96_DPI * s).round().max(1.0)
}
/// The side of a corner zone for an edge `edge` wide: twice the edge, so a corner is easy to hit.
pub fn corner_size(edge: f32) -> f32 {
2.0 * edge
}
/// The edge and corner zones of a window with rect `w`, edges `edge` wide: (zone, direction,
/// cursor). Corners come last so they win where they overlap the edges.
pub fn zones(w: Rect, edge: f32) -> [(Rect, ResizeDirection, CursorIcon); 8] {
use ResizeDirection::*;
let (e, c) = (edge, corner_size(edge));
[
(Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical),
(Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical),
(Rect::from_min_max(w.left_top(), pos2(w.left() + e, w.bottom())), West, CursorIcon::ResizeHorizontal),
(Rect::from_min_max(pos2(w.right() - e, w.top()), w.right_bottom()), East, CursorIcon::ResizeHorizontal),
(Rect::from_min_size(w.left_top(), vec2(c, c)), NorthWest, CursorIcon::ResizeNwSe),
(Rect::from_min_size(w.right_top() - vec2(c, 0.0), vec2(c, c)), NorthEast, CursorIcon::ResizeNeSw),
(Rect::from_min_size(w.left_bottom() - vec2(0.0, c), vec2(c, c)), SouthWest, CursorIcon::ResizeNeSw),
(Rect::from_min_size(w.right_bottom() - vec2(c, c), vec2(c, c)), SouthEast, CursorIcon::ResizeNwSe),
]
}
/// The resize direction at `p` in a window with rect `w` and edges `edge` wide, `None` away
/// from the edges and outside the window. A point is in the window when `min <= p < max` (pixel
/// rects: the right and bottom pixels are at `max - 1`). Corners win over edges.
pub fn resize_hit(w: Rect, p: Pos2, edge: f32) -> Option<ResizeDirection> {
if !(w.is_positive() && edge > 0.0 && p.x >= w.left() && p.x < w.right() && p.y >= w.top() && p.y < w.bottom()) {
return None;
}
zones(w, edge).into_iter().rev().find(|(r, ..)| p.x >= r.left() && p.x < r.right() && p.y >= r.top() && p.y < r.bottom()).map(|(_, d, _)| d)
}
/// The window's new physical size when it moves from `old_dpi` to `new_dpi` (Windows'
/// `WM_GETDPISCALEDSIZE`): the same logical size, rounded as winit rounds the size it then
/// applies, so the rect the OS suggests mid-move is exactly the rect the window gets.
pub fn dpi_scaled_size(size: (i32, i32), old_dpi: u32, new_dpi: u32) -> Option<(i32, i32)> {
if old_dpi == 0 || new_dpi == 0 || size.0 <= 0 || size.1 <= 0 {
return None;
}
let (old, new) = (f64::from(old_dpi) / 96.0, f64::from(new_dpi) / 96.0);
let scale = |v: i32| (f64::from(v) / old * new).round().clamp(1.0, f64::from(i32::MAX)) as i32;
Some((scale(size.0), scale(size.1)))
}
/// Invisible resize zones along the window's edges, above everything else; a press in one
/// starts an OS resize in that direction. None while the window is maximized or fullscreen.
pub fn resize_zones(ui: &mut Ui) {
let ctx = ui.ctx().clone();
if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) {
return;
}
let w = ctx.content_rect();
let ppp = ctx.pixels_per_point();
let edge = resize_border_px(ctx.input(|i| i.viewport().native_pixels_per_point).unwrap_or(ppp)) / ppp;
let layer = LayerId::new(Order::Foreground, Id::unique("titlebar-resize"));
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
for (i, (r, dir, cursor)) in zones(w, edge).into_iter().enumerate() {
let resp = zui.interact(r, Id::unique(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
if resp.drag_started_by(PointerButton::Primary) {
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
/// Runs a title-bar action as SolidCraft does.
pub(crate) fn run(app: &mut SolveApp, ctx: &egui::Context, action: WindowAction) {
if action == WindowAction::Close {
// Unsaved designs are asked about first; the window closes once they are answered.
crate::documents::request_quit(app);
if !app.quit_requested {
return;
}
}
action.send(ctx);
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Event, Pos2, RawInput, ViewportId};
use crate::window_chrome::{BUTTON_WIDTH, CAPTIONS, caption_id};
use egui::{Event, PointerButton, Pos2, RawInput, Rect, ResizeDirection, ViewportCommand, ViewportId, pos2, vec2};
use solidcraft_engine::Session;
use solidcraft_theme::Tokens;
/// A headless window `width` pt wide showing the application bar (as the title bar) and the
/// resize zones.
@@ -418,80 +246,6 @@ mod tests {
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::BeginResize(_))), "{cmds:?}");
}
#[test]
fn resize_border_is_windows_default_for_the_scale() {
for (dpi, px) in [(96.0, 8.0), (120.0, 10.0), (144.0, 12.0), (168.0, 14.0), (192.0, 16.0), (288.0, 24.0)] {
assert_eq!(resize_border_px(dpi / 96.0), px, "at {dpi} DPI");
}
// Never 0 px, whatever the scale says.
for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] {
assert_eq!(resize_border_px(bad), 8.0, "{bad}");
}
assert_eq!(resize_border_px(0.01), 1.0);
assert_eq!(corner_size(resize_border_px(1.5)), 24.0);
}
#[test]
fn hit_test_edges_corners_and_inside() {
use ResizeDirection::*;
// A window at (100, 50), 1000 x 700 px, at 150 %: 12 px edges, 24 px corners.
let w = Rect::from_min_size(pos2(100.0, 50.0), vec2(1000.0, 700.0));
let e = resize_border_px(1.5);
let (l, t, r, b) = (100.0, 50.0, 1099.0, 749.0); // the outermost pixels
for (p, want) in [
((l, 400.0), Some(West)),
((l + 11.0, 400.0), Some(West)),
((l + 12.0, 400.0), None),
((r, 400.0), Some(East)),
((r - 11.0, 400.0), Some(East)),
((r - 12.0, 400.0), None),
((600.0, t), Some(North)),
((600.0, t + 12.0), None),
((600.0, b), Some(South)),
((600.0, b - 11.0), Some(South)),
((l, t), Some(NorthWest)),
((r, t), Some(NorthEast)),
((l, b), Some(SouthWest)),
((r, b), Some(SouthEast)),
// Corners win over the edges, along both edges for the corner's length.
((l + 23.0, t), Some(NorthWest)),
((l, t + 23.0), Some(NorthWest)),
((l + 24.0, t), Some(North)),
((l, t + 24.0), Some(West)),
((r - 23.0, b), Some(SouthEast)),
((r, b - 23.0), Some(SouthEast)),
((r - 23.0, t), Some(NorthEast)),
((l, b - 23.0), Some(SouthWest)),
((600.0, 400.0), None),
// Outside the window: not ours.
((l - 1.0, 400.0), None),
((r + 1.0, 400.0), None),
((600.0, t - 1.0), None),
((600.0, b + 1.0), None),
] {
assert_eq!(resize_hit(w, pos2(p.0, p.1), e), want, "at {p:?}");
}
assert_eq!(resize_hit(w, pos2(l, 400.0), 0.0), None);
assert_eq!(resize_hit(Rect::NOTHING, pos2(l, 400.0), e), None);
// Every DPI has a zone on every edge.
for dpi in [96.0, 120.0, 144.0, 192.0] {
let e = resize_border_px(dpi / 96.0);
for (p, d) in [((l, 400.0), West), ((r, 400.0), East), ((600.0, t), North), ((600.0, b), South)] {
assert_eq!(resize_hit(w, pos2(p.0, p.1), e), Some(d), "{d:?} at {dpi} DPI");
}
}
}
#[test]
fn dpi_change_keeps_the_logical_size() {
assert_eq!(dpi_scaled_size((1600, 1000), 96, 144), Some((2400, 1500)));
assert_eq!(dpi_scaled_size((2400, 1500), 144, 96), Some((1600, 1000)));
assert_eq!(dpi_scaled_size((1001, 777), 120, 192), Some((1602, 1243)));
assert_eq!(dpi_scaled_size((1001, 777), 96, 96), Some((1001, 777)));
assert_eq!(dpi_scaled_size((1001, 777), 0, 96), None);
assert_eq!(dpi_scaled_size((0, 777), 96, 144), None);
}
#[test]
fn edges_resize_on_the_start_page() {
// At launch without a design the start page shows; its layout returned before the
+231 -230
View File
@@ -405,238 +405,239 @@ pub fn app_bar(app: &mut SolveApp, ui: &mut egui::Ui) {
let frame = Container::new(ui.ctx(), Surface::ApplicationBar).edge_frame(t.app_bar);
Container::new(ui.ctx(), Surface::ApplicationBar).show_edge(ui, app_bar.exact_size(APP_BAR_HEIGHT).show_separator_line(false), frame, |ui| {
let r = ui.max_rect();
// The bar is the window's title bar: its empty space moves the window.
if app.custom_titlebar || app.integrated_titlebar {
crate::titlebar::drag_area(ui, r);
}
let left = r.left() + if app.integrated_titlebar { crate::titlebar::TRAFFIC_LIGHTS } else { 0.0 };
// Keep the current header controls and product name when merging the new mark.
let compact_actions = r.right() - left < 640.0;
// Brand mark: the SolidCraft logo, drawn in code from the SVG's own coordinates
// (viewBox 357 x 412). Same 20 px height and centre as the old block.
{
let c = pos2(left + 20.0, r.center().y);
crate::icons::glyphs::brand_mark(ui.painter(), c);
}
if !compact_actions {
ui.painter().text(
pos2(left + 36.0, r.center().y),
Align2::LEFT_CENTER,
"SolidCraft",
crate::fonts::semibold(t.font_heading),
t.app_bar_text,
);
}
let mut x = left + if compact_actions { 56.0 } else { 128.0 };
let home = Rect::from_center_size(pos2(x - 4.0, r.center().y), vec2(32.0, 32.0));
let idle = !app.file_load.busy();
let home_response = ui.interact(home, ui.scope_id().with("appbar_home"), if idle { Sense::click() } else { Sense::hover() });
let home_tip = app.tr("ui.start_page");
home_response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, idle, home_tip));
crate::scenario::publish_handle("appbar:home", home.center());
crate::scenario::publish_handle("titlebar:home", home.center());
// Compatibility for scenarios that used the former Home tab.
crate::scenario::publish_target_as("tab:home", "titlebar:home", home);
crate::scenario::publish_rect("appbar:home", home);
if idle && (home_response.hovered() || home_response.has_focus()) {
ui.painter().rect_filled(home.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
}
icons::paint_app_bar(ui.painter(), home.shrink(7.0), "home", if idle { 1.0 } else { 0.35 });
if home_response.backing_on_hover_text(home_tip).clicked() && idle {
app.home.show(app.session.revision);
}
x = home.right() + 8.0;
app_bar_separator(ui, r, x, "brand");
x += 16.0;
// While a file loads the design waits: no menus or commands from the bar.
// The File menu: a text button (New, Open, Save, Save As, Export…). Its handle keeps the
// old `appbar:folder` name too, for scripts written against the folder button.
let file = {
let label = app.tr("ui.file").to_string();
let galley = ui.painter().layout_no_wrap(label.clone(), FontId::proportional(t.font_button), t.app_bar_text);
let br = Rect::from_min_size(pos2(x - 13.0, r.center().y - 13.0), vec2(galley.size().x + 16.0, 26.0));
x = br.right() + 17.0;
let resp = ui.interact(br, ui.scope_id().with(("ab", "file")), if idle { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, idle, &label));
crate::scenario::publish_handle("appbar:file", br.center());
crate::scenario::publish_target_as("appbar:folder", "appbar:file", br);
let open = ui.ctx().data(|d| d.get_temp::<bool>(egui::Id::unique("sc_file_menu")).unwrap_or(false));
if idle && (resp.hovered() || resp.has_focus() || open) {
ui.painter().rect_filled(br, t.radius_sm, t.app_bar_hover);
// The bar is the window's title bar: its empty space moves the window, the traffic lights
// or the caption buttons take its ends.
let chrome = crate::window_chrome::TitleBar { traffic_lights: app.integrated_titlebar, caption_buttons: app.custom_titlebar };
let action = chrome.show(ui, r, |ui, content| {
let left = content.left();
// Keep the current header controls and product name when merging the new mark.
let compact_actions = r.right() - left < 640.0;
// Brand mark: the SolidCraft logo, drawn in code from the SVG's own coordinates
// (viewBox 357 x 412). Same 20 px height and centre as the old block.
{
let c = pos2(left + 20.0, r.center().y);
crate::icons::glyphs::brand_mark(ui.painter(), c);
}
let ink = if idle { t.app_bar_text } else { t.app_bar_text.gamma_multiply(0.35) };
ui.painter().galley(pos2(br.left() + 8.0, br.center().y - galley.size().y / 2.0), galley, ink);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("sc_file_menu_x"), br.left()));
idle && resp.clicked()
};
// A disabled button is dimmed and ignores clicks (its tooltip still shows).
let click = |ui: &mut egui::Ui, at: f32, icon: &str, tip: &str, enabled: bool| -> bool {
let width = if compact_actions && matches!(icon, "save" | "undo" | "redo") { 22.0 } else { 32.0 };
let br = Rect::from_center_size(pos2(at, r.center().y), vec2(width, 32.0));
let resp = ui.interact(br, ui.scope_id().with(("ab", icon)), if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip));
crate::scenario::publish_target(&format!("appbar:{icon}"), br);
crate::scenario::publish_target(&format!("titlebar:{icon}"), br);
if enabled && (resp.hovered() || resp.has_focus()) {
ui.painter().rect_filled(br.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
}
icons::paint_app_bar(ui.painter(), Rect::from_center_size(br.center(), vec2(18.0, 18.0)), icon, if enabled { 1.0 } else { 0.35 });
let clicked = resp.backing_on_hover_text(tip).clicked();
enabled && clicked
};
let step = if compact_actions { 22.0 } else { 34.0 };
// Save, Undo and Redo use the same actions as the File menu and shortcuts.
let shown = design_shown(app) && idle;
let tip = |id: &str, key: &str| {
let label = app.tr(id);
if shown { format!("{label} ({key})") } else { solidcraft_ui_text::format("ui.needs_design", &[("command", label)]) }
};
let (save_tip, undo_tip, redo_tip) = (tip("ui.save", "Ctrl+S"), tip("ui.undo", "Ctrl+Z"), tip("ui.redo", "Ctrl+Y"));
if click(ui, x, "save", &save_tip, shown) {
save(app);
}
x += step;
// Narrow windows retain Save; Undo and Redo remain available from their shortcuts and menus.
if !compact_actions {
if click(ui, x, "undo", &undo_tip, shown) {
FileAction::Undo.run(app);
}
x += step;
if click(ui, x, "redo", &redo_tip, shown) {
FileAction::Redo.run(app);
}
x += step;
x = quick_access_bar(app, ui, r, x, step, shown);
}
if file {
app.ui.palette_open = false;
let ctx = ui.ctx().clone();
let open = ctx.data(|d| d.get_temp::<bool>(egui::Id::unique("sc_file_menu")).unwrap_or(false));
let pass = ctx.cumulative_pass_nr();
// The File button toggles the menu. Remember the pass it opened on so the same
// click isn't also treated as a click outside the menu (which would close it at once).
ctx.data_mut(|d| {
d.insert_temp(egui::Id::unique("sc_file_menu"), !open);
d.insert_temp(egui::Id::unique("sc_file_menu_opened"), pass);
});
}
// Document tabs follow the file actions; the house opens the start page.
let captions = if app.custom_titlebar { crate::titlebar::WIDTH } else { 0.0 };
let separator_x = x - step + if compact_actions { 19.0 } else { 24.0 };
app_bar_separator(ui, r, separator_x, "documents");
let tabs_start = separator_x + 10.0;
let report_text = solidcraft_ui_text::tr("ui.report_a_bug");
let report_font = FontId::proportional(t.font_button);
let label_width = ui.painter().layout_no_wrap(report_text.to_string(), report_font.clone(), t.app_bar_text).size().x;
// The version links to its release notes; measured here so Report a bug leaves it room.
let version =
ui.painter().layout_no_wrap(format!("v{}", crate::update::current_version()), FontId::proportional(t.font_small), t.app_bar_text);
let (report, report_label) = report_rect(r, captions, tabs_start, label_width, version.size().x, app.header);
// Settings' left edge; command search, when the bar holds it, sits just before it and
// moves the assistant button and the end of the document tabs left by its width.
let gear_left = report.left() - 68.0;
let search = (search_in_header(app) && design_shown(app))
.then(|| header_search_rect(ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
let search_shift = search.map_or(0.0, |s| s.width() + HEADER_SEARCH_GAP);
if let Some(search) = search {
// shortcut_box::show draws the one search editor wherever this rectangle is.
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("sc_ribbon_search"), search));
crate::scenario::publish_target("titlebar:search", search);
}
// Assistant, Settings, Help and Report a bug form the right-hand cluster.
if click(ui, help_x(report), "help", app.tr("ui.help"), true) {
app.help.menu = !app.help.menu;
let at = help_x(report);
ui.ctx().data_mut(|d| {
d.insert_temp(egui::Id::unique("sc_help_opened"), true);
d.insert_temp(egui::Id::unique("sc_help_anchor"), at);
});
}
// A newer release: its Update button stays before the right cluster, in the space up to
// the bar's own file actions on the left.
let controls_left = report.left() - CLUSTER_BUTTONS - search_shift;
let span = Rect::from_min_max(pos2(x, report.top()), pos2(controls_left - 2.0, report.bottom()));
// Labelled where Report a bug is (an icon-only Report a bug leaves the pill to fit by room).
let update_labeled = report_label || (app.header.report == ReportButton::Icon && r.width() >= 640.0);
let update = crate::update::button(app, ui, span, update_labeled);
let tabs_end = update.map_or(controls_left, |u| u.left()) - 8.0;
// Narrow title bars can fit only part of the tab strip. Its hit regions must not
// extend into the persistent report and window controls.
let tabs_rect = Rect::from_min_max(pos2(tabs_start.min(tabs_end), r.top()), pos2(tabs_end, r.bottom()));
let mut tabs_ui = ui.new_child(egui::UiBuilder::new().max_rect(tabs_rect));
tabs_ui.set_clip_rect(tabs_rect.intersect(ui.clip_rect()));
crate::documents::tabs(app, &mut tabs_ui, r, tabs_start, tabs_end);
let response = ui.interact(report, ui.scope_id().with("sc_report_button"), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), report_text));
crate::scenario::publish_target("titlebar:report", report);
let hot = response.hovered() || response.has_focus();
let tip = if app.header.report == ReportButton::Icon {
// Drawn like Settings and Help beside it.
if hot {
ui.painter().rect_filled(report.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
}
icons::paint_app_bar(ui.painter(), report.shrink(7.0), "bug_report", 1.0);
report_text
} else {
ui.painter().rect_filled(report, t.radius_sm, if hot { t.app_bar_hover_button } else { t.app_bar });
ui.painter().rect_stroke(report, t.radius_sm, Stroke::new(1.0, t.app_bar_separator), egui::StrokeKind::Inside);
let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0));
icons::paint_app_bar(ui.painter(), icon, "bug_report", 1.0);
if report_label {
if !compact_actions {
ui.painter().text(
pos2(report.left() + REPORT_TEXT_X, report.center().y),
pos2(left + 36.0, r.center().y),
Align2::LEFT_CENTER,
report_text,
report_font,
"SolidCraft",
crate::fonts::semibold(t.font_heading),
t.app_bar_text,
);
}
solidcraft_ui_text::tr_label("Report a bug — review a screenshot and open a GitHub draft")
};
if response.backing_on_hover_text(tip).clicked() {
crate::help::run(app, &ui.ctx().clone(), "report");
}
// Settings sits immediately left of Help (also Ctrl+,).
let gear = Rect::from_center_size(pos2(report.left() - 52.0, r.center().y), vec2(32.0, 32.0));
let gresp = ui.interact(gear, ui.scope_id().with("sc_prefs_button"), Sense::click());
if gresp.hovered() || gresp.has_focus() {
ui.painter().rect_filled(gear.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
}
icons::paint_app_bar(ui.painter(), gear.shrink(7.0), "settings", 1.0);
crate::scenario::publish_rect("titlebar:preferences", gear);
if crate::agent_chat::app_bar_button_shown(app) {
assistant_button(app, ui, Rect::from_center_size(pos2(report.left() - 86.0 - search_shift, r.center().y), vec2(32.0, 32.0)));
}
crate::scenario::publish_handle("titlebar:preferences", gear.center());
let prefs_tip = if cfg!(target_os = "macos") {
format!("{} (Cmd+,)", app.tr("ui.preferences"))
} else {
format!("{} (Ctrl+,)", app.tr("ui.preferences"))
};
if gresp.backing_on_hover_text(prefs_tip).clicked() {
crate::prefs::open_at(app, 0);
}
if app.header.version == VersionPlace::Header && version_room(r, captions, tabs_start, version.size().x).is_some() {
let galley = version;
let vrect = Align2::RIGHT_CENTER.anchor_size(pos2(r.right() - 12.0 - captions, r.center().y), galley.size());
let vresp = ui.interact(
vrect.expand2(vec2(2.0, 4.0)).intersect(Rect::everything_right_of(report.right())),
ui.scope_id().with("sc_version_link"),
Sense::click(),
);
let url = crate::update::release_notes_url(crate::update::current_version(), crate::update::release_build());
let vresp = vresp.on_hover_cursor(egui::CursorIcon::PointingHand).backing_on_hover_text(&url);
if vresp.hovered() || vresp.has_focus() {
ui.painter().hline(vrect.x_range(), vrect.bottom(), Stroke::new(1.0, t.app_bar_text));
let mut x = left + if compact_actions { 56.0 } else { 128.0 };
let home = Rect::from_center_size(pos2(x - 4.0, r.center().y), vec2(32.0, 32.0));
let idle = !app.file_load.busy();
let home_response = ui.interact(home, ui.scope_id().with("appbar_home"), if idle { Sense::click() } else { Sense::hover() });
let home_tip = app.tr("ui.start_page");
home_response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, idle, home_tip));
crate::scenario::publish_handle("appbar:home", home.center());
crate::scenario::publish_handle("titlebar:home", home.center());
// Compatibility for scenarios that used the former Home tab.
crate::scenario::publish_target_as("tab:home", "titlebar:home", home);
crate::scenario::publish_rect("appbar:home", home);
if idle && (home_response.hovered() || home_response.has_focus()) {
ui.painter().rect_filled(home.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
}
ui.painter().galley(vrect.min, galley, t.app_bar_text);
crate::scenario::publish_handle("titlebar:version", vrect.center());
if vresp.clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
icons::paint_app_bar(ui.painter(), home.shrink(7.0), "home", if idle { 1.0 } else { 0.35 });
if home_response.backing_on_hover_text(home_tip).clicked() && idle {
app.home.show(app.session.revision);
}
}
if app.custom_titlebar {
crate::titlebar::caption_buttons(app, ui, r);
x = home.right() + 8.0;
app_bar_separator(ui, r, x, "brand");
x += 16.0;
// While a file loads the design waits: no menus or commands from the bar.
// The File menu: a text button (New, Open, Save, Save As, Export…). Its handle keeps the
// old `appbar:folder` name too, for scripts written against the folder button.
let file = {
let label = app.tr("ui.file").to_string();
let galley = ui.painter().layout_no_wrap(label.clone(), FontId::proportional(t.font_button), t.app_bar_text);
let br = Rect::from_min_size(pos2(x - 13.0, r.center().y - 13.0), vec2(galley.size().x + 16.0, 26.0));
x = br.right() + 17.0;
let resp = ui.interact(br, ui.scope_id().with(("ab", "file")), if idle { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, idle, &label));
crate::scenario::publish_handle("appbar:file", br.center());
crate::scenario::publish_target_as("appbar:folder", "appbar:file", br);
let open = ui.ctx().data(|d| d.get_temp::<bool>(egui::Id::unique("sc_file_menu")).unwrap_or(false));
if idle && (resp.hovered() || resp.has_focus() || open) {
ui.painter().rect_filled(br, t.radius_sm, t.app_bar_hover);
}
let ink = if idle { t.app_bar_text } else { t.app_bar_text.gamma_multiply(0.35) };
ui.painter().galley(pos2(br.left() + 8.0, br.center().y - galley.size().y / 2.0), galley, ink);
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("sc_file_menu_x"), br.left()));
idle && resp.clicked()
};
// A disabled button is dimmed and ignores clicks (its tooltip still shows).
let click = |ui: &mut egui::Ui, at: f32, icon: &str, tip: &str, enabled: bool| -> bool {
let width = if compact_actions && matches!(icon, "save" | "undo" | "redo") { 22.0 } else { 32.0 };
let br = Rect::from_center_size(pos2(at, r.center().y), vec2(width, 32.0));
let resp = ui.interact(br, ui.scope_id().with(("ab", icon)), if enabled { Sense::click() } else { Sense::hover() });
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, enabled, tip));
crate::scenario::publish_target(&format!("appbar:{icon}"), br);
crate::scenario::publish_target(&format!("titlebar:{icon}"), br);
if enabled && (resp.hovered() || resp.has_focus()) {
ui.painter().rect_filled(br.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
}
icons::paint_app_bar(ui.painter(), Rect::from_center_size(br.center(), vec2(18.0, 18.0)), icon, if enabled { 1.0 } else { 0.35 });
let clicked = resp.backing_on_hover_text(tip).clicked();
enabled && clicked
};
let step = if compact_actions { 22.0 } else { 34.0 };
// Save, Undo and Redo use the same actions as the File menu and shortcuts.
let shown = design_shown(app) && idle;
let tip = |id: &str, key: &str| {
let label = app.tr(id);
if shown { format!("{label} ({key})") } else { solidcraft_ui_text::format("ui.needs_design", &[("command", label)]) }
};
let (save_tip, undo_tip, redo_tip) = (tip("ui.save", "Ctrl+S"), tip("ui.undo", "Ctrl+Z"), tip("ui.redo", "Ctrl+Y"));
if click(ui, x, "save", &save_tip, shown) {
save(app);
}
x += step;
// Narrow windows retain Save; Undo and Redo remain available from their shortcuts and menus.
if !compact_actions {
if click(ui, x, "undo", &undo_tip, shown) {
FileAction::Undo.run(app);
}
x += step;
if click(ui, x, "redo", &redo_tip, shown) {
FileAction::Redo.run(app);
}
x += step;
x = quick_access_bar(app, ui, r, x, step, shown);
}
if file {
app.ui.palette_open = false;
let ctx = ui.ctx().clone();
let open = ctx.data(|d| d.get_temp::<bool>(egui::Id::unique("sc_file_menu")).unwrap_or(false));
let pass = ctx.cumulative_pass_nr();
// The File button toggles the menu. Remember the pass it opened on so the same
// click isn't also treated as a click outside the menu (which would close it at once).
ctx.data_mut(|d| {
d.insert_temp(egui::Id::unique("sc_file_menu"), !open);
d.insert_temp(egui::Id::unique("sc_file_menu_opened"), pass);
});
}
// Document tabs follow the file actions; the house opens the start page.
let captions = r.right() - content.right();
let separator_x = x - step + if compact_actions { 19.0 } else { 24.0 };
app_bar_separator(ui, r, separator_x, "documents");
let tabs_start = separator_x + 10.0;
let report_text = solidcraft_ui_text::tr("ui.report_a_bug");
let report_font = FontId::proportional(t.font_button);
let label_width = ui.painter().layout_no_wrap(report_text.to_string(), report_font.clone(), t.app_bar_text).size().x;
// The version links to its release notes; measured here so Report a bug leaves it room.
let version =
ui.painter().layout_no_wrap(format!("v{}", crate::update::current_version()), FontId::proportional(t.font_small), t.app_bar_text);
let (report, report_label) = report_rect(r, captions, tabs_start, label_width, version.size().x, app.header);
// Settings' left edge; command search, when the bar holds it, sits just before it and
// moves the assistant button and the end of the document tabs left by its width.
let gear_left = report.left() - 68.0;
let search = (search_in_header(app) && design_shown(app))
.then(|| header_search_rect(ui.ctx(), r, gear_left - HEADER_SEARCH_GAP, report.left() - CLUSTER_BUTTONS - tabs_start));
let search_shift = search.map_or(0.0, |s| s.width() + HEADER_SEARCH_GAP);
if let Some(search) = search {
// shortcut_box::show draws the one search editor wherever this rectangle is.
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("sc_ribbon_search"), search));
crate::scenario::publish_target("titlebar:search", search);
}
// Assistant, Settings, Help and Report a bug form the right-hand cluster.
if click(ui, help_x(report), "help", app.tr("ui.help"), true) {
app.help.menu = !app.help.menu;
let at = help_x(report);
ui.ctx().data_mut(|d| {
d.insert_temp(egui::Id::unique("sc_help_opened"), true);
d.insert_temp(egui::Id::unique("sc_help_anchor"), at);
});
}
// A newer release: its Update button stays before the right cluster, in the space up to
// the bar's own file actions on the left.
let controls_left = report.left() - CLUSTER_BUTTONS - search_shift;
let span = Rect::from_min_max(pos2(x, report.top()), pos2(controls_left - 2.0, report.bottom()));
// Labelled where Report a bug is (an icon-only Report a bug leaves the pill to fit by room).
let update_labeled = report_label || (app.header.report == ReportButton::Icon && r.width() >= 640.0);
let update = crate::update::button(app, ui, span, update_labeled);
let tabs_end = update.map_or(controls_left, |u| u.left()) - 8.0;
// Narrow title bars can fit only part of the tab strip. Its hit regions must not
// extend into the persistent report and window controls.
let tabs_rect = Rect::from_min_max(pos2(tabs_start.min(tabs_end), r.top()), pos2(tabs_end, r.bottom()));
let mut tabs_ui = ui.new_child(egui::UiBuilder::new().max_rect(tabs_rect));
tabs_ui.set_clip_rect(tabs_rect.intersect(ui.clip_rect()));
crate::documents::tabs(app, &mut tabs_ui, r, tabs_start, tabs_end);
let response = ui.interact(report, ui.scope_id().with("sc_report_button"), Sense::click());
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), report_text));
crate::scenario::publish_target("titlebar:report", report);
let hot = response.hovered() || response.has_focus();
let tip = if app.header.report == ReportButton::Icon {
// Drawn like Settings and Help beside it.
if hot {
ui.painter().rect_filled(report.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
}
icons::paint_app_bar(ui.painter(), report.shrink(7.0), "bug_report", 1.0);
report_text
} else {
ui.painter().rect_filled(report, t.radius_sm, if hot { t.app_bar_hover_button } else { t.app_bar });
ui.painter().rect_stroke(report, t.radius_sm, Stroke::new(1.0, t.app_bar_separator), egui::StrokeKind::Inside);
let icon = Rect::from_center_size(pos2(report.left() + 14.0, report.center().y), vec2(18.0, 18.0));
icons::paint_app_bar(ui.painter(), icon, "bug_report", 1.0);
if report_label {
ui.painter().text(
pos2(report.left() + REPORT_TEXT_X, report.center().y),
Align2::LEFT_CENTER,
report_text,
report_font,
t.app_bar_text,
);
}
solidcraft_ui_text::tr_label("Report a bug — review a screenshot and open a GitHub draft")
};
if response.backing_on_hover_text(tip).clicked() {
crate::help::run(app, &ui.ctx().clone(), "report");
}
// Settings sits immediately left of Help (also Ctrl+,).
let gear = Rect::from_center_size(pos2(report.left() - 52.0, r.center().y), vec2(32.0, 32.0));
let gresp = ui.interact(gear, ui.scope_id().with("sc_prefs_button"), Sense::click());
if gresp.hovered() || gresp.has_focus() {
ui.painter().rect_filled(gear.shrink(3.0), t.radius_sm, t.app_bar_hover_button);
}
icons::paint_app_bar(ui.painter(), gear.shrink(7.0), "settings", 1.0);
crate::scenario::publish_rect("titlebar:preferences", gear);
if crate::agent_chat::app_bar_button_shown(app) {
assistant_button(app, ui, Rect::from_center_size(pos2(report.left() - 86.0 - search_shift, r.center().y), vec2(32.0, 32.0)));
}
crate::scenario::publish_handle("titlebar:preferences", gear.center());
let prefs_tip = if cfg!(target_os = "macos") {
format!("{} (Cmd+,)", app.tr("ui.preferences"))
} else {
format!("{} (Ctrl+,)", app.tr("ui.preferences"))
};
if gresp.backing_on_hover_text(prefs_tip).clicked() {
crate::prefs::open_at(app, 0);
}
if app.header.version == VersionPlace::Header && version_room(r, captions, tabs_start, version.size().x).is_some() {
let galley = version;
let vrect = Align2::RIGHT_CENTER.anchor_size(pos2(r.right() - 12.0 - captions, r.center().y), galley.size());
let vresp = ui.interact(
vrect.expand2(vec2(2.0, 4.0)).intersect(Rect::everything_right_of(report.right())),
ui.scope_id().with("sc_version_link"),
Sense::click(),
);
let url = crate::update::release_notes_url(crate::update::current_version(), crate::update::release_build());
let vresp = vresp.on_hover_cursor(egui::CursorIcon::PointingHand).backing_on_hover_text(&url);
if vresp.hovered() || vresp.has_focus() {
ui.painter().hline(vrect.x_range(), vrect.bottom(), Stroke::new(1.0, t.app_bar_text));
}
ui.painter().galley(vrect.min, galley, t.app_bar_text);
crate::scenario::publish_handle("titlebar:version", vrect.center());
if vresp.clicked() {
ui.ctx().open_url(egui::OpenUrl::new_tab(url));
}
}
});
if let Some(action) = action {
crate::titlebar::run(app, ui.ctx(), action);
}
});
file_menu(app, ui.ctx());
@@ -4335,7 +4336,7 @@ mod header_report_tests {
for scale in [1.0, 2.0] {
let width = physical_width / scale;
let bar = Rect::from_min_size(egui::Pos2::ZERO, vec2(width, APP_BAR_HEIGHT));
for captions in [0.0, crate::titlebar::WIDTH] {
for captions in [0.0, crate::window_chrome::WIDTH] {
// The English label, and a longer one (Russian is about 125 px).
for (label_width, layout) in [80.0, 125.0].into_iter().flat_map(|w| [(w, HeaderLayout::DESKTOP), (w, HeaderLayout::COMPACT)]) {
let (report, labeled) = report_rect(bar, captions, 354.0, label_width, 40.0, layout);
@@ -4426,8 +4427,8 @@ mod header_report_tests {
let version = h.ctx.fonts_mut(|f| f.layout_no_wrap(version, FontId::proportional(t.font_small), t.app_bar_text).size().x);
// The button's right edge is fixed; its width is twice the distance to its centre.
let bar = Rect::from_min_size(egui::Pos2::ZERO, vec2(1400.0, APP_BAR_HEIGHT));
let room = version_room(bar, crate::titlebar::WIDTH, 354.0, version).unwrap_or(8.0);
2.0 * (1400.0 - crate::titlebar::WIDTH - room - center.x)
let room = version_room(bar, crate::window_chrome::WIDTH, 354.0, version).unwrap_or(8.0);
2.0 * (1400.0 - crate::window_chrome::WIDTH - room - center.x)
};
let (en, ru) = (width_in("en"), width_in("ru"));
assert!((en - 114.34375).abs() < 0.05, "Plex Sans report button width: {en}");
@@ -4480,7 +4481,7 @@ mod header_report_tests {
let t = Tokens::get();
let version = format!("v{}", crate::update::current_version());
let version = h.ctx.fonts_mut(|f| f.layout_no_wrap(version, FontId::proportional(t.font_small), t.app_bar_text).size().x);
assert_eq!(report.right(), 1400.0 - crate::titlebar::WIDTH - RIGHT_GROUP.max(12.0 + version + 8.0));
assert_eq!(report.right(), 1400.0 - crate::window_chrome::WIDTH - RIGHT_GROUP.max(12.0 + version + 8.0));
assert_eq!(report.height(), 26.0);
assert!(report.width() > 100.0, "Report a bug carries its label: {report:?}");
assert_eq!(rect("titlebar:preferences").center().x, report.left() - 52.0);
@@ -4511,7 +4512,7 @@ mod header_report_tests {
// Report a bug: an icon the size of Settings and Help, still named for assistive tools.
let report = rect("titlebar:report");
assert_eq!(report.size(), vec2(32.0, 32.0));
assert_eq!(report.right(), 1400.0 - crate::titlebar::WIDTH - 8.0, "no room is kept for the version");
assert_eq!(report.right(), 1400.0 - crate::window_chrome::WIDTH - 8.0, "no room is kept for the version");
assert_eq!(rect("appbar:help").size(), report.size());
assert!(h.widget("Report a bug").is_some(), "the icon keeps its accessible name");
assert!(crate::scenario::handle_at("titlebar:version").is_none(), "the version left the bar");
@@ -4571,7 +4572,7 @@ mod header_report_tests {
assert!(candidate > release + 15.0, "{release} {candidate}");
for width in [700.0, 900.0, 1400.0] {
let bar = Rect::from_min_size(egui::Pos2::ZERO, vec2(width, APP_BAR_HEIGHT));
for captions in [0.0, crate::titlebar::WIDTH] {
for captions in [0.0, crate::window_chrome::WIDTH] {
// A release keeps the button where it always was.
assert_eq!(version_room(bar, captions, 354.0, release), Some(RIGHT_GROUP), "{width} {captions}");
for (k, version_width) in [release, candidate, 2.0 * candidate].into_iter().enumerate() {
+506
View File
@@ -0,0 +1,506 @@
//! Window chrome any Craft app shares: the window's settings ([`viewport`]) and its title bar,
//! drawn from plain inputs ([`TitleBar`]) and answering with [`WindowAction`]s for the app to run.
//!
//! The OS window frame is kept. On macOS the OS title bar is hidden and the content extends under
//! it, with room left for the traffic lights ([`TRAFFIC_LIGHTS`]). On Windows and Linux an app
//! can drop the OS decorations: then empty space in the bar drags the window and a double-click
//! there maximizes or restores it, caption buttons at the far right minimize, maximize/restore and
//! close, and thin invisible zones along the window's edges resize it. Dragging uses the OS's own
//! move loop, so Windows snapping (to the screen edges and corners) works as with a native title
//! bar.
//!
//! The resize zones are as wide as Windows' own resize border at the window's scale
//! ([`resize_border_px`]), corners win where they overlap the edges ([`resize_hit`]). An app that
//! answers the system's hit test itself (SolidCraft's `apps/solidcraft/src/native_frame.rs`) uses
//! them there, so the OS shows its resize cursors and resizes from the first frame; elsewhere egui
//! zones above everything else start an OS resize ([`resize_zones`]). The web build has neither.
//!
//! Nothing here knows an app's state: closing is a [`WindowAction::Close`] the app answers (it may
//! ask about unsaved work first).
//!
//! Adapted from VectorCraft's `crates/ui-egui/src/titlebar.rs` (MIT OR Apache-2.0).
use crate::icons::glyphs::paint_glyph;
use crate::ui_kit::BackingTooltipExt as _;
use egui::{
Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportBuilder, ViewportCommand, pos2,
vec2,
};
use solidcraft_theme::Tokens;
pub mod native_menu;
/// One caption button: as wide as the native Windows ones.
pub const BUTTON_WIDTH: f32 = 46.0;
/// The three caption buttons together.
pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH;
/// Room for the macOS traffic lights at the bar's left end.
pub const TRAFFIC_LIGHTS: f32 = 76.0;
/// Windows' resize border at 100 % (96 DPI): `SM_CXSIZEFRAME` + `SM_CXPADDEDBORDER`, 4 + 4 px.
pub const RESIZE_BORDER_AT_96_DPI: f32 = 8.0;
/// The window's settings: `title` and `app_id` (the desktop entry's reverse-DNS id) are the
/// app's; `custom_titlebar` drops the OS decorations so the app's bar is the title bar. On macOS
/// the OS title bar is hidden and the content runs under it. The app adds its own icon.
pub fn viewport(title: &str, app_id: &str, custom_titlebar: bool) -> ViewportBuilder {
ViewportBuilder::default()
.with_title(title)
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_decorations(!custom_titlebar)
.with_fullsize_content_view(true)
.with_titlebar_shown(false)
.with_title_shown(false)
.with_app_id(app_id)
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Caption {
Minimize,
Maximize,
Close,
}
/// Left to right.
pub const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close];
/// Caption button ids (stable, so tests and agents can find them).
pub fn caption_id(c: Caption) -> Id {
Id::unique(("titlebar-caption", c as u8))
}
/// What the window is asked to do, by a caption button or by name (an app's control channel).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WindowAction {
Minimize,
Maximize,
Restore,
/// Maximize or restore.
Toggle,
Close,
}
impl WindowAction {
pub const ALL: [Self; 5] = [Self::Minimize, Self::Maximize, Self::Restore, Self::Toggle, Self::Close];
pub fn name(self) -> &'static str {
match self {
Self::Minimize => "minimize",
Self::Maximize => "maximize",
Self::Restore => "restore",
Self::Toggle => "toggle",
Self::Close => "close",
}
}
pub fn parse(name: &str) -> Result<Self, String> {
Self::ALL
.into_iter()
.find(|a| a.name() == name)
.ok_or_else(|| format!("unknown window action `{name}` (minimize, maximize, restore, toggle, close)"))
}
/// Sends the action's viewport command. [`Self::Close`] closes the window at once: an app
/// with unsaved work asks first and sends it once answered.
pub fn send(self, ctx: &egui::Context) {
match self {
Self::Minimize => ctx.send_viewport_cmd(ViewportCommand::Minimized(true)),
Self::Maximize => ctx.send_viewport_cmd(ViewportCommand::Maximized(true)),
Self::Restore => ctx.send_viewport_cmd(ViewportCommand::Maximized(false)),
Self::Toggle => toggle_maximize(ctx),
Self::Close => ctx.send_viewport_cmd(ViewportCommand::Close),
}
}
}
pub fn maximized(ctx: &egui::Context) -> bool {
ctx.input(|i| i.viewport().maximized.unwrap_or(false))
}
pub fn toggle_maximize(ctx: &egui::Context) {
ctx.send_viewport_cmd(ViewportCommand::Maximized(!maximized(ctx)));
}
/// How the window's title bar behaves: which of the OS's controls share the bar.
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub struct TitleBar {
/// macOS: the OS draws its traffic lights over the bar's left end.
pub traffic_lights: bool,
/// No OS decorations (Windows, Linux): the bar has its own caption buttons at the right end.
pub caption_buttons: bool,
}
/// Where a [`TitleBar`] puts things in the bar's rect.
#[derive(Clone, Copy, PartialEq, Debug)]
pub struct TitleBarLayout {
/// Empty space here moves the window; `None` under the OS's own title bar.
pub drag: Option<Rect>,
/// What is left for the app's contents.
pub content: Rect,
/// The caption buttons, flush with the window's right edge.
pub captions: Option<Rect>,
}
impl TitleBar {
pub fn layout(self, bar: Rect) -> TitleBarLayout {
let left = bar.left() + if self.traffic_lights { TRAFFIC_LIGHTS } else { 0.0 };
let right = bar.right() - if self.caption_buttons { WIDTH } else { 0.0 };
TitleBarLayout {
drag: (self.traffic_lights || self.caption_buttons).then_some(bar),
content: Rect::from_min_max(pos2(left, bar.top()), pos2(right, bar.bottom())),
captions: self.caption_buttons.then(|| Rect::from_min_max(pos2(right, bar.top()), bar.right_bottom())),
}
}
/// Lays out the bar in `bar`: the drag area first (so the contents' widgets, registered
/// later, take their clicks), `contents` in the rect left for them, then the caption buttons.
/// The caption button clicked, as the action for the app to run.
pub fn show(self, ui: &mut Ui, bar: Rect, contents: impl FnOnce(&mut Ui, Rect)) -> Option<WindowAction> {
let layout = self.layout(bar);
if let Some(r) = layout.drag {
drag_area(ui, r);
}
contents(ui, layout.content);
if self.caption_buttons { caption_buttons(ui, bar) } else { None }
}
}
/// The bar's background: a press-and-drag on empty space moves the window, a double-click
/// toggles maximize. Call it before laying out the bar so its buttons, registered later, take
/// their clicks.
pub fn drag_area(ui: &Ui, rect: Rect) {
let ctx = ui.ctx();
let resp = ui.interact(rect, Id::unique("titlebar-drag"), Sense::click_and_drag());
// egui hands a drag that starts on a click-only widget to the drag-sensing background below
// it, so remember whether the press itself landed on bare background.
if ctx.input(|i| i.pointer.any_pressed()) {
let bare = ctx.viewport(|v| v.hits.click.is_some_and(|w| w.id == resp.id));
ctx.data_mut(|d| d.insert_temp(resp.id, bare));
}
if resp.double_clicked() {
toggle_maximize(ctx);
} else if resp.drag_started_by(PointerButton::Primary) && ctx.data(|d| d.get_temp(resp.id).unwrap_or(false)) {
ctx.send_viewport_cmd(ViewportCommand::StartDrag);
}
}
/// Minimize, Maximize/Restore and Close at the right end of `bar`, full bar height, flush with
/// the window's edge so a maximized window's corner hits Close. The one clicked, as the action
/// for the app to run.
pub fn caption_buttons(ui: &mut Ui, bar: Rect) -> Option<WindowAction> {
let t = Tokens::get();
let max = maximized(ui.ctx());
let mut clicked = None;
for (i, c) in CAPTIONS.into_iter().enumerate() {
let r = Rect::from_min_size(pos2(bar.right() - WIDTH + i as f32 * BUTTON_WIDTH, bar.top()), vec2(BUTTON_WIDTH, bar.height()));
let resp = ui.interact(r, caption_id(c), Sense::click());
let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {
(true, _) => t.app_bar_hover_subtle,
(_, true) => t.app_bar_hover_button,
_ if resp.has_focus() => t.app_bar_hover_button,
_ => Color32::TRANSPARENT,
};
ui.painter().rect_filled(r, 0.0, fill);
paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, t.app_bar_text));
let tip = match c {
Caption::Minimize => "Minimize",
Caption::Maximize if max => "Restore",
Caption::Maximize => "Maximize",
Caption::Close => "Close",
};
if resp.backing_on_hover_text(tip).clicked() {
clicked = Some(match c {
Caption::Minimize => WindowAction::Minimize,
Caption::Maximize => WindowAction::Toggle,
Caption::Close => WindowAction::Close,
});
}
}
clicked
}
/// The resize border in physical pixels at `scale` (1.0 = 96 DPI): Windows' default, scaled and
/// rounded, never less than a pixel.
pub fn resize_border_px(scale: f32) -> f32 {
let s = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
(RESIZE_BORDER_AT_96_DPI * s).round().max(1.0)
}
/// The side of a corner zone for an edge `edge` wide: twice the edge, so a corner is easy to hit.
pub fn corner_size(edge: f32) -> f32 {
2.0 * edge
}
/// The edge and corner zones of a window with rect `w`, edges `edge` wide: (zone, direction,
/// cursor). Corners come last so they win where they overlap the edges.
pub fn zones(w: Rect, edge: f32) -> [(Rect, ResizeDirection, CursorIcon); 8] {
use ResizeDirection::*;
let (e, c) = (edge, corner_size(edge));
[
(Rect::from_min_max(w.left_top(), pos2(w.right(), w.top() + e)), North, CursorIcon::ResizeVertical),
(Rect::from_min_max(pos2(w.left(), w.bottom() - e), w.right_bottom()), South, CursorIcon::ResizeVertical),
(Rect::from_min_max(w.left_top(), pos2(w.left() + e, w.bottom())), West, CursorIcon::ResizeHorizontal),
(Rect::from_min_max(pos2(w.right() - e, w.top()), w.right_bottom()), East, CursorIcon::ResizeHorizontal),
(Rect::from_min_size(w.left_top(), vec2(c, c)), NorthWest, CursorIcon::ResizeNwSe),
(Rect::from_min_size(w.right_top() - vec2(c, 0.0), vec2(c, c)), NorthEast, CursorIcon::ResizeNeSw),
(Rect::from_min_size(w.left_bottom() - vec2(0.0, c), vec2(c, c)), SouthWest, CursorIcon::ResizeNeSw),
(Rect::from_min_size(w.right_bottom() - vec2(c, c), vec2(c, c)), SouthEast, CursorIcon::ResizeNwSe),
]
}
/// The resize direction at `p` in a window with rect `w` and edges `edge` wide, `None` away
/// from the edges and outside the window. A point is in the window when `min <= p < max` (pixel
/// rects: the right and bottom pixels are at `max - 1`). Corners win over edges.
pub fn resize_hit(w: Rect, p: Pos2, edge: f32) -> Option<ResizeDirection> {
if !(w.is_positive() && edge > 0.0 && p.x >= w.left() && p.x < w.right() && p.y >= w.top() && p.y < w.bottom()) {
return None;
}
zones(w, edge).into_iter().rev().find(|(r, ..)| p.x >= r.left() && p.x < r.right() && p.y >= r.top() && p.y < r.bottom()).map(|(_, d, _)| d)
}
/// The window's new physical size when it moves from `old_dpi` to `new_dpi` (Windows'
/// `WM_GETDPISCALEDSIZE`): the same logical size, rounded as winit rounds the size it then
/// applies, so the rect the OS suggests mid-move is exactly the rect the window gets.
pub fn dpi_scaled_size(size: (i32, i32), old_dpi: u32, new_dpi: u32) -> Option<(i32, i32)> {
if old_dpi == 0 || new_dpi == 0 || size.0 <= 0 || size.1 <= 0 {
return None;
}
let (old, new) = (f64::from(old_dpi) / 96.0, f64::from(new_dpi) / 96.0);
let scale = |v: i32| (f64::from(v) / old * new).round().clamp(1.0, f64::from(i32::MAX)) as i32;
Some((scale(size.0), scale(size.1)))
}
/// Invisible resize zones along the window's edges, above everything else; a press in one
/// starts an OS resize in that direction. None while the window is maximized or fullscreen.
pub fn resize_zones(ui: &mut Ui) {
let ctx = ui.ctx().clone();
if maximized(&ctx) || ctx.input(|i| i.viewport().fullscreen.unwrap_or(false)) {
return;
}
let w = ctx.content_rect();
let ppp = ctx.pixels_per_point();
let edge = resize_border_px(ctx.input(|i| i.viewport().native_pixels_per_point).unwrap_or(ppp)) / ppp;
let layer = LayerId::new(Order::Foreground, Id::unique("titlebar-resize"));
let zui = ui.new_child(egui::UiBuilder::new().layer_id(layer).max_rect(w));
for (i, (r, dir, cursor)) in zones(w, edge).into_iter().enumerate() {
let resp = zui.interact(r, Id::unique(("titlebar-resize", i)), Sense::drag()).on_hover_cursor(cursor);
if resp.drag_started_by(PointerButton::Primary) {
ctx.send_viewport_cmd(ViewportCommand::BeginResize(dir));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui::{Event, PointerButton, Pos2, RawInput, Rect, ViewportCommand, ViewportId, pos2, vec2};
const BAR: f32 = 34.0;
/// A headless window `width` pt wide that draws only a title bar, from plain inputs.
struct Bar {
ctx: egui::Context,
bar: TitleBar,
width: f32,
time: f64,
maximized: bool,
/// The rect the bar handed its contents, and the action it returned, last frame.
content: Option<Rect>,
action: Option<WindowAction>,
}
impl Bar {
fn new(bar: TitleBar, width: f32) -> Self {
Self { ctx: crate::fonts::test_context(), bar, width, time: 0.0, maximized: false, content: None, action: None }
}
fn rect(&self) -> Rect {
Rect::from_min_size(Pos2::ZERO, vec2(self.width, BAR))
}
fn frame(&mut self, events: Vec<Event>) -> Vec<ViewportCommand> {
self.time += 0.05;
let mut input = RawInput {
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 700.0))),
time: Some(self.time),
events,
..Default::default()
};
if let Some(v) = input.viewports.get_mut(&ViewportId::ROOT) {
v.maximized = Some(self.maximized);
}
let (bar, rect) = (self.bar, self.rect());
let (mut content, mut action) = (None, None);
let mut out = self.ctx.run_ui(input, |ui| {
action = bar.show(ui, rect, |_, r| content = Some(r));
});
out.textures_delta.clear();
(self.content, self.action) = (content, action);
out.viewport_output.remove(&ViewportId::ROOT).map(|v| v.commands).unwrap_or_default()
}
/// Move to `path[0]`, press, move along `path`, release: the commands sent and the
/// actions returned.
fn gesture(&mut self, path: &[Pos2]) -> (Vec<ViewportCommand>, Vec<WindowAction>) {
let button = |p: Pos2, pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() };
let mut events = vec![vec![Event::PointerMoved(path[0])], vec![button(path[0], true)]];
events.extend(path[1..].iter().map(|p| vec![Event::PointerMoved(*p)]));
events.push(vec![button(*path.last().unwrap_or(&path[0]), false)]);
let (mut cmds, mut actions) = (vec![], vec![]);
for e in events {
cmds.extend(self.frame(e));
actions.extend(self.action);
}
(cmds, actions)
}
}
#[test]
fn title_bar_from_plain_inputs_insets_for_the_traffic_lights_and_drags_from_empty_space() {
let bar = Rect::from_min_size(Pos2::ZERO, vec2(1200.0, BAR));
// macOS: the OS draws the traffic lights over the bar's left end; no caption buttons.
let mac = TitleBar { traffic_lights: true, caption_buttons: false };
let layout = mac.layout(bar);
assert_eq!(layout.drag, Some(bar));
assert_eq!(layout.content, Rect::from_min_max(pos2(TRAFFIC_LIGHTS, 0.0), pos2(1200.0, BAR)));
assert_eq!(layout.captions, None);
// Windows and Linux without OS decorations: caption buttons at the right end.
let own = TitleBar { traffic_lights: false, caption_buttons: true };
let layout = own.layout(bar);
assert_eq!(layout.drag, Some(bar));
assert_eq!(layout.content, Rect::from_min_max(pos2(0.0, 0.0), pos2(1200.0 - WIDTH, BAR)));
assert_eq!(layout.captions, Some(Rect::from_min_max(pos2(1200.0 - WIDTH, 0.0), pos2(1200.0, BAR))));
// The OS's own title bar: the bar is plain content and moves nothing.
let os = TitleBar { traffic_lights: false, caption_buttons: false };
assert_eq!(os.layout(bar), TitleBarLayout { drag: None, content: bar, captions: None });
let mut w = Bar::new(mac, 1200.0);
w.frame(vec![]);
assert_eq!(w.content, Some(mac.layout(bar).content), "the contents get the inset rect");
let empty = pos2(600.0, 17.0);
let (cmds, _) = w.gesture(&[empty, empty + vec2(12.0, 4.0), empty + vec2(30.0, 8.0)]);
assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");
w.time += 1.0;
let (mut cmds, _) = w.gesture(&[empty]);
cmds.extend(w.gesture(&[empty]).0);
assert!(cmds.contains(&ViewportCommand::Maximized(true)), "double-click maximizes: {cmds:?}");
let mut w = Bar::new(TitleBar { traffic_lights: false, caption_buttons: false }, 1200.0);
w.frame(vec![]);
let (cmds, _) = w.gesture(&[empty, empty + vec2(30.0, 8.0)]);
assert!(cmds.is_empty(), "no drag area under the OS's own title bar: {cmds:?}");
}
#[test]
fn caption_buttons_return_actions_and_leave_close_to_the_app() {
let mut w = Bar::new(TitleBar { traffic_lights: false, caption_buttons: true }, 1200.0);
w.frame(vec![]);
let at = |c: Caption| w.ctx.read_response(caption_id(c)).map(|r| r.rect.center()).unwrap_or(Pos2::ZERO);
let (min, max, close) = (at(Caption::Minimize), at(Caption::Maximize), at(Caption::Close));
assert_eq!(close, pos2(1200.0 - BUTTON_WIDTH / 2.0, BAR / 2.0));
assert_eq!(w.gesture(&[min]).1, vec![WindowAction::Minimize]);
assert_eq!(w.gesture(&[max]).1, vec![WindowAction::Toggle]);
let (cmds, actions) = w.gesture(&[close]);
assert_eq!(actions, vec![WindowAction::Close]);
assert!(!cmds.contains(&ViewportCommand::Close), "the app decides whether the window closes: {cmds:?}");
// A drag from a caption button doesn't move the window.
let (cmds, _) = w.gesture(&[min, min + vec2(-30.0, 8.0)]);
assert!(!cmds.iter().any(|c| matches!(c, ViewportCommand::StartDrag)), "{cmds:?}");
}
#[test]
fn window_actions_parse_their_names() {
for a in WindowAction::ALL {
assert_eq!(WindowAction::parse(a.name()), Ok(a));
}
assert_eq!(WindowAction::parse("shrink"), Err("unknown window action `shrink` (minimize, maximize, restore, toggle, close)".to_string()));
}
#[test]
fn viewport_has_the_shared_window_settings() {
for custom in [false, true] {
let v = viewport("Test", "org.example.Test", custom);
assert_eq!(v.title.as_deref(), Some("Test"));
assert_eq!(v.app_id.as_deref(), Some("org.example.Test"));
assert_eq!(v.decorations, Some(!custom));
assert_eq!((v.fullsize_content_view, v.titlebar_shown, v.title_shown), (Some(true), Some(false), Some(false)));
assert_eq!((v.inner_size, v.min_inner_size), (Some(vec2(1600.0, 1000.0)), Some(vec2(960.0, 600.0))));
assert_eq!(v.drag_and_drop, Some(true));
}
}
#[test]
fn resize_border_is_windows_default_for_the_scale() {
for (dpi, px) in [(96.0, 8.0), (120.0, 10.0), (144.0, 12.0), (168.0, 14.0), (192.0, 16.0), (288.0, 24.0)] {
assert_eq!(resize_border_px(dpi / 96.0), px, "at {dpi} DPI");
}
// Never 0 px, whatever the scale says.
for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] {
assert_eq!(resize_border_px(bad), 8.0, "{bad}");
}
assert_eq!(resize_border_px(0.01), 1.0);
assert_eq!(corner_size(resize_border_px(1.5)), 24.0);
}
#[test]
fn hit_test_edges_corners_and_inside() {
use ResizeDirection::*;
// A window at (100, 50), 1000 x 700 px, at 150 %: 12 px edges, 24 px corners.
let w = Rect::from_min_size(pos2(100.0, 50.0), vec2(1000.0, 700.0));
let e = resize_border_px(1.5);
let (l, t, r, b) = (100.0, 50.0, 1099.0, 749.0); // the outermost pixels
for (p, want) in [
((l, 400.0), Some(West)),
((l + 11.0, 400.0), Some(West)),
((l + 12.0, 400.0), None),
((r, 400.0), Some(East)),
((r - 11.0, 400.0), Some(East)),
((r - 12.0, 400.0), None),
((600.0, t), Some(North)),
((600.0, t + 12.0), None),
((600.0, b), Some(South)),
((600.0, b - 11.0), Some(South)),
((l, t), Some(NorthWest)),
((r, t), Some(NorthEast)),
((l, b), Some(SouthWest)),
((r, b), Some(SouthEast)),
// Corners win over the edges, along both edges for the corner's length.
((l + 23.0, t), Some(NorthWest)),
((l, t + 23.0), Some(NorthWest)),
((l + 24.0, t), Some(North)),
((l, t + 24.0), Some(West)),
((r - 23.0, b), Some(SouthEast)),
((r, b - 23.0), Some(SouthEast)),
((r - 23.0, t), Some(NorthEast)),
((l, b - 23.0), Some(SouthWest)),
((600.0, 400.0), None),
// Outside the window: not ours.
((l - 1.0, 400.0), None),
((r + 1.0, 400.0), None),
((600.0, t - 1.0), None),
((600.0, b + 1.0), None),
] {
assert_eq!(resize_hit(w, pos2(p.0, p.1), e), want, "at {p:?}");
}
assert_eq!(resize_hit(w, pos2(l, 400.0), 0.0), None);
assert_eq!(resize_hit(Rect::NOTHING, pos2(l, 400.0), e), None);
// Every DPI has a zone on every edge.
for dpi in [96.0, 120.0, 144.0, 192.0] {
let e = resize_border_px(dpi / 96.0);
for (p, d) in [((l, 400.0), West), ((r, 400.0), East), ((600.0, t), North), ((600.0, b), South)] {
assert_eq!(resize_hit(w, pos2(p.0, p.1), e), Some(d), "{d:?} at {dpi} DPI");
}
}
}
#[test]
fn dpi_change_keeps_the_logical_size() {
assert_eq!(dpi_scaled_size((1600, 1000), 96, 144), Some((2400, 1500)));
assert_eq!(dpi_scaled_size((2400, 1500), 144, 96), Some((1600, 1000)));
assert_eq!(dpi_scaled_size((1001, 777), 120, 192), Some((1602, 1243)));
assert_eq!(dpi_scaled_size((1001, 777), 96, 96), Some((1001, 777)));
assert_eq!(dpi_scaled_size((1001, 777), 0, 96), None);
assert_eq!(dpi_scaled_size((0, 777), 96, 144), None);
}
}
@@ -0,0 +1,427 @@
//! The macOS menu bar, built from the kit's menu entries ([`Item`]) and answering with the id
//! (and parameters) of the entry chosen, for the app to run through its own command table.
//!
//! The bar follows the system's layout: the app menu (About, the app's entries, Services, Hide,
//! Hide Others, Show All, Quit), then the app's menus. The menu whose id is [`WINDOW`] also gets
//! the system's window entries (Minimize, Zoom, Enter Full Screen, Bring All to Front). An entry's
//! `shortcut` is a menu accelerator (`Cmd+Shift+S`, `Cmd+,`). A menu's own id (`menu.file`, ...)
//! names it for [`MenuTable::replace`] and is never dispatched. The layout ([`MenuTable`]) is the
//! same everywhere; only macOS turns it into native menus ([`NativeMenu`]); Windows and Linux keep
//! their default (no menu bar).
use crate::ui_kit::Item;
use serde_json::Value;
/// The id of the app's Window menu: the system's window entries are added to it.
pub const WINDOW: &str = "menu.window";
/// The id of the app menu (the one titled with the app's name).
pub const APP: &str = "menu.app";
/// What the app puts in its menu bar.
#[derive(Clone, Debug)]
pub struct MenuBar {
/// The app's name: the app menu's title, About and Hide name it.
pub title: String,
/// Shown in About.
pub version: String,
/// The app menu's own entries, between About and Services (Settings, ...).
pub app: Vec<Item>,
/// The app menu's last entry.
pub quit: Item,
/// The menus after the app menu, left to right: each `Item`'s children are its entries.
pub menus: Vec<Item>,
}
/// An entry the system provides and runs itself.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum System {
About,
Services,
Hide,
HideOthers,
ShowAll,
Minimize,
Zoom,
FullScreen,
BringAllToFront,
}
/// One menu row. An entry carries the key its native item is made with; the [`MenuTable`] maps
/// it back to the entry's id and parameters.
#[derive(Debug)]
pub enum Row<'a> {
Separator,
/// A section heading: a row that can't be chosen.
Heading(&'a str),
Entry {
key: String,
item: &'a Item,
},
Submenu {
item: &'a Item,
rows: Vec<Row<'a>>,
},
System(System),
}
/// One menu of the bar, left to right.
#[derive(Debug)]
pub struct Menu<'a> {
pub id: &'a str,
pub label: &'a str,
/// Off: the menu shows greyed and does not open.
pub enabled: bool,
pub rows: Vec<Row<'a>>,
}
#[derive(Debug)]
struct Entry {
key: String,
id: String,
params: Option<Value>,
/// The ids of the menus it sits in, outermost first.
within: Vec<String>,
}
/// The bar's entries by the key of their native item, and its submenus.
#[derive(Debug, Default)]
pub struct MenuTable {
entries: Vec<Entry>,
/// Every submenu's id with the ids of the menus it sits in.
submenus: Vec<(String, Vec<String>)>,
next: u64,
}
impl MenuTable {
/// Lays out the bar, registering every entry that can be chosen.
pub fn layout<'a>(&mut self, bar: &'a MenuBar) -> Vec<Menu<'a>> {
let app = [APP.to_string()];
let mut rows = vec![Row::System(System::About), Row::Separator];
rows.extend(self.rows(&bar.app, &app));
rows.extend([Row::Separator, Row::System(System::Services), Row::Separator]);
rows.extend([Row::System(System::Hide), Row::System(System::HideOthers), Row::System(System::ShowAll), Row::Separator]);
rows.extend(self.rows(std::slice::from_ref(&bar.quit), &app));
let mut menus = vec![Menu { id: APP, label: &bar.title, enabled: true, rows }];
for menu in &bar.menus {
let within = [menu.id.clone()];
let mut rows = self.rows(&menu.children, &within);
if menu.id == WINDOW {
rows.extend([Row::Separator, Row::System(System::Minimize), Row::System(System::Zoom), Row::System(System::FullScreen)]);
rows.extend([Row::Separator, Row::System(System::BringAllToFront)]);
}
menus.push(Menu { id: &menu.id, label: &menu.label, enabled: menu.enabled, rows });
}
menus
}
/// Replaces what the submenu with id `id` holds (a recent-files list): forgets its old
/// entries and submenus and lays out `children` in their place. For submenus inside the
/// bar's menus only: a menu of the bar itself (the app menu, File, Window, ...) keeps the
/// system's entries the layout gave it, so it is built once and `replace` refuses it.
pub fn replace<'a>(&mut self, id: &str, children: &'a [Item]) -> Result<Vec<Row<'a>>, String> {
let Some((_, outer)) = self.submenus.iter().find(|(sid, _)| sid == id) else {
return Err(format!("no submenu `{id}` in the menu bar"));
};
let mut within = outer.clone();
within.push(id.to_string());
let inside = |w: &[String]| w.iter().any(|m| m == id);
self.entries.retain(|e| !inside(&e.within));
self.submenus.retain(|(_, w)| !inside(w));
Ok(self.rows(children, &within))
}
fn rows<'a>(&mut self, items: &'a [Item], within: &[String]) -> Vec<Row<'a>> {
items
.iter()
.map(|item| {
if item.is_sep() {
Row::Separator
} else if item.is_heading() {
Row::Heading(&item.label)
} else if !item.children.is_empty() {
self.submenus.push((item.id.clone(), within.to_vec()));
let mut inner = within.to_vec();
inner.push(item.id.clone());
Row::Submenu { item, rows: self.rows(&item.children, &inner) }
} else {
self.next += 1;
let key = format!("menu-{}", self.next);
self.entries.push(Entry { key: key.clone(), id: item.id.clone(), params: item.params.clone(), within: within.to_vec() });
Row::Entry { key, item }
}
})
.collect()
}
/// The id and parameters of the entry whose native item has `key`.
pub fn find(&self, key: &str) -> Option<(&str, Option<&Value>)> {
self.entries.iter().find(|e| e.key == key).map(|e| (e.id.as_str(), e.params.as_ref()))
}
/// Every entry: (key, id, parameters).
pub fn entries(&self) -> impl Iterator<Item = (&str, &str, Option<&Value>)> {
self.entries.iter().map(|e| (e.key.as_str(), e.id.as_str(), e.params.as_ref()))
}
/// The menus the submenu with id `id` sits in, outermost first; `None` for one the bar
/// doesn't have.
pub fn within(&self, id: &str) -> Option<&[String]> {
self.submenus.iter().find(|(sid, _)| sid == id).map(|(_, w)| w.as_slice())
}
}
#[cfg(target_os = "macos")]
pub use native::NativeMenu;
#[cfg(target_os = "macos")]
mod native {
use super::{MenuBar, MenuTable, Row, System};
use crate::ui_kit::Item;
use muda::{AboutMetadata, IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu, accelerator::Accelerator};
use serde_json::Value;
use std::sync::mpsc::{Receiver, sync_channel};
/// The app's menu bar while it lives; dropping it takes the bar down.
pub struct NativeMenu {
menu: Menu,
rows: Rows,
events: Receiver<MenuEvent>,
}
/// The native rows of a laid-out bar.
struct Rows {
table: MenuTable,
title: String,
version: String,
items: Vec<(String, MenuItem)>,
/// Every submenu by its id.
submenus: Vec<(String, Submenu)>,
}
fn err(e: muda::Error) -> String {
e.to_string()
}
impl NativeMenu {
/// Builds `bar` and installs it as the app's menu bar. A chosen entry repaints `ctx`; read
/// it with [`Self::events`]. On the main thread only, as every AppKit menu.
pub fn new(bar: &MenuBar, ctx: &egui::Context) -> Result<Self, String> {
// Rows first: the bar is only installed, and taken down on drop, once it is whole.
let mut table = MenuTable::default();
let menus = table.layout(bar);
let mut rows = Rows { table, title: bar.title.clone(), version: bar.version.clone(), items: Vec::new(), submenus: Vec::new() };
let menu = Menu::new();
for m in menus {
let sub = Submenu::new(m.label, m.enabled);
rows.append(&sub, m.rows)?;
menu.append(&sub).map_err(err)?;
}
let (sender, events) = sync_channel(64);
let ctx = ctx.clone();
MenuEvent::set_event_handler(Some(move |event| {
let _ = sender.try_send(event);
ctx.request_repaint();
}));
menu.init_for_nsapp();
Ok(Self { menu, rows, events })
}
/// The entries chosen since the last call: (id, parameters).
pub fn events(&mut self) -> Vec<(String, Option<Value>)> {
let events: Vec<_> = self.events.try_iter().take(64).collect();
events.iter().filter_map(|e| self.rows.table.find(e.id.as_ref())).map(|(id, params)| (id.to_string(), params.cloned())).collect()
}
/// Turns every entry on or off: `enabled(id, parameters)`.
pub fn update(&self, enabled: impl Fn(&str, Option<&Value>) -> bool) {
for (key, id, params) in self.rows.table.entries() {
if let Some((_, item)) = self.rows.items.iter().find(|(k, _)| k == key) {
let on = enabled(id, params);
if item.is_enabled() != on {
item.set_enabled(on);
}
}
}
}
/// Replaces the entries of the submenu with id `id` (a recent-files list).
pub fn replace(&mut self, id: &str, children: &[Item]) -> Result<(), String> {
let sub = self
.rows
.submenus
.iter()
.find(|(sid, _)| sid == id)
.map(|(_, s)| s.clone())
.ok_or_else(|| format!("no submenu `{id}` in the menu bar"))?;
let mut table = std::mem::take(&mut self.rows.table);
let laid = table.replace(id, children);
// What the table forgot goes from the native rows too.
self.rows.items.retain(|(k, _)| table.find(k).is_some());
self.rows.submenus.retain(|(sid, _)| sid == id || table.within(sid).is_some());
self.rows.table = table;
let laid = laid?;
while sub.remove_at(0).is_some() {}
self.rows.append(&sub, laid)
}
}
impl Rows {
fn system(&self, s: System) -> PredefinedMenuItem {
let title = self.title.as_str();
match s {
System::About => PredefinedMenuItem::about(
Some(&format!("About {title}")),
Some(AboutMetadata { name: Some(title.into()), version: Some(self.version.clone()), ..Default::default() }),
),
System::Services => PredefinedMenuItem::services(None),
System::Hide => PredefinedMenuItem::hide(Some(&format!("Hide {title}"))),
System::HideOthers => PredefinedMenuItem::hide_others(None),
System::ShowAll => PredefinedMenuItem::show_all(None),
System::Minimize => PredefinedMenuItem::minimize(None),
System::Zoom => PredefinedMenuItem::maximize(None),
System::FullScreen => PredefinedMenuItem::fullscreen(None),
System::BringAllToFront => PredefinedMenuItem::bring_all_to_front(None),
}
}
fn append(&mut self, parent: &Submenu, rows: Vec<Row<'_>>) -> Result<(), String> {
for row in rows {
let native: Box<dyn IsMenuItem> = match row {
Row::Separator => Box::new(PredefinedMenuItem::separator()),
Row::Heading(label) => Box::new(MenuItem::new(label, false, None)),
Row::System(s) => Box::new(self.system(s)),
Row::Entry { key, item } => {
let shortcut = match item.shortcut.trim() {
"" => None,
s => Some(s.parse::<Accelerator>().map_err(|e| format!("menu entry `{}`: shortcut `{s}`: {e}", item.id))?),
};
let native = MenuItem::with_id(key.as_str(), &item.label, item.enabled, shortcut);
self.items.push((key, native.clone()));
Box::new(native)
}
Row::Submenu { item, rows } => {
let sub = Submenu::new(&item.label, item.enabled);
self.append(&sub, rows)?;
self.submenus.push((item.id.clone(), sub.clone()));
Box::new(sub)
}
};
parent.append(native.as_ref()).map_err(err)?;
}
Ok(())
}
}
impl Drop for NativeMenu {
fn drop(&mut self) {
MenuEvent::set_event_handler(None::<fn(MenuEvent)>);
self.menu.remove_for_nsapp();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn entry(id: &str, label: &str) -> Item {
Item::action(id, label, "")
}
fn submenu(id: &str, label: &str, children: Vec<Item>) -> Item {
let mut menu = entry(id, label);
menu.children = children;
menu
}
/// The rows as words: the entry's id, `-`, `#heading`, `[submenu: ...]` or the system entry.
fn shape(rows: &[Row<'_>]) -> Vec<String> {
rows.iter()
.map(|r| match r {
Row::Separator => "-".to_string(),
Row::Heading(h) => format!("#{h}"),
Row::Entry { item, .. } => item.id.clone(),
Row::Submenu { item, rows } => format!("[{}: {}]", item.id, shape(rows).join(" ")),
Row::System(s) => format!("{s:?}"),
})
.collect()
}
fn keys<'a>(rows: &'a [Row<'a>], out: &mut Vec<&'a str>) {
for r in rows {
match r {
Row::Entry { key, .. } => out.push(key),
Row::Submenu { rows, .. } => keys(rows, out),
_ => {}
}
}
}
fn bar() -> MenuBar {
let recent = vec![
entry("ui.file.open_recent", "/a.solidcraft").with(json!({"path": "/a.solidcraft"})),
Item::sep(),
entry("ui.file.clear_recent", "Clear"),
];
MenuBar {
title: "Craft".into(),
version: "1.0".into(),
app: vec![entry("ui.prefs", "Settings…").with(json!({"open": "General"})).key("Cmd+,")],
quit: entry("app.quit", "Quit Craft").key("Cmd+Q"),
menus: vec![
submenu(
"menu.file",
"File",
vec![entry("file.new", "New").key("Cmd+N"), submenu("menu.file.recent", "Open Recent", recent), Item::sep()],
),
submenu(WINDOW, "Window", vec![entry("ui.agent_chat", "Agent Chat")]).on_if(false),
],
}
}
#[test]
fn the_bar_follows_the_system_layout() {
let bar = bar();
let mut table = MenuTable::default();
let menus = table.layout(&bar);
let titles: Vec<_> = menus.iter().map(|m| (m.id, m.label, m.enabled)).collect();
assert_eq!(titles, [(APP, "Craft", true), ("menu.file", "File", true), (WINDOW, "Window", false)], "a menu that is off stays off");
assert_eq!(shape(&menus[0].rows), ["About", "-", "ui.prefs", "-", "Services", "-", "Hide", "HideOthers", "ShowAll", "-", "app.quit"]);
assert_eq!(shape(&menus[1].rows), ["file.new", "[menu.file.recent: ui.file.open_recent - ui.file.clear_recent]", "-"]);
assert_eq!(shape(&menus[2].rows), ["ui.agent_chat", "-", "Minimize", "Zoom", "FullScreen", "-", "BringAllToFront"]);
// Menu ids are never entries.
assert!(table.entries().all(|(_, id, _)| !id.starts_with("menu.")));
assert_eq!(table.entries().count(), 6);
}
#[test]
fn a_chosen_entry_maps_back_to_its_id_and_params_and_replace_forgets_the_old_ones() {
let bar = bar();
let mut table = MenuTable::default();
let menus = table.layout(&bar);
let mut file = vec![];
keys(&menus[1].rows, &mut file);
let file: Vec<String> = file.into_iter().map(str::to_string).collect();
assert_eq!(table.find(&file[0]), Some(("file.new", None)));
assert_eq!(table.find(&file[1]), Some(("ui.file.open_recent", Some(&json!({"path": "/a.solidcraft"})))));
assert_eq!(table.find(&file[2]), Some(("ui.file.clear_recent", None)));
assert_eq!(table.find("menu-999"), None);
assert_eq!(table.within("menu.file.recent"), Some(&["menu.file".to_string()][..]));
let fresh = vec![entry("ui.file.open_recent", "/b.solidcraft").with(json!({"path": "/b.solidcraft"}))];
let rows = table.replace("menu.file.recent", &fresh).unwrap_or_default();
let mut new = vec![];
keys(&rows, &mut new);
assert_eq!(new.len(), 1);
assert_eq!(table.find(new[0]), Some(("ui.file.open_recent", Some(&json!({"path": "/b.solidcraft"})))));
assert_eq!((table.find(&file[1]), table.find(&file[2])), (None, None), "the old recent entries are gone");
assert_eq!(table.find(&file[0]), Some(("file.new", None)), "the rest of File stays");
assert_eq!(table.entries().count(), 5);
assert!(table.replace("menu.nothing", &fresh).is_err());
for menu in [APP, "menu.file", WINDOW] {
assert!(table.replace(menu, &fresh).is_err(), "{menu} is a menu of the bar, built once");
}
assert_eq!(table.entries().count(), 5, "a refused replace forgets nothing");
}
}
+2 -2
View File
@@ -836,7 +836,7 @@ no box selection.
| `ui.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive |
| `ui.home` | `open?`: bool; `sample?`: index | the start page: show or hide it, open a built-in sample (once built); returns `open` and the recent designs |
| `ui.shortcut` | `command`, `key?`, `slot?`, `replace?` | a command's keys (`key`: the first, shown in menus; `keys`: all), or rebinds it: `key` becomes its only key, or with `slot` replaces its key there (one past the last adds a key, `""` there removes that key); `""` without `slot` clears them; key names are case-insensitive and modifiers are normalized (`shift+ctrl+t` becomes `Ctrl+Shift+T`); unknown and reserved keys are refused; a key another command uses is refused unless `replace` |
| `ui.help` | `item?`: `about`, `updates` (checks for updates and shows the result), `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window) | the version, commit, build date and diagnostic text |
| `ui.help` | `item?`: `about`, `updates` (checks for updates and shows the result), `shortcuts` (read only), `report` (opens a report draft for review), `crashes` (the Crash Reports window), `docs` (the documentation in the browser), `messages` (the Messages window), `install_cli` (macOS: links `solidcraft-cli` into `/usr/local/bin`); another item is an error | the version, commit, build date and diagnostic text |
| `ui.prefs` | any of the [`ui.prefs` settings](#uiprefs-settings) below; also `open`, `button` and `stand_up` (listed there) | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `apply_enabled` (whether the dialog's Apply has a change to keep), `dark`, `viewport_dark` (whether the 3D view is dark), `stand_up_offer` (the offered bodies, or null), `stand_up_note` (whether that offer is the note), `theme_followed`, `theme_follow_error`, `theme_option_defs`, `theme_option_values` and `theme_option_notices` |
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
@@ -845,7 +845,7 @@ no box selection.
| `engine.execute` → `browser.expand_all` | `path?`: occurrence ids; `component?`: id; omit both for the whole tree | Open every Browser row under that scope, including folders that start closed (Origin, Named Views, Features). Display only: not saved in the design, not an undo step. |
| `engine.execute` → `browser.collapse_all` | same | Close those rows. The design row stays open when the whole tree is collapsed. |
| `engine.execute` → `view.history_layout` | `width?`, `dock?`: `bottom`, `left`, `under_browser`, `collapsed?` | Resize/dock the history without opening a dialog or changing the design. Contents respond to measured width; browser-stacked history shares its width and splits the height at the remembered share (`browser_share`, half until the seam between the two cards is dragged). One folded header gives the expanded card the rest; two folded headers stay at the top. |
| `ui.agent_chat` | `features?`, `toolbar_button?`, `connect_at_start?` and `open_at_start?` (connect the agent started last, and open the panel, when SolidCraft starts; both off by default, never with `--control`), `instructions?` (the custom instructions every new chat's system note carries, Preferences → AI; at most 4000 characters are kept), `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp`, `api` or `anthropic` (`api` is OpenAI-compatible, `anthropic` Anthropic-compatible), `command` or `base_url` + `model` + `request_timeout_s?` (seconds one model request may take, 300 by default, rounded and clamped to 30 to 3600), `allow_insecure_http?` (OpenAI-compatible endpoints only; false by default; explicitly permits sending the key over unencrypted HTTP to this endpoint, without opening a confirmation dialog)}; no keys), `add_dialog?` (`installed`, `custom`, `api`, `anthropic`, or `false` to close), `edit_dialog?` (the id of an agent you added: the same dialog filled with what it holds, as its **Edit** button opens it; the key is never shown), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `provider?` (which provider's group the model picker opens first, when the models come from several), `model?` (one of its models, or see below), `open?`, `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `attach?`: a file's path (a picture, a text file or a CAD file, for the next message; CAD files are imported at once), `remove_file?` (index of a file on the next message), `send?`: text (files alone make a message too), `attach_view?`, `attach_selection?`, `pick?` (true: open the context picker), `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited, failed: the agent never got ready, and Connect reads Retry), `connectAtStart`, `openAtStart`, `instructions`, `lastAgent` (the agent started last), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `modelWarning` (the model capability warning text, or `null` when unknown or tool support is reported; checked asynchronously and never blocks sending), `turnWarning` (the latest completed turn's no-tool notice, otherwise `null`; cleared on the next turn or new chat), `providers` (`name` and `count` of each, empty with one provider), `provider` (the one listed), `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages, with their `files`), `attachments` (the files on the next message, by name), `loading` (pictures still being read for it, by name), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
| `ui.agent_chat` | `features?`, `toolbar_button?`, `connect_at_start?` and `open_at_start?` (connect the agent started last, and open the panel, when SolidCraft starts; both off by default, never with `--control`), `instructions?` (the custom instructions every new chat's system note carries, Preferences → AI; at most 4000 characters are kept), `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp`, `api` or `anthropic` (`api` is OpenAI-compatible, `anthropic` Anthropic-compatible), `command` or `base_url` + `model` + `request_timeout_s?` (seconds one model request may take, 300 by default, rounded and clamped to 30 to 3600), `allow_insecure_http?` (OpenAI-compatible endpoints only; false by default; explicitly permits sending the key over unencrypted HTTP to this endpoint, without opening a confirmation dialog)}; no keys), `add_dialog?` (`installed`, `custom`, `api`, `anthropic`, or `false` to close), `edit_dialog?` (the id of an agent you added: the same dialog filled with what it holds, as its **Edit** button opens it; the key is never shown), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `provider?` (which provider's group the model picker opens first, when the models come from several), `model?` (one of its models, or see below), `open?`, `toggle?` (true: show or hide the panel as its button does, unfolding a folded one), `side?` (`left`, `right`), `minimized?`, `popped?` (in its own window), `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `attach?`: a file's path (a picture, a text file or a CAD file, for the next message; CAD files are imported at once), `remove_file?` (index of a file on the next message), `send?`: text (files alone make a message too), `attach_view?`, `attach_selection?`, `pick?` (true: open the context picker), `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited, failed: the agent never got ready, and Connect reads Retry), `connectAtStart`, `openAtStart`, `instructions`, `lastAgent` (the agent started last), `popped`, `window` (its place and size), `agent`, `modes`, `mode`, `models`, `model`, `modelWarning` (the model capability warning text, or `null` when unknown or tool support is reported; checked asynchronously and never blocks sending), `turnWarning` (the latest completed turn's no-tool notice, otherwise `null`; cleared on the next turn or new chat), `providers` (`name` and `count` of each, empty with one provider), `provider` (the one listed), `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages, with their `files`), `attachments` (the files on the next message, by name), `loading` (pictures still being read for it, by name), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
| `ui.resize` | `width`, `height` | |
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |