mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-08 21:11:56 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7321a54a24 | |||
| 2724151d04 | |||
| d83cc2e48b | |||
| 4b6efddb3c | |||
| de146e349b | |||
| d83d6b3c1e | |||
| 50c78b30dd | |||
| 26db4c7777 | |||
| 363ec793dc | |||
| bd4f6e857d | |||
| 31c6f39e98 | |||
| 731749224d | |||
| a54ff93c59 | |||
| d2b9daeba8 | |||
| 223ccd0004 | |||
| 331218f23b | |||
| c361491d57 | |||
| 24ba6f29ca | |||
| 3edfed7219 | |||
| 0383f87e57 | |||
| bb91986df3 | |||
| af73d7bd90 | |||
| 7d86e00ca7 | |||
| e894f4a12f | |||
| 7b9e66929b | |||
| bd3a94e946 | |||
| b0d7219115 | |||
| a7598a92c6 | |||
| 5c4932ba42 | |||
| ee72a98948 | |||
| 2fba25f564 | |||
| 7b90cdf3fb | |||
| 8b7586ba87 | |||
| b23d1ada5d | |||
| 9a0d9ce243 | |||
| be226abee7 | |||
| 414b8bb139 |
Generated
+1
@@ -4405,6 +4405,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"muda",
|
||||
"nix 0.31.3",
|
||||
"pollster 0.4.0",
|
||||
"proc-macro2",
|
||||
|
||||
@@ -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
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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)") };
|
||||
|
||||
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user