Merge pull request #969 from rafi612/system-title-bar
Compact app bar: 32 pt bar, PhotoCraft search icon, no app menu + System title bar option
This commit is contained in:
commit
58bb72ffc6
File diff suppressed because one or more lines are too long
|
|
@ -400,6 +400,32 @@ fn adapter_summary(info: &eframe::wgpu::AdapterInfo) -> String {
|
|||
/// macOS keeps its traffic lights over the integrated title strip.
|
||||
const CUSTOM_TITLEBAR: bool = !cfg!(target_os = "macos");
|
||||
|
||||
/// Whether this start draws its own title bar: Windows and Linux do, unless Preferences ›
|
||||
/// User Interface › System Title Bar asks for the system's. Read from the saved preferences
|
||||
/// before the window opens; a missing or unreadable value keeps the default.
|
||||
fn custom_titlebar(saved: Option<&vectorcraft_ui_egui::UiState>) -> bool {
|
||||
CUSTOM_TITLEBAR && !saved.map(|ui| ui.engine_prefs.get("systemTitleBar").and_then(serde_json::Value::as_bool).unwrap_or(false)).unwrap_or(false)
|
||||
}
|
||||
|
||||
fn native_options(custom_titlebar: bool) -> eframe::NativeOptions {
|
||||
eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!custom_titlebar)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_icon(app_icon())
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
#[cfg(feature = "windows7")]
|
||||
renderer: eframe::Renderer::Glow,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> std::process::ExitCode {
|
||||
// First, so every start-up warning is recorded (`logging`).
|
||||
let logger = logging::install();
|
||||
|
|
@ -486,22 +512,8 @@ fn main() -> std::process::ExitCode {
|
|||
}
|
||||
#[cfg(feature = "wgpu")]
|
||||
let startup = std::sync::Arc::new(gpu::Startup::default());
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("VectorCraft")
|
||||
.with_inner_size(window::DEFAULT_SIZE)
|
||||
.with_min_inner_size(window::MIN_SIZE)
|
||||
.with_drag_and_drop(true)
|
||||
.with_decorations(!CUSTOM_TITLEBAR)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_icon(app_icon())
|
||||
.with_app_id("ai.storyteller.vectorcraft"),
|
||||
#[cfg(feature = "windows7")]
|
||||
renderer: eframe::Renderer::Glow,
|
||||
..Default::default()
|
||||
};
|
||||
let custom_titlebar = custom_titlebar(saved.as_ref());
|
||||
let options = native_options(custom_titlebar);
|
||||
#[cfg(feature = "wgpu")]
|
||||
let options = {
|
||||
let mut options = options;
|
||||
|
|
@ -575,7 +587,7 @@ fn main() -> std::process::ExitCode {
|
|||
{
|
||||
app.graphics_adapter = Some("OpenGL (Windows 7 compatibility)".into());
|
||||
}
|
||||
app.custom_titlebar = CUSTOM_TITLEBAR;
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx).with_automation_roots(roots.clone());
|
||||
|
|
@ -618,6 +630,25 @@ fn main() -> std::process::ExitCode {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ui_state_with_prefs(engine_prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState {
|
||||
vectorcraft_ui_egui::UiState { engine_prefs, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_system_title_bar_preference_keeps_the_window_decorations() {
|
||||
// Preferences › User Interface › System Title Bar gives the window back its system
|
||||
// decorations on Windows and Linux; macOS always has them.
|
||||
assert_eq!(super::custom_titlebar(None), super::CUSTOM_TITLEBAR, "no preferences: the default");
|
||||
let off = ui_state_with_prefs(serde_json::json!({"systemTitleBar": false}));
|
||||
assert_eq!(super::custom_titlebar(Some(&off)), super::CUSTOM_TITLEBAR);
|
||||
let on = ui_state_with_prefs(serde_json::json!({"systemTitleBar": true}));
|
||||
assert!(!super::custom_titlebar(Some(&on)));
|
||||
assert_eq!(super::native_options(false).viewport.decorations, Some(true));
|
||||
if super::CUSTOM_TITLEBAR {
|
||||
assert_eq!(super::native_options(true).viewport.decorations, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// The file extensions the macOS bundle declares: its document types and its own exported type.
|
||||
fn plist_extensions(plist: &str) -> Vec<&str> {
|
||||
["<key>CFBundleTypeExtensions</key>", "<key>public.filename-extension</key>"]
|
||||
|
|
|
|||
|
|
@ -278,6 +278,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
|
|||
p!("toolGroupLabels", "User Interface", "", "Show Tool Group Labels", bool),
|
||||
p!("openDocumentsAsTabs", "User Interface", "", "Open Documents As Tabs", bool),
|
||||
p!("largeTabs", "User Interface", "", "Large Tabs", bool),
|
||||
p!("systemTitleBar", "User Interface", "", "System Title Bar", bool),
|
||||
p!("uiScaling", "User Interface", "UI Scaling", "Scale", num(0.75, 2.0, "×")),
|
||||
p!("scaleCursorWithUi", "User Interface", "UI Scaling", "Scale Cursor Proportional to UI", bool),
|
||||
// `auto` or a language code the shell registers (`zh-hant`); the shell shows it as a dropdown.
|
||||
|
|
|
|||
|
|
@ -512,6 +512,10 @@ pub struct Prefs {
|
|||
/// UI language: `auto` (follow the system locale) or a language code such as `en`, `zh-hant`.
|
||||
/// The list of languages belongs to the shell (`ui-egui` i18n); an unknown code reads as `auto`.
|
||||
pub interface_language: String,
|
||||
/// Windows and Linux: use the system's title bar and window buttons instead of the app bar
|
||||
/// acting as the title bar (tiling window managers, desktops that draw their own decorations).
|
||||
/// Read when the app starts. macOS always uses the system's.
|
||||
pub system_title_bar: bool,
|
||||
// Performance
|
||||
pub gpu_performance: bool,
|
||||
pub animated_zoom: bool,
|
||||
|
|
@ -726,6 +730,7 @@ impl Default for Prefs {
|
|||
ui_scaling: 1.0,
|
||||
scale_cursor_with_ui: false,
|
||||
interface_language: s("auto"),
|
||||
system_title_bar: false,
|
||||
gpu_performance: true,
|
||||
animated_zoom: true,
|
||||
gpu_preference: s("automatic"),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
//! The VectorCraft brand mark: the app icon (the engraved dragon, `assets/app-icon/`, see its
|
||||
//! README). The PNG carries the icon's rounded corners; it is decoded once per context into a
|
||||
//! mipmapped texture, so it stays crisp from the 22 pt app-bar mark to the About box.
|
||||
//! mipmapped texture, so it stays crisp from the 18 pt app-bar mark to the About box.
|
||||
|
||||
use egui::{Color32, Context, Id, Rect, TextureHandle, TextureOptions, Ui, pos2};
|
||||
|
||||
|
|
@ -36,6 +36,12 @@ pub fn paint_mark(ui: &Ui, r: Rect) {
|
|||
ui.painter().image(texture(ui.ctx()).id(), r, uv, Color32::WHITE);
|
||||
}
|
||||
|
||||
/// The mark's texture id (tests: find the painted marks among the shapes).
|
||||
#[cfg(test)]
|
||||
pub fn texture_id(ctx: &Context) -> egui::TextureId {
|
||||
texture(ctx).id()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -55,6 +61,9 @@ mod tests {
|
|||
let ctx = Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
|
||||
// The system title bar hides the in-app mark (the OS draws its own icon), so paint the
|
||||
// app-drawn bar here.
|
||||
app.custom_titlebar = true;
|
||||
let mut frame = || {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::chrome::app_bar(&mut app, ui));
|
||||
let uploads = out.textures_delta.set.values().flat_map(|d| d.iter()).filter(|d| d.image.size() == [128, 128]).count();
|
||||
|
|
@ -72,7 +81,7 @@ mod tests {
|
|||
let (uploads, marks) = frame();
|
||||
assert_eq!(uploads, 1);
|
||||
assert_eq!(marks.len(), 1);
|
||||
assert_eq!(marks[0].size(), egui::vec2(22.0, 22.0));
|
||||
assert_eq!(marks[0].size(), egui::vec2(18.0, 18.0));
|
||||
// Later frames reuse the texture.
|
||||
let (uploads, marks) = frame();
|
||||
assert_eq!((uploads, marks.len()), (0, 1));
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use egui::{CornerRadius, Sense, Stroke, StrokeKind, Ui, vec2};
|
||||
use egui::{CornerRadius, Sense, Stroke, Ui, vec2};
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
|
|
@ -12,15 +12,22 @@ use crate::theme::{self, Tokens};
|
|||
use crate::widgets;
|
||||
use crate::{VectorcraftApp, icons, menus, titlebar};
|
||||
|
||||
/// The application bar's height, in points.
|
||||
pub const APP_BAR_HEIGHT: f32 = 44.0;
|
||||
/// The application bar's height, in points (compact, as in PhotoCraft).
|
||||
pub const APP_BAR_HEIGHT: f32 = 32.0;
|
||||
/// The brand mark's side.
|
||||
const MARK: f32 = 18.0;
|
||||
/// Height of the workspace switcher.
|
||||
const WIDGET_H: f32 = 22.0;
|
||||
|
||||
/// The application bar: brand mark, Home, menus, then Discord, search and the workspace switcher
|
||||
/// at the right. With [`VectorcraftApp::custom_titlebar`] it is also the window's title bar
|
||||
/// ([`titlebar`]): the caption buttons take the right end and the rest of the bar drags the window.
|
||||
/// With the system title bar (Windows and Linux, `system_title_bar`) the OS draws its own icon and
|
||||
/// title, so the in-app bar shows Home, menus and workspace controls only: no brand mark.
|
||||
pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let custom = app.custom_titlebar;
|
||||
let system = system_title_bar(app);
|
||||
let left = app.titlebar_inset.clamp(8.0, f32::from(i8::MAX)) as i8;
|
||||
let frame = egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin { left, right: if custom { 0 } else { 14 }, top: 0, bottom: 0 });
|
||||
let bar = egui::Panel::top("app_bar").exact_size(APP_BAR_HEIGHT).frame(frame.stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
|
||||
|
|
@ -28,12 +35,14 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
titlebar::drag_area(ui, ui.max_rect());
|
||||
}
|
||||
ui.horizontal_centered(|ui| {
|
||||
// Brand mark: the app icon.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover());
|
||||
crate::brand::paint_mark(ui, r);
|
||||
ui.add_space(4.0);
|
||||
// With the system title bar the OS draws its own icon: no in-app brand mark.
|
||||
if !system {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(MARK, MARK), Sense::hover());
|
||||
crate::brand::paint_mark(ui, r);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
let on_home = menus::home_showing(app);
|
||||
if widgets::icon_button(ui, "house", tl!("Home"), on_home, 24.0).clicked() {
|
||||
if widgets::icon_button(ui, "house", tl!("Home"), on_home, 20.0).clicked() {
|
||||
app.run("app.home", json!({})).ok();
|
||||
}
|
||||
ui.add_space(2.0);
|
||||
|
|
@ -46,8 +55,8 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
menus::menu_bar(app, ui)
|
||||
};
|
||||
// The right-side group fills the space after the menus from the right; when it runs
|
||||
// short, Discord goes first (it is also under Help), then the search box becomes an
|
||||
// icon, then the workspace switcher narrows.
|
||||
// short, Discord goes first (it is also under Help), then the search icon (the palette
|
||||
// stays under its shortcut), then the workspace switcher narrows.
|
||||
let full = ui.max_rect();
|
||||
let right_edge = if custom { full.right() - titlebar::WIDTH - 10.0 } else { full.right() };
|
||||
let right = egui::Rect::from_min_max(egui::pos2(menus_end + 8.0, full.top()), egui::pos2(right_edge, full.bottom()));
|
||||
|
|
@ -58,45 +67,31 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
let ws = ui.painter().layout_no_wrap(ws_name, egui::FontId::proportional(12.0), t.text);
|
||||
let ws_w = (ws.size().x + 36.0).clamp(112.0, 190.0);
|
||||
let gap = ui.spacing().item_spacing.x;
|
||||
let with_search = ws_w + 8.0 + gap + 200.0;
|
||||
let search_full = room >= with_search;
|
||||
let discord = room >= with_search + 10.0 + gap + crate::community::discord_width(ui, false);
|
||||
let ws_w = if search_full { ws_w } else { ws_w.min(room - 8.0 - gap - 24.0).max(64.0) };
|
||||
// The search icon, as in PhotoCraft: shown while a minimal switcher fits beside it.
|
||||
let icon = 28.0;
|
||||
let search = room >= 64.0 + 8.0 + gap + icon;
|
||||
let ws_w = ws_w.min(room - 8.0 - gap - if search { icon } else { 0.0 }).max(64.0);
|
||||
let discord = search && room >= ws_w + 8.0 + gap + icon + 10.0 + gap + crate::community::discord_width(ui, false);
|
||||
let mut rui = ui.new_child(egui::UiBuilder::new().max_rect(right).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
let ui = &mut rui;
|
||||
// Workspace switcher: shows the current workspace, opens Window → Workspace.
|
||||
let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, 24.0), Sense::click());
|
||||
let (wr, wresp) = ui.allocate_exact_size(vec2(ws_w, WIDGET_H), Sense::click());
|
||||
ui.painter().rect_filled(wr, CornerRadius::same(4), if wresp.hovered() { t.hover } else { t.panel });
|
||||
ui.painter().with_clip_rect(wr.shrink2(vec2(4.0, 0.0))).galley(wr.left_center() + vec2(10.0, -ws.size().y / 2.0), ws, t.text);
|
||||
icons::paint(ui, "chevron-down", egui::Rect::from_center_size(wr.right_center() - vec2(12.0, 0.0), vec2(12.0, 12.0)), t.text_dim);
|
||||
let wresp = wresp.on_hover_text(tl!("Switch workspace"));
|
||||
egui::Popup::menu(&wresp).show(|ui| crate::workspaces::popup(app, ui));
|
||||
ui.add_space(8.0);
|
||||
// Search box → command palette.
|
||||
let open_palette = if search_full {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), Sense::click());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(12), t.input);
|
||||
ui.painter().rect_stroke(
|
||||
r,
|
||||
CornerRadius::same(12),
|
||||
Stroke::new(1.0, if resp.hovered() { t.input_border } else { t.divider }),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
icons::paint(ui, "search", egui::Rect::from_center_size(r.left_center() + vec2(14.0, 0.0), vec2(13.0, 13.0)), t.text_dim);
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(26.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
tl!("Search commands and tools"),
|
||||
egui::FontId::proportional(11.5),
|
||||
t.text_dim,
|
||||
);
|
||||
resp.clicked()
|
||||
} else {
|
||||
widgets::icon_button(ui, "search", tl!("Search commands and tools"), false, 24.0).clicked()
|
||||
};
|
||||
if open_palette {
|
||||
app.ui.palette_open = true;
|
||||
app.ui.palette_query.clear();
|
||||
// Search → command palette (its shortcut opens it too).
|
||||
if search {
|
||||
let tip = match menus::shortcut_of("help.commandPalette") {
|
||||
Some(sc) => format!("{} ({})", tl!("Search commands and tools"), menus::pretty_shortcut(sc)),
|
||||
None => tl!("Search commands and tools").to_string(),
|
||||
};
|
||||
if widgets::icon_button(ui, "search", &tip, app.ui.palette_open, icon).clicked() {
|
||||
app.ui.palette_open = !app.ui.palette_open;
|
||||
app.ui.palette_query.clear();
|
||||
}
|
||||
}
|
||||
if discord {
|
||||
ui.add_space(10.0);
|
||||
|
|
@ -109,6 +104,38 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether the window shows the system's title bar instead of the app drawing its own: Windows
|
||||
/// and Linux with Preferences › User Interface › System Title Bar. macOS always has system
|
||||
/// decorations, but keeps the in-app brand mark, so it never counts as system mode here; nor
|
||||
/// does the web build (the browser tab has no document title), nor an embedder that never
|
||||
/// turned the preference on.
|
||||
pub fn system_title_bar(app: &VectorcraftApp) -> bool {
|
||||
!app.custom_titlebar && app.session.prefs.system_title_bar && !cfg!(any(target_os = "macos", target_arch = "wasm32"))
|
||||
}
|
||||
|
||||
/// The OS window title: the active document's name suffixed with the app name (a `*` prefix
|
||||
/// marks unsaved changes, like the `*` in the document tabs), or just the app name with no
|
||||
/// document open.
|
||||
pub fn window_title(app: &VectorcraftApp) -> String {
|
||||
app.session
|
||||
.active()
|
||||
.map(|d| format!("{}{} \u{2014} VectorCraft", if d.is_dirty() { "*" } else { "" }, d.title()))
|
||||
.unwrap_or_else(|| "VectorCraft".into())
|
||||
}
|
||||
|
||||
/// Keep the OS window title (and the taskbar / Alt-Tab entry) on the active file. Sends
|
||||
/// `ViewportCommand::Title` only when the title changed since the last frame.
|
||||
pub fn sync_window_title(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
let want = window_title(app);
|
||||
if app.last_window_title != want {
|
||||
app.last_window_title = want.clone();
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(want));
|
||||
// One more frame: a new title makes AppKit lay its title bar out again, putting the window
|
||||
// buttons back where it keeps them until the next frame centres them on the bar (#968).
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// An anchor button: icon, tooltip, command and the convert command's `to`.
|
||||
type AnchorButton<'a> = (&'a str, &'a str, &'a str, Option<&'a str>);
|
||||
|
||||
|
|
@ -965,4 +992,81 @@ mod tests {
|
|||
let off = hint(&mut app);
|
||||
assert!(off.contains(" to zoom into an area") && !off.contains("Hold"), "{off}");
|
||||
}
|
||||
|
||||
fn title_test_app(custom_titlebar: bool) -> crate::VectorcraftApp {
|
||||
let mut app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.custom_titlebar = custom_titlebar;
|
||||
// The preference that makes the shell launch without its own title bar.
|
||||
app.run("prefs.set", json!({"key": "systemTitleBar", "value": !custom_titlebar})).unwrap();
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_the_preference_the_mark_stays() {
|
||||
// The default (e.g. the web build): no custom bar, no preference.
|
||||
let app = crate::VectorcraftApp::new(Session::new(), Default::default());
|
||||
assert!(!super::system_title_bar(&app));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_title_with_no_document_is_the_app_name() {
|
||||
assert_eq!(super::window_title(&title_test_app(true)), "VectorCraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_title_follows_the_active_file() {
|
||||
let mut app = title_test_app(true);
|
||||
app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap();
|
||||
assert_eq!(super::window_title(&app), "foo.svg \u{2014} VectorCraft");
|
||||
// Unsaved changes gain a `*` prefix, like the `*` in the document tabs.
|
||||
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
|
||||
assert_eq!(super::window_title(&app), "*foo.svg \u{2014} VectorCraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_mode_hides_the_mark() {
|
||||
for custom in [true, false] {
|
||||
let mut app = title_test_app(custom);
|
||||
app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap();
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::app_bar(&mut app, ui));
|
||||
let uploads = out.textures_delta.set.values().flat_map(|d| d.iter()).filter(|d| d.image.size() == [128, 128]).count();
|
||||
out.textures_delta.clear();
|
||||
let marks: Vec<egui::Rect> = out
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Mesh(m) if m.texture_id == crate::brand::texture_id(&ctx) => Some(m.calc_bounds()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let system = super::system_title_bar(&app);
|
||||
assert_eq!(system, !custom && !cfg!(target_os = "macos"));
|
||||
assert_eq!(marks.is_empty(), system, "mark in system mode (custom={custom})");
|
||||
assert_eq!(uploads, if system { 0 } else { 1 }, "mark texture in system mode (custom={custom})");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_window_title_sends_only_on_change() {
|
||||
let mut app = title_test_app(true);
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
super::sync_window_title(&mut app, &ctx);
|
||||
assert_eq!(app.last_window_title, "VectorCraft");
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
|
||||
assert!(cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(t) if t == "VectorCraft")), "{cmds:?}");
|
||||
// Same document state again: the cached title stops a repeat command.
|
||||
super::sync_window_title(&mut app, &ctx);
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
let cmds = out.viewport_output.remove(&egui::ViewportId::ROOT).map(|v| v.commands).unwrap_or_default();
|
||||
assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Title(_))), "repeat Title: {cmds:?}");
|
||||
app.run("file.new", json!({"width": 100, "height": 100, "name": "foo.svg"})).unwrap();
|
||||
super::sync_window_title(&mut app, &ctx);
|
||||
assert_eq!(app.last_window_title, "foo.svg \u{2014} VectorCraft");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@
|
|||
Align to Path Zarovnat k cestě
|
||||
Cut Path at Selected Anchor Points Rozdělit cestu ve vybraných kotevních bodech
|
||||
Join Our Discord Připojit se k našemu Discordu
|
||||
Settings… Nastavení…
|
||||
Language Jazyk
|
||||
UI Brightness Jas rozhraní
|
||||
Dark Tmavý
|
||||
|
|
@ -673,3 +672,5 @@
|
|||
Patchwork… Patchwork…
|
||||
Stained Glass… Vitráž…
|
||||
Texturizer… Texturovač…
|
||||
System Title Bar Systémové záhlaví okna
|
||||
Exit Ukončit
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3975,3 +3975,4 @@ axis Both Ambos
|
|||
Top Right Arriba a la derecha
|
||||
Bottom Right Abajo a la derecha
|
||||
Edge Borde
|
||||
System Title Bar Barra de título del sistema
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3987,3 +3987,4 @@ axis Both Les deux
|
|||
Bottom Right En bas à droite
|
||||
effect Soft Doux
|
||||
Edge Bord
|
||||
System Title Bar Barre de titre du système
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3977,3 +3977,4 @@ axis Both Entrambi
|
|||
effect Soft Morbido
|
||||
effect Regular Normale
|
||||
Edge Bordo
|
||||
System Title Bar Barra del titolo di sistema
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3970,3 +3970,4 @@ effect Soft ソフト
|
|||
effect Horizontal 水平
|
||||
effect Vertical 垂直
|
||||
Edge エッジ
|
||||
System Title Bar システムのタイトルバー
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -2119,7 +2119,6 @@
|
|||
Set Zoom Definir Zoom
|
||||
Set base color to the current color Definir cor base para a cor atual
|
||||
Set the selected color to the current color Definir a cor selecionada para a cor atual
|
||||
Settings… Configurações…
|
||||
Setup… Configuração…
|
||||
Shadows and Glows Sombras e Brilhos
|
||||
Shape Forma
|
||||
|
|
@ -2733,3 +2732,5 @@
|
|||
Patchwork… Colcha de retalhos…
|
||||
Stained Glass… Vitral…
|
||||
Texturizer… Texturizador…
|
||||
System Title Bar Barra de título do sistema
|
||||
Exit Sair
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -3973,3 +3973,4 @@ axis Both Оба
|
|||
Top Right Сверху справа
|
||||
Bottom Right Снизу справа
|
||||
Edge Край
|
||||
System Title Bar Системная строка заголовка
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -199,8 +199,8 @@ fn the_engine_follows_the_interface_language() {
|
|||
assert_eq!(app.ui_language().code(), "ja");
|
||||
}
|
||||
|
||||
/// VectorCraft › Language (`app.language`) sets the `interfaceLanguage` preference, which is what
|
||||
/// persists; the checked item follows the preference, and a bad code is an error, not a change.
|
||||
/// `app.language` sets the `interfaceLanguage` preference, which is what persists; the checked
|
||||
/// item follows the preference, and a bad code is an error, not a change.
|
||||
#[test]
|
||||
fn language_command_sets_the_preference() {
|
||||
use crate::menus::{checked, run_ui_command};
|
||||
|
|
@ -218,9 +218,21 @@ fn language_command_sets_the_preference() {
|
|||
run_ui_command(&mut app, "app.language", &serde_json::json!({"lang": "auto"})).unwrap().unwrap();
|
||||
assert_eq!(app.session.prefs.interface_language, "auto");
|
||||
assert_eq!(checked(&app, "app.language", &serde_json::json!({"lang": "auto"})), Some(true));
|
||||
// Every registered language is in the menu, by its own name.
|
||||
let labels: Vec<String> =
|
||||
crate::menus::menu_entries(&app).into_iter().filter(|e| e.command.as_deref() == Some("app.language")).map(|e| e.label).collect();
|
||||
// Every registered language is in the Mac App menu's Language, by its own name (the in-window
|
||||
// bar has no Language menu).
|
||||
let layout = crate::native_menu::mac_layout(&app, &crate::native_menu::from_tree(&app, &crate::menus::menu_tree()), Lang::EN);
|
||||
let app_menu = layout.bar.menus.into_iter().find(|m| m.title == "VectorCraft").expect("App menu");
|
||||
fn items(nodes: &[crate::native_menu::Node], out: &mut Vec<String>) {
|
||||
for n in nodes {
|
||||
match n {
|
||||
crate::native_menu::Node::Item(it) if it.command == Some("app.language") => out.push(it.label.clone()),
|
||||
crate::native_menu::Node::Submenu { children, .. } => items(children, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut labels = vec![];
|
||||
items(&app_menu.children, &mut labels);
|
||||
for l in Lang::all() {
|
||||
assert!(labels.iter().any(|x| x == l.name()), "{} missing from {labels:?}", l.code());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3958,3 +3958,4 @@ axis Both Обидві
|
|||
Top Right Вгорі праворуч
|
||||
Bottom Right Знизу праворуч
|
||||
Edge Край
|
||||
System Title Bar Системний рядок заголовка
|
||||
|
|
|
|||
|
Can't render this file because it has a wrong number of fields in line 13.
|
|
|
@ -3606,3 +3606,4 @@ effect Soft 柔和
|
|||
effect Horizontal 水平
|
||||
effect Vertical 垂直
|
||||
Edge 边缘
|
||||
System Title Bar 系统标题栏
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
|
|
@ -3387,3 +3387,4 @@ axis Both 兩者
|
|||
Bottom Right 右下
|
||||
effect Soft 柔和
|
||||
Edge 邊緣
|
||||
System Title Bar 系統標題列
|
||||
|
|
|
|||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
|
|
@ -353,6 +353,9 @@ pub struct VectorcraftApp {
|
|||
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
|
||||
/// double-click to maximize, caption buttons) and invisible edge zones resize the window.
|
||||
pub custom_titlebar: bool,
|
||||
/// Last window title sent to the OS (`ViewportCommand::Title`, see `chrome::sync_window_title`):
|
||||
/// sent again only when it changes, so idle frames don't spam the backend.
|
||||
pub(crate) last_window_title: String,
|
||||
/// The graphics adapter the window renders with ("name (backend)"), as the host reports it:
|
||||
/// shown in Help › About and `ui.inspect` for GPU bug reports. `None` when unknown.
|
||||
pub graphics_adapter: Option<String>,
|
||||
|
|
@ -462,6 +465,7 @@ impl VectorcraftApp {
|
|||
canvas_rect: None,
|
||||
hover_doc: None,
|
||||
custom_titlebar: false,
|
||||
last_window_title: String::new(),
|
||||
graphics_adapter: None,
|
||||
place: Default::default(),
|
||||
system_paste: false,
|
||||
|
|
@ -958,6 +962,9 @@ impl VectorcraftApp {
|
|||
}
|
||||
shortcut_editor::sync(&self.ui);
|
||||
prefs_dialog::apply_runtime(self, ctx);
|
||||
// The OS title bar (and the taskbar / Alt-Tab entry) follows the active file; with the
|
||||
// system title bar this is where the document name lives, as the in-app mark is hidden.
|
||||
crate::chrome::sync_window_title(self, ctx);
|
||||
self.drain_control(ctx);
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ use crate::VectorcraftApp;
|
|||
use crate::io;
|
||||
use crate::panels::character::Face;
|
||||
use crate::state::{DockTab, next_zoom};
|
||||
use crate::theme::{self, Brightness, Tokens};
|
||||
use crate::theme::{Brightness, Tokens};
|
||||
use crate::widgets;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -1926,8 +1926,10 @@ fn selected_image(app: &VectorcraftApp, keep: impl Fn(&vectorcraft_doc::ImageObj
|
|||
})
|
||||
}
|
||||
|
||||
/// The in-window and native menu tree. `english_names` is Preferences › Type › Show Font Names in
|
||||
/// English (the Type → Font labels follow it; tests use the default `true`).
|
||||
/// The in-window and native menu tree. The in-window bar skips the VectorCraft menu (see
|
||||
/// `menu_bar`); the native Mac bar is composed from the whole tree (see `native_menu`).
|
||||
/// `english_names` is Preferences › Type › Show Font Names in English (the Type → Font labels
|
||||
/// follow it; tests use the default `true`).
|
||||
pub fn menu_tree() -> Vec<(&'static str, Vec<Item>)> {
|
||||
menu_tree_named(true)
|
||||
}
|
||||
|
|
@ -1936,24 +1938,13 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
|||
let panel = |label: &'static str, id: &'static str| cp(label, "window.panel", json!({ "panel": id }));
|
||||
vec![
|
||||
(
|
||||
// The application menu: About and Discord only. The in-window bar never shows it (File
|
||||
// is first there, as in Illustrator); on macOS `native_menu::mac_layout` composes the
|
||||
// App menu from this entry and the other menus (About from Help, Settings from Edit,
|
||||
// Quit from File, Language and Appearance synthesized). It stays first: `from_tree`
|
||||
// gives the first menu the App role.
|
||||
"VectorCraft",
|
||||
vec![
|
||||
c("About VectorCraft", "help.about"),
|
||||
c("Join Our Discord", "help.discord"),
|
||||
Sep,
|
||||
c("Settings…", "edit.preferences"),
|
||||
sub(
|
||||
"Language",
|
||||
std::iter::once(cp("Automatic", "app.language", json!({"lang": "auto"})))
|
||||
.chain(std::iter::once(Sep))
|
||||
.chain(crate::i18n::Lang::all().map(|l| cp(l.name(), "app.language", json!({"lang": l.code()}))))
|
||||
.collect(),
|
||||
),
|
||||
Sep,
|
||||
sub("UI Brightness", Brightness::ALL.iter().map(|b| cp(b.label(), "window.brightness", json!({"brightness": b.id()}))).collect()),
|
||||
Sep,
|
||||
c("Quit VectorCraft", "app.quit"),
|
||||
],
|
||||
vec![c("About VectorCraft", "help.about"), c("Join Our Discord", "help.discord")],
|
||||
),
|
||||
(
|
||||
"File",
|
||||
|
|
@ -2007,6 +1998,8 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
|||
c("File Info…", "file.info"),
|
||||
Sep,
|
||||
c("Print…", "file.print"),
|
||||
Sep,
|
||||
c("Exit", "app.quit"),
|
||||
],
|
||||
),
|
||||
(
|
||||
|
|
@ -2754,15 +2747,14 @@ pub fn menu_bar(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> f32 {
|
|||
}
|
||||
let mut clicked: Option<(String, Value)> = None;
|
||||
let tree = menu_tree_named(app.session.prefs.font_names_in_english);
|
||||
// No application menu in the window (as in Illustrator): the bar starts at File on every
|
||||
// platform. The VectorCraft menu stays in the tree: the native Mac bar takes it from there.
|
||||
let tree: Vec<(&str, Vec<Item>)> = tree.into_iter().filter(|(title, _)| *title != "VectorCraft").collect();
|
||||
let (end, open) = egui::MenuBar::new()
|
||||
.ui(ui, |ui| {
|
||||
let mut titles = Vec::with_capacity(tree.len());
|
||||
for (i, (title, items)) in tree.iter().enumerate() {
|
||||
let text = if i == 0 {
|
||||
egui::RichText::new(tl!(title)).font(theme::semibold(13.0)).color(t.text)
|
||||
} else {
|
||||
egui::RichText::new(tl!(title)).size(13.0).color(t.text)
|
||||
};
|
||||
for (title, items) in tree.iter() {
|
||||
let text = egui::RichText::new(tl!(title)).size(13.0).color(t.text);
|
||||
titles.push(ui.menu_button(text, |ui| menu_body(app, ui, items, &mut clicked)).response);
|
||||
}
|
||||
(ui.cursor().min.x, switch_on_hover(ui.ctx(), &titles))
|
||||
|
|
@ -3524,6 +3516,7 @@ pub fn menu_strings() -> std::collections::BTreeSet<String> {
|
|||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::theme;
|
||||
|
||||
/// One headless frame of the in-window menu bar; returns its titles (left to right) as
|
||||
/// (rect, id of the title's popup).
|
||||
|
|
@ -3553,6 +3546,43 @@ mod tests {
|
|||
(0..titles.len()).filter(|&i| egui::Popup::is_id_open(ctx, titles[i].1)).collect()
|
||||
}
|
||||
|
||||
/// File ends with Exit, running the app's own quit flow (unsaved documents are asked about).
|
||||
#[test]
|
||||
fn file_ends_with_exit() {
|
||||
let tree = menu_tree();
|
||||
let (_, file) = tree.iter().find(|(t, _)| *t == "File").unwrap();
|
||||
let last = file.iter().rev().find(|i| !matches!(i, Item::Sep)).unwrap();
|
||||
assert!(matches!(last, Item::Cmd("Exit", "app.quit", _)), "{last:?}");
|
||||
}
|
||||
|
||||
/// No application menu in the window (as in Illustrator): the bar starts at File.
|
||||
#[test]
|
||||
fn the_bar_starts_at_file() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
theme::apply(&ctx, Default::default());
|
||||
let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 700.0));
|
||||
let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
|
||||
menu_bar(&mut app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
let titles = ["File", "Edit", "Object", "Type", "Select", "Effect", "View", "Window", "Help"];
|
||||
for t in titles {
|
||||
assert!(shown.iter().any(|s| s == t), "{t} missing from {shown:?}");
|
||||
}
|
||||
assert!(!shown.iter().any(|s| s == "VectorCraft"), "{shown:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hovering_another_title_switches_the_open_menu() {
|
||||
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
|
||||
|
|
@ -3561,9 +3591,9 @@ mod tests {
|
|||
theme::apply(&ctx, Default::default());
|
||||
bar_frame(&mut app, &ctx, vec![]);
|
||||
let titles = bar_frame(&mut app, &ctx, vec![]);
|
||||
assert_eq!(titles.len(), menu_tree().len());
|
||||
// 0 is the app menu, then File, Edit, Object.
|
||||
let (file, edit, object) = (titles[1].0.center(), titles[2].0.center(), titles[3].0.center());
|
||||
// No application menu in the window: the bar starts at File on every platform.
|
||||
assert_eq!(titles.len(), menu_tree().len() - 1);
|
||||
let (file, edit, object) = (titles[0].0.center(), titles[1].0.center(), titles[2].0.center());
|
||||
let frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
|
||||
let mut t = bar_frame(app, &ctx, events);
|
||||
for _ in 0..2 {
|
||||
|
|
@ -3581,17 +3611,17 @@ mod tests {
|
|||
frames(&mut app, vec![egui::Event::PointerMoved(file)]);
|
||||
frames(&mut app, vec![button(file, true)]);
|
||||
let t = frames(&mut app, vec![button(file, false)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![1], "a click opens File");
|
||||
assert_eq!(open_titles(&ctx, &t), vec![0], "a click opens File");
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(edit)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Edit opens it and closes File");
|
||||
assert_eq!(open_titles(&ctx, &t), vec![1], "hovering Edit opens it and closes File");
|
||||
let t = frames(&mut app, vec![egui::Event::PointerMoved(object)]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![3], "hovering Object opens it and closes Edit");
|
||||
assert_eq!(open_titles(&ctx, &t), vec![2], "hovering Object opens it and closes Edit");
|
||||
|
||||
// A pointer resting on a title doesn't switch: File opened another way (the keyboard)
|
||||
// stays open while the pointer stays still over Object.
|
||||
egui::Popup::open_id(&ctx, t[1].1);
|
||||
egui::Popup::open_id(&ctx, t[0].1);
|
||||
let t = frames(&mut app, vec![]);
|
||||
assert_eq!(open_titles(&ctx, &t), vec![1], "a still pointer leaves the open menu alone");
|
||||
assert_eq!(open_titles(&ctx, &t), vec![0], "a still pointer leaves the open menu alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@
|
|||
//! - an **app menu** named VectorCraft: About; Settings, a submenu with one item per Preferences
|
||||
//! page (General… ⌘K first); Language; Appearance (the UI brightness); Services; Hide VectorCraft
|
||||
//! ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All; and Quit ⌘Q, still the app's own
|
||||
//! `app.quit`, so documents with unsaved changes are asked about. These leave the other menus
|
||||
//! (Edit › Preferences…, Help › About), and Join Our Discord stays under Help only;
|
||||
//! `app.quit`, so documents with unsaved changes are asked about. Language and Appearance have
|
||||
//! no in-window menu (as in PhotoCraft) and are synthesized here; Quit is File › Exit in the
|
||||
//! window and reads as Quit in the App menu. These leave the other menus (Edit › Preferences…,
|
||||
//! Help › About), and Join Our Discord stays under Help only;
|
||||
//! - **Window** gets Minimize ⌃⌘M, Zoom and Bring All to Front, and **Help** the system's search
|
||||
//! field;
|
||||
//! - a system key already bound to a command keeps the command's: the system item goes without.
|
||||
|
|
@ -34,6 +36,7 @@ use serde_json::{Value, json};
|
|||
use crate::VectorcraftApp;
|
||||
use crate::i18n::{Lang, tr, tr_ctx};
|
||||
use crate::menus::{self, Entry};
|
||||
use crate::theme::Brightness;
|
||||
|
||||
pub const APP_NAME: &str = "VectorCraft";
|
||||
/// Hide VectorCraft: an item of ours (AppKit's always takes ⌘H, which is View › Hide Edges).
|
||||
|
|
@ -292,7 +295,9 @@ pub struct Layout {
|
|||
}
|
||||
|
||||
/// The Mac menu bar of `bar` (the in-window menus, [`from_tree`]): see the module docs.
|
||||
pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
|
||||
/// Language and Appearance have no in-window menu (as in PhotoCraft); when the tree doesn't
|
||||
/// carry them they are synthesized below, so the App menu keeps them.
|
||||
pub fn mac_layout(app: &VectorcraftApp, bar: &MenuBar, lang: Lang) -> Layout {
|
||||
let mut bar = bar.clone();
|
||||
let mut clashes = Vec::new();
|
||||
let bound = crate::shortcuts::all_shortcuts();
|
||||
|
|
@ -316,8 +321,10 @@ pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
|
|||
let about = take_items(&mut bar, "help.about").into_iter().next();
|
||||
let settings = take_items(&mut bar, "edit.preferences").into_iter().next();
|
||||
let quit = take_items(&mut bar, "app.quit").into_iter().next();
|
||||
let language = take_submenu(&mut bar, "app.language");
|
||||
let appearance = take_submenu(&mut bar, "window.brightness");
|
||||
// Language and UI Brightness live only in the App menu: taken from the tree when it carries
|
||||
// them, else synthesized here (the in-window bar has neither).
|
||||
let language = take_submenu(&mut bar, "app.language").or_else(|| Some(language_node(app, lang)));
|
||||
let appearance = take_submenu(&mut bar, "window.brightness").or_else(|| Some(appearance_node(app, lang)));
|
||||
// Its other items (Join Our Discord) stay where the in-window bar also has them: Help.
|
||||
bar.menus.retain(|m| m.role != MenuRole::App);
|
||||
|
||||
|
|
@ -333,7 +340,11 @@ pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
|
|||
}));
|
||||
app.extend([Node::Separator, Node::Standard(Standard::Services), Node::Separator]);
|
||||
app.extend([Node::Item(hide), Node::Item(hide_others), Node::Standard(Standard::ShowAll), Node::Separator]);
|
||||
app.extend(quit.map(Node::Item));
|
||||
// Quit is File › Exit in the window; in the App menu it reads as a Mac Quit item.
|
||||
app.extend(quit.map(|mut q| {
|
||||
q.label = tr(lang, "Quit VectorCraft").into();
|
||||
Node::Item(q)
|
||||
}));
|
||||
bar.menus.insert(0, Menu { title: APP_NAME.into(), role: MenuRole::App, children: app });
|
||||
|
||||
if let Some(window) = bar.menus.iter_mut().find(|m| m.role == MenuRole::Window) {
|
||||
|
|
@ -350,6 +361,40 @@ pub fn mac_layout(bar: &MenuBar, lang: Lang) -> Layout {
|
|||
Layout { bar, clashes }
|
||||
}
|
||||
|
||||
/// Language in the app menu, after Settings: Automatic, then every UI language in its own name,
|
||||
/// with the `interfaceLanguage` preference checked.
|
||||
fn language_node(app: &VectorcraftApp, lang: Lang) -> Node {
|
||||
let mut children = vec![Node::Item(native_item(app, "Automatic", "app.language", json!({"lang": "auto"}))), Node::Separator];
|
||||
children.extend(Lang::all().map(|l| Node::Item(native_item(app, l.name(), "app.language", json!({"lang": l.code()})))));
|
||||
Node::Submenu { label: tr(lang, "Language").into(), children }
|
||||
}
|
||||
|
||||
/// Appearance in the app menu (the UI-brightness theme, as Mac apps call it).
|
||||
fn appearance_node(app: &VectorcraftApp, lang: Lang) -> Node {
|
||||
let children =
|
||||
Brightness::ALL.iter().map(|b| Node::Item(native_item(app, b.label(), "window.brightness", json!({"brightness": b.id()})))).collect();
|
||||
Node::Submenu { label: tr_ctx(lang, "theme", "Appearance").into(), children }
|
||||
}
|
||||
|
||||
/// A native item running `command`, built like [`from_tree`] builds it (translated label,
|
||||
/// shortcut, enabled and checked as they read now).
|
||||
fn native_item(app: &VectorcraftApp, label: &'static str, command: &'static str, params: Value) -> Item {
|
||||
let item = menus::Item::Cmd(label, command, params);
|
||||
match menus::entry(app, &item) {
|
||||
Some(Entry::Item(row)) => Item {
|
||||
command: row.command.map(|(id, _)| id),
|
||||
params: row.command.map_or(Value::Null, |(_, p)| p.clone()),
|
||||
source: row.source,
|
||||
label: row.label.into_owned(),
|
||||
shortcut: row.shortcut,
|
||||
enabled: row.enabled,
|
||||
checked: row.checked,
|
||||
},
|
||||
// The commands always show; the fallback keeps the structure.
|
||||
_ => Item { command: Some(command), params: Value::Null, source: label, label: label.into(), shortcut: None, enabled: true, checked: None },
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings ▸: one item per page of the Preferences dialog (its own list), each opening it there;
|
||||
/// the first (General…) has Preferences' shortcut (⌘K), as in Illustrator.
|
||||
fn settings_pages(preferences: &Item, lang: Lang) -> Vec<Node> {
|
||||
|
|
@ -413,7 +458,7 @@ fn take_submenu(bar: &mut MenuBar, command: &str) -> Option<Node> {
|
|||
|
||||
/// VectorCraft's menus as a Mac menu bar, as they show now.
|
||||
pub fn layout(app: &VectorcraftApp) -> Layout {
|
||||
mac_layout(&from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current())
|
||||
mac_layout(app, &from_tree(app, &menus::menu_tree_named(app.session.prefs.font_names_in_english)), crate::i18n::current())
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------- the app's side
|
||||
|
|
|
|||
|
|
@ -261,6 +261,14 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
|
|||
PrefKind::Bool if sp.key == "animatedZoom" => {
|
||||
ui.add_enabled_ui(d.bool("gpuPerformance"), |ui| bool_row(ui, d, sp.key, sp.label));
|
||||
}
|
||||
// Windows and Linux only: macOS always has the system's title bar, the web has none.
|
||||
PrefKind::Bool if sp.key == "systemTitleBar" => {
|
||||
if cfg!(all(not(target_arch = "wasm32"), not(target_os = "macos"))) {
|
||||
bool_row(ui, d, sp.key, sp.label);
|
||||
// The window's decorations are chosen once, when the app starts.
|
||||
ui.label(egui::RichText::new(tl!("Applies the next time VectorCraft starts.")).color(t.text_dim).size(11.0));
|
||||
}
|
||||
}
|
||||
PrefKind::Bool => bool_row(ui, d, sp.key, sp.label),
|
||||
PrefKind::Num { min, max, unit } => {
|
||||
labeled(ui, sp.label, |ui| {
|
||||
|
|
@ -530,6 +538,37 @@ mod tests {
|
|||
assert_eq!(saved["engine_prefs"]["gpuPreference"], json!("highPerformance"));
|
||||
}
|
||||
|
||||
/// User Interface › System Title Bar: shown with its next-launch note, applied by OK and
|
||||
/// saved with the UI state under the key the desktop app reads before the window opens.
|
||||
#[test]
|
||||
fn system_title_bar_preference_shows_and_persists() {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut a = app();
|
||||
open(&mut a, Some("User Interface"));
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
// The window sizes itself on the first frame and draws on the second.
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| show(&mut a, ui.ctx()));
|
||||
out.textures_delta.clear();
|
||||
let mut shown = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut shown));
|
||||
assert!(shown.iter().any(|t| t == "System Title Bar"), "{shown:?}");
|
||||
assert!(shown.iter().any(|t| t == "Applies the next time VectorCraft starts."), "{shown:?}");
|
||||
a.ui.dialog.as_mut().unwrap().fields.insert("systemTitleBar".into(), json!(true));
|
||||
confirm(&mut a).unwrap();
|
||||
assert!(a.session.prefs.system_title_bar);
|
||||
let saved: Value = serde_json::from_slice(&serde_json::to_vec(&a.ui).unwrap()).unwrap();
|
||||
assert_eq!(saved["engine_prefs"]["systemTitleBar"], json!(true));
|
||||
}
|
||||
|
||||
/// Units › Numbers Without Units Are Points (#394): the preference reaches the fields when it
|
||||
/// changes, and its checkbox is dimmed unless a unit is Picas.
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ fn app(doc: bool) -> VectorcraftApp {
|
|||
}
|
||||
|
||||
fn bar(app: &VectorcraftApp) -> MenuBar {
|
||||
native_menu::mac_layout(&native_menu::from_tree(app, &menus::menu_tree()), Lang::EN).bar
|
||||
native_menu::mac_layout(app, &native_menu::from_tree(app, &menus::menu_tree()), Lang::EN).bar
|
||||
}
|
||||
|
||||
/// A level's nodes as short names: an item's command (`---` a separator, `[label]` a submenu,
|
||||
|
|
@ -182,8 +182,10 @@ fn every_in_window_item_is_in_the_mac_menu_bar_once() {
|
|||
assert_eq!(m, want, "{key:?} is listed {m} times on the Mac, {n} in the window");
|
||||
}
|
||||
let system = [native_menu::HIDE, native_menu::HIDE_OTHERS, native_menu::MINIMIZE];
|
||||
// Language and Appearance have no in-window menu (synthesized in the App menu, as in PhotoCraft).
|
||||
let synthesized = ["app.language", "window.brightness"];
|
||||
for key in b.keys() {
|
||||
assert!(a.contains_key(key) || system.contains(&key.0.as_str()), "{key:?} isn't an in-window item");
|
||||
assert!(a.contains_key(key) || system.contains(&key.0.as_str()) || synthesized.contains(&key.0.as_str()), "{key:?} isn't an in-window item");
|
||||
}
|
||||
let help = ids(menu(&mac, "Help"));
|
||||
assert!(help.contains(&"help.discord".to_string()) && !help.contains(&"help.about".to_string()), "{help:?}");
|
||||
|
|
@ -211,7 +213,7 @@ fn a_command_keeps_a_system_key_it_is_bound_to() {
|
|||
let mut app = app(true);
|
||||
app.ui.shortcut_overrides.insert("view.grid".into(), "Ctrl+Cmd+H".into());
|
||||
crate::shortcut_editor::sync(&app.ui);
|
||||
let layout = native_menu::mac_layout(&native_menu::from_tree(&app, &menus::menu_tree()), Lang::EN);
|
||||
let layout = native_menu::mac_layout(&app, &native_menu::from_tree(&app, &menus::menu_tree()), Lang::EN);
|
||||
app.ui.shortcut_overrides.clear();
|
||||
crate::shortcut_editor::sync(&app.ui);
|
||||
assert_eq!(find(&layout.bar, native_menu::HIDE).shortcut, None);
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ mod tests {
|
|||
.layers()
|
||||
.filter(|(l, _)| l.order != Order::Foreground)
|
||||
.flat_map(|(_, ws)| ws.iter())
|
||||
.filter(|w| w.sense.senses_click() && w.rect.top() < 44.0 && !skip.contains(&w.id))
|
||||
.filter(|w| w.sense.senses_click() && w.rect.top() < 32.0 && !skip.contains(&w.id))
|
||||
.map(|w| w.rect)
|
||||
.collect()
|
||||
})
|
||||
|
|
@ -263,20 +263,22 @@ mod tests {
|
|||
let out = w.frame(vec![]);
|
||||
let [min, max, close] = CAPTIONS.map(|c| w.caption(c));
|
||||
assert_eq!((close.right(), close.top()), (width, 0.0), "Close sits in the window's corner at {width}");
|
||||
assert!(close.height() >= 42.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
|
||||
assert!(close.height() >= 30.0 && [min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));
|
||||
assert_eq!((min.right(), max.right()), (max.left(), close.left()));
|
||||
let widgets = w.bar_widgets();
|
||||
assert!(widgets.len() >= 12, "home, nine menus, workspace switcher, search: {widgets:?}");
|
||||
assert!(widgets.len() >= 11, "home, eight menus, workspace switcher, search: {widgets:?}");
|
||||
for (i, a) in widgets.iter().enumerate() {
|
||||
assert!(a.right() <= min.left() - 8.0, "{a:?} runs into the caption buttons at {width}");
|
||||
for b in &widgets[i + 1..] {
|
||||
assert!(!a.shrink(0.5).intersects(b.shrink(0.5)), "{a:?} overlaps {b:?} at {width}");
|
||||
}
|
||||
}
|
||||
// Wide windows show the whole group; narrow ones drop Discord and shorten the search.
|
||||
// Wide windows show the whole group; narrow ones drop Discord, then the search icon.
|
||||
let texts: Vec<String> = texts(&out).into_iter().map(|(t, _)| t).collect();
|
||||
let has = |s: &str| texts.iter().any(|t| t == s);
|
||||
assert_eq!((has("Discord"), has("Search commands and tools")), (width > 1000.0, width > 1000.0), "{width}: {texts:?}");
|
||||
assert_eq!(has("Discord"), width > 1000.0, "{width}: {texts:?}");
|
||||
// Search is an icon only (as in PhotoCraft): its label lives in the tooltip.
|
||||
assert!(!has("Search commands and tools"), "{width}: {texts:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -314,7 +316,7 @@ mod tests {
|
|||
fn empty_bar_space_drags_and_double_click_maximizes() {
|
||||
let mut w = Win::new(1440.0);
|
||||
w.frame(vec![]);
|
||||
let empty = pos2(700.0, 22.0);
|
||||
let empty = pos2(700.0, 16.0);
|
||||
assert!(w.bar_widgets().iter().all(|r| !r.contains(empty)), "the probe point must be empty bar space");
|
||||
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:?}");
|
||||
|
|
|
|||
|
|
@ -208,8 +208,10 @@ Tile Edge Color: Object → Pattern → Tile Edge Color… (`ui.tileEdgeColor`)
|
|||
Window title bar: on Windows and Linux the window has no OS decorations and the app bar is the title bar. Its
|
||||
caption buttons (Minimize, Maximize/Restore, Close) sit at the bar's right end, 46 pt each; they are window chrome,
|
||||
not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (the same `saveChanges` questions for
|
||||
modified documents), and empty bar space and the 5 pt window edges move and resize the window. macOS and the web
|
||||
build are unchanged.
|
||||
modified documents), and empty bar space and the 5 pt window edges move and resize the window. Preferences › User
|
||||
Interface › System Title Bar (`systemTitleBar`, read when the app starts) gives the window its OS decorations back
|
||||
instead: the OS title then shows the active document (`*name — VectorCraft` with unsaved changes) and the app bar
|
||||
drops its brand mark. On macOS the window buttons are centred on the app bar; the web build is unchanged.
|
||||
|
||||
Fill/Stroke chips and panel shortcuts: the Control bar's and Properties' Fill and Stroke chips bring their proxy
|
||||
forward (`paint.toggleActive {fill}`) and open a popover with the Swatches panel (Shift-click: the Color panel's mixer);
|
||||
|
|
|
|||
|
|
@ -86,9 +86,9 @@ By default VectorCraft's own crates log at `info` and everything else at `warn`.
|
|||
|
||||
## macOS: the menu bar
|
||||
|
||||
On macOS the menus live in the system menu bar, not in the window's app bar (which keeps the brand mark, Home, the search box and the workspace switcher next to the traffic lights). `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps them in the window, as on Windows, Linux and the web; so does a menu bar AppKit refuses to build (logged). `ui.inspect` says which with `nativeMenuBar`.
|
||||
On macOS the menus live in the system menu bar, not in the window's app bar (which keeps the brand mark, Home, the search icon and the workspace switcher next to the traffic lights, centred on the bar by `apps/vectorcraft/src/mac_window.rs`). `--in-window-menus` or `VECTORCRAFT_IN_WINDOW_MENUS=1` keeps them in the window, as on Windows, Linux and the web; so does a menu bar AppKit refuses to build (logged). `ui.inspect` says which with `nativeMenuBar`.
|
||||
|
||||
- **One menu model.** `crates/ui-egui/src/native_menu.rs` (platform-free, tested on every platform) builds the menu bar from the in-window menus (`menus::menu_tree`, each item through `menus::entry`, which the in-window menus draw too) and lays it out the Mac way: the VectorCraft menu holds About, Settings ▸ (one item per Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own `app.quit`, so unsaved documents are asked about); those leave Edit and Help. Window starts with Minimize ⌃⌘M and Zoom and ends with Bring All to Front, and Help is the system's Help menu (its search field). A system key a command (or a user's shortcut) already has stays the command's, and the log says so.
|
||||
- **One menu model.** `crates/ui-egui/src/native_menu.rs` (platform-free, tested on every platform) builds the menu bar from the in-window menus (`menus::menu_tree`, each item through `menus::entry`, which the in-window menus draw too) and lays it out the Mac way: the VectorCraft menu holds About, Settings ▸ (one item per Preferences page, General… ⌘K), Language ▸, Appearance ▸ (UI brightness), Services, Hide VectorCraft ⌃⌘H (⌘H stays View › Hide Edges), Hide Others ⌥⌘H, Show All and Quit ⌘Q (the app's own `app.quit`, so unsaved documents are asked about); those leave Edit and Help. The in-window bar itself never shows the VectorCraft menu (it starts at File, as in Illustrator, on every platform): Language and Appearance have no in-window menu and are synthesized in `mac_layout`, and Quit is File › Exit in the window, relabelled to Quit in the App menu. Window starts with Minimize ⌃⌘M and Zoom and ends with Bring All to Front, and Help is the system's Help menu (its search field). A system key a command (or a user's shortcut) already has stays the command's, and the log says so.
|
||||
- **The native side** is `apps/vectorcraft/src/mac_menu.rs`: muda (`=0.21.1`, default features off, macOS only, no `unsafe`) builds the `NSMenu`s. Labels, enabled and checked are updated in place when anything they show may have changed (a command ran, the document, its history or selection changed, a click or a key press); a change of structure (a recent file, the language, a shortcut) rebuilds the menus.
|
||||
- **Keys stay with egui.** AppKit runs a menu's key equivalents before the window sees the key, so the item's key goes back to egui as the key press it was (⌘X/⌘C/⌘V as egui's Cut/Copy/Paste events), through `raw_input_hook`: `shortcuts::handle` runs it once, user shortcuts apply, and a focused text field keeps ⌘A, ⌘C, ⌘V and ⌘Z. A click runs the item like an in-window click. Bare keys and ⌥/⇧ chords are never key equivalents (they type).
|
||||
- Built and clippy-checked for `aarch64-apple-darwin` and `x86_64-apple-darwin` from any host: `cargo clippy -p vectorcraft --target aarch64-apple-darwin --all-targets -- -D warnings`.
|
||||
|
|
@ -182,8 +182,8 @@ locale such as `ru-RU`, `ru-BY` or `ru-KZ` resolves to it), Ukrainian (`uk`, com
|
|||
- The shared widgets (`widgets::check`, `dropdown`, `menu_item`, the buttons, `label_row`, tooltips of
|
||||
`icon_button`…), the menus, the dock, the toolbar, the dialog frame and `panels::empty_state` translate
|
||||
the text they are given, so a panel mostly needs its literals wrapped in `tl!` to be covered by the tests.
|
||||
- The language is VectorCraft › Language (the `app.language` UI command, `{lang: auto|<code>}`) or Edit ›
|
||||
Preferences › User Interface › Language; both set the `interfaceLanguage` preference (`auto` or a language
|
||||
- The language is Edit › Preferences › User Interface › Language or, on macOS, VectorCraft › Language (the
|
||||
`app.language` UI command, `{lang: auto|<code>}`); both set the `interfaceLanguage` preference (`auto` or a language
|
||||
code; `auto` follows the system locale: `VECTORCRAFT_LOCALE`, then `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE`,
|
||||
the macOS preferred languages, the Windows user locale; on the web, `?lang=<code>` in the address, then
|
||||
the browser's `navigator.languages`). The Preferences dialog previews the chosen language before OK.
|
||||
|
|
|
|||
|
|
@ -2601,7 +2601,7 @@ without ideographs uses its em box. One undo step.
|
|||
|
||||
## New type in a Japanese interface
|
||||
|
||||
While the interface is in Japanese (VectorCraft › Language, or `auto` on a Japanese system), new type starts with em
|
||||
While the interface is in Japanese (Preferences › User Interface › Language, or `auto` on a Japanese system), new type starts with em
|
||||
box top-to-top leading (`leadingModel: "emBoxTop"`) and em box centre character alignment (`charAlign: "emBoxCenter"`);
|
||||
in other languages it starts on the Roman baseline (#432). This covers the Type tools,
|
||||
`text.create` and `text.createInPath`, which also take `leadingModel` and `charAlign` to choose, and `paraStyle.new` /
|
||||
|
|
|
|||
Loading…
Reference in New Issue