Browse / github.com/storytold/vectorcraft / patch
UI: Classic OG colour scheme, 0.11's look with the desktop's window buttons
UI is inconsistent with PhotoCraft since the 0.12 restyle Since updating from 0.11.0 to 0.13.1, VectorCraft looks noticeably different from PhotoCraft, and the two no longer feel like they belong to the same suite. I use both side by side, so it stands out. What I'm seeing: - **Toolbar:** VectorCraft's toolbar is now drawn as a card and looks different from PhotoCraft's toolbar, which is still flat. - **Buttons:** PhotoCraft's start screen uses pill buttons ("New document…", "Open…"). VectorCraft uses squarer filled buttons ("New file", "Open"). - **Panel tabs:** the tab row in the panel dock (Properties / Layers / Libraries) is flattened into one
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- turnerboy (imported from pull request #3175, open)
- Base
1dda8d9e67e1bafc2d32708e847a972835c0469d- Size
- +1044 −75 in 38 files
- Review
- pending
- Shared
- 2026-10-28
- Id
b7d4c190142d18c20f76e9f4
How it went
No other operator has reported using it yet.
Diff
raw · apps/vectorcraft/src/caption_style.rs apps/vectorcraft/src/main.rs crates/engine/src/cmd/prefscmds.rs crates/engine/src/lib.rs crates/ui-egui/src/chrome.rs crates/ui-egui/src/community.rs crates/ui-egui/src/dock.rs crates/ui-egui/src/i18n/bg.tsv crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/da.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/el.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fi.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/id.tsv crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/ka.tsv crates/ui-egui/src/i18n/ko.tsv crates/ui-egui/src/i18n/nl.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/sv.tsv crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/i18n/vi.tsv crates/ui-egui/src/i18n/zh-hans.tsv crates/ui-egui/src/i18n/zh-hant.tsv crates/ui-egui/src/lib.rs crates/ui-egui/src/panel_docking.rs crates/ui-egui/src/theme.rs crates/ui-egui/src/titlebar.rs crates/ui-egui/src/toolbar.rs crates/ui-egui/src/ui_fonts.rs crates/ui-egui/src/widgets.rs docs/control-protocol.md
new file mode 100644@@ -0,0 +1,285 @@+//! Linux: which desktop the custom title bar's caption buttons look like with the Classic OG colour+//! scheme, and which buttons go on which side (`vectorcraft_ui_egui::titlebar::CaptionStyle`), as in+//! PhotoCraft (its `caption_style.rs`). GNOME gets libadwaita's buttons in its `button-layout` order+//! (from `gsettings`), KDE (and SteamOS's desktop mode) Breeze's in KWin's order (`kwinrc`); other desktops get plain round buttons, or Adwaita's+//! or Breeze's when their GTK theme is one of those, in GTK's `gtk-decoration-layout` order. Read+//! once, when the app starts.+//!+//! `VECTORCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`, `generic`) and+//! `VECTORCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found.++#[cfg(target_os = "linux")]+use vectorcraft_ui_egui::titlebar::{CaptionFamily, CaptionStyle};++#[cfg(target_os = "linux")]+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+enum Desktop {+ Gnome,+ Kde,+ Other,+}++/// The desktop, from the first of its variables that is set: `ubuntu:GNOME`, `Budgie:GNOME` and+/// the like count as GNOME.+#[cfg(target_os = "linux")]+fn desktop(var: &dyn Fn(&str) -> Option<String>) -> Desktop {+ for key in ["XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "DESKTOP_SESSION"] {+ let Some(v) = var(key).map(|v| v.to_ascii_lowercase()).filter(|v| !v.trim().is_empty()) else { continue };+ return if v.contains("gnome") {+ Desktop::Gnome+ } else if v.contains("kde") || v.contains("plasma") {+ Desktop::Kde+ } else {+ Desktop::Other+ };+ }+ if var("KDE_FULL_SESSION").is_some() {+ Desktop::Kde+ } else if var("GNOME_DESKTOP_SESSION_ID").is_some() {+ Desktop::Gnome+ } else {+ Desktop::Other+ }+}++/// GNOME's apps are libadwaita's whatever the GTK theme, and KWin draws Breeze; elsewhere the GTK+/// theme says (Adwaita, adw-gtk3, Yaru, Breeze), and any other theme gets the plain buttons.+#[cfg(target_os = "linux")]+fn family(desktop: Desktop, gtk_theme: Option<&str>) -> CaptionFamily {+ match desktop {+ Desktop::Gnome => CaptionFamily::Adwaita,+ Desktop::Kde => CaptionFamily::Breeze,+ Desktop::Other => {+ let theme = gtk_theme.unwrap_or_default().trim().to_ascii_lowercase();+ if ["adwaita", "adw-gtk3", "yaru"].iter().any(|p| theme.starts_with(p)) {+ CaptionFamily::Adwaita+ } else if theme.starts_with("breeze") {+ CaptionFamily::Breeze+ } else {+ CaptionFamily::Generic+ }+ }+ }+}++/// The value of `key` in `[section]` of an INI-style file (GTK's `settings.ini`, KDE's `kwinrc`).+#[cfg(target_os = "linux")]+fn ini_value(text: &str, section: &str, key: &str) -> Option<String> {+ let mut inside = false;+ for line in text.lines().map(str::trim) {+ if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) {+ inside = name == section;+ } else if inside+ && let Some((k, v)) = line.split_once('=')+ && k.trim() == key+ {+ return Some(v.trim().to_string());+ }+ }+ None+}++/// KWin's button order (`[org.kde.kdecoration2]` `ButtonsOnLeft` / `ButtonsOnRight`, one letter a+/// button: `I` minimize, `A` maximize, `X` close), as a GTK layout. Missing keys keep KWin's+/// defaults: the window menu on the left, minimize, maximize and close on the right.+#[cfg(target_os = "linux")]+fn kwin_layout(kwinrc: &str) -> String {+ let side = |key: &str, default: &str| {+ let letters = ini_value(kwinrc, "org.kde.kdecoration2", key).unwrap_or_else(|| default.to_string());+ letters+ .chars()+ .filter_map(|c| match c {+ 'I' => Some("minimize"),+ 'A' => Some("maximize"),+ 'X' => Some("close"),+ _ => None,+ })+ .collect::<Vec<_>>()+ .join(",")+ };+ format!("{}:{}", side("ButtonsOnLeft", "MS"), side("ButtonsOnRight", "HIAX"))+}++/// What the style is worked out from.+#[cfg(target_os = "linux")]+#[derive(Clone, Default)]+struct Found {+ gtk_theme: Option<String>,+ /// A GTK decoration layout.+ layout: Option<String>,+}++/// The style from what was found.+#[cfg(target_os = "linux")]+fn resolve(desktop: Desktop, found: &Found, var: &dyn Fn(&str) -> Option<String>) -> CaptionStyle {+ let family =+ var("VECTORCRAFT_CAPTION_STYLE").and_then(|s| CaptionFamily::parse(&s)).unwrap_or_else(|| family(desktop, found.gtk_theme.as_deref()));+ // GNOME's own default shows Close only; GTK's shows all three.+ let default = if desktop == Desktop::Gnome { "appmenu:close" } else { "menu:minimize,maximize,close" };+ let layout = var("VECTORCRAFT_CAPTION_LAYOUT").or_else(|| found.layout.clone()).unwrap_or_else(|| default.to_string());+ CaptionStyle::from_gtk_layout(family, &layout)+}++/// A settings file, if it is there and small (a settings file is a few kilobytes).+#[cfg(target_os = "linux")]+fn read_text(path: &std::path::Path) -> Option<String> {+ use std::io::Read as _;+ const MAX: u64 = 1 << 20;+ let mut text = String::new();+ std::fs::File::open(path).ok()?.take(MAX).read_to_string(&mut text).ok()?;+ Some(text)+}++/// The GTK theme and the button layout from the desktop's files: `kwinrc` on KDE, GTK 3's+/// `settings.ini` elsewhere (on GNOME, its own setting replaces it: [`gnome_button_layout`]).+#[cfg(target_os = "linux")]+fn from_files(desktop: Desktop, var: &dyn Fn(&str) -> Option<String>) -> Found {+ let config = var("XDG_CONFIG_HOME")+ .filter(|d| std::path::Path::new(d).is_absolute())+ .map(std::path::PathBuf::from)+ .or_else(|| var("HOME").map(|h| std::path::Path::new(&h).join(".config")));+ let Some(config) = config else { return Found::default() };+ let gtk = read_text(&config.join("gtk-3.0").join("settings.ini")).unwrap_or_default();+ let gtk_theme = ini_value(>k, "Settings", "gtk-theme-name");+ let layout = if desktop == Desktop::Kde {+ Some(kwin_layout(&read_text(&config.join("kwinrc")).unwrap_or_default()))+ } else {+ ini_value(>k, "Settings", "gtk-decoration-layout")+ };+ Found { gtk_theme, layout }+}++/// GNOME's `org.gnome.desktop.wm.preferences` `button-layout`, from `gsettings` (none when it is+/// missing, as in some sandboxes, or fails).+#[cfg(target_os = "linux")]+fn gnome_button_layout() -> Option<String> {+ let out = std::process::Command::new("gsettings").args(["get", "org.gnome.desktop.wm.preferences", "button-layout"]).output().ok()?;+ if !out.status.success() {+ return None;+ }+ gsettings_string(&String::from_utf8_lossy(&out.stdout))+}++/// A string as `gsettings get` prints it (`'appmenu:close'`, or `@ms 'appmenu:close'` for a maybe+/// type), without the quotes; none when empty.+#[cfg(target_os = "linux")]+fn gsettings_string(printed: &str) -> Option<String> {+ let s = printed.trim();+ let s = s.strip_prefix("@ms ").unwrap_or(s).trim();+ let s = s.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')).unwrap_or(s).trim();+ (!s.is_empty()).then(|| s.to_string())+}++/// The desktop's caption buttons, for `Services::caption_style`; `None` off Linux (Windows keeps its+/// own buttons, macOS its traffic lights).+pub fn detect() -> Option<vectorcraft_ui_egui::titlebar::CaptionStyle> {+ #[cfg(target_os = "linux")]+ {+ let var = |k: &str| std::env::var(k).ok();+ let desktop = desktop(&var);+ let mut found = from_files(desktop, &var);+ // GNOME keeps its button layout in its settings (what Settings and Tweaks change), not in+ // GTK's settings.ini.+ if desktop == Desktop::Gnome+ && let Some(layout) = gnome_button_layout()+ {+ found.layout = Some(layout);+ }+ Some(resolve(desktop, &found, &var))+ }+ #[cfg(not(target_os = "linux"))]+ {+ None+ }+}++#[cfg(all(test, target_os = "linux"))]+mod tests {+ #![allow(clippy::unwrap_used)]+ use super::*;+ use vectorcraft_ui_egui::titlebar::Caption::{Close, Maximize, Minimize};++ fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {+ let pairs: Vec<(String, String)> = pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();+ move |k| pairs.iter().find(|(key, _)| key == k).map(|(_, v)| v.clone())+ }++ #[test]+ fn gsettings_strings_lose_their_quotes() {+ assert_eq!(gsettings_string("'appmenu:minimize,maximize,close'\n").as_deref(), Some("appmenu:minimize,maximize,close"));+ assert_eq!(gsettings_string("@ms 'close,minimize:'").as_deref(), Some("close,minimize:"));+ assert_eq!(gsettings_string("''\n"), None);+ assert_eq!(gsettings_string(""), None);+ // GNOME's layout then sets the buttons (Minimize and Maximize turned on in Tweaks).+ let found = Found { gtk_theme: None, layout: gsettings_string("'appmenu:minimize,maximize,close'") };+ let s = resolve(Desktop::Gnome, &found, &env(&[]));+ assert_eq!((s.family, s.left, s.right), (CaptionFamily::Adwaita, vec![], vec![Minimize, Maximize, Close]));+ }++ #[test]+ fn the_desktop_comes_from_the_first_variable_set() {+ assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")])), Desktop::Gnome);+ assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "KDE")])), Desktop::Kde);+ assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", "X-Cinnamon"), ("KDE_FULL_SESSION", "true")])), Desktop::Other);+ assert_eq!(desktop(&env(&[("XDG_CURRENT_DESKTOP", " "), ("DESKTOP_SESSION", "plasma")])), Desktop::Kde);+ assert_eq!(desktop(&env(&[("KDE_FULL_SESSION", "true")])), Desktop::Kde);+ assert_eq!(desktop(&env(&[("GNOME_DESKTOP_SESSION_ID", "this-is-deprecated")])), Desktop::Gnome);+ assert_eq!(desktop(&env(&[])), Desktop::Other);+ }++ #[test]+ fn the_family_follows_the_desktop_then_the_gtk_theme() {+ assert_eq!(family(Desktop::Gnome, Some("Arc")), CaptionFamily::Adwaita);+ assert_eq!(family(Desktop::Kde, None), CaptionFamily::Breeze);+ assert_eq!(family(Desktop::Other, Some("adw-gtk3-dark")), CaptionFamily::Adwaita);+ assert_eq!(family(Desktop::Other, Some("Yaru-blue")), CaptionFamily::Adwaita);+ assert_eq!(family(Desktop::Other, Some("Breeze-Dark")), CaptionFamily::Breeze);+ assert_eq!(family(Desktop::Other, Some("Mint-Y")), CaptionFamily::Generic);+ assert_eq!(family(Desktop::Other, None), CaptionFamily::Generic);+ }++ #[test]+ fn kwin_button_letters_become_a_gtk_layout() {+ assert_eq!(kwin_layout(""), ":minimize,maximize,close", "KWin's defaults");+ let rc = "[Desktops]\nButtonsOnLeft=X\n[org.kde.kdecoration2]\nButtonsOnLeft=XIA\nButtonsOnRight=\nlibrary=org.kde.breeze\n";+ assert_eq!(kwin_layout(rc), "close,minimize,maximize:");+ assert_eq!(ini_value(rc, "org.kde.kdecoration2", "library").as_deref(), Some("org.kde.breeze"));+ assert_eq!(ini_value(rc, "nope", "library"), None);+ // Garbage is just no buttons on that side, never a panic.+ assert_eq!(kwin_layout("[org.kde.kdecoration2]\nButtonsOnRight=ÿ€=X\n"), ":close");+ }++ #[test]+ fn layouts_and_overrides_resolve_to_a_style() {+ let none = env(&[]);+ let gnome = resolve(Desktop::Gnome, &Found::default(), &none);+ assert_eq!((gnome.family, gnome.left, gnome.right), (CaptionFamily::Adwaita, vec![], vec![Close]), "GNOME's default layout");+ // KDE, SteamOS's desktop mode among them: Breeze's buttons in KWin's default order.+ let kde = resolve(Desktop::Kde, &Found { layout: Some(kwin_layout("")), ..Found::default() }, &none);+ assert_eq!((kde.family, kde.left, kde.right), (CaptionFamily::Breeze, vec![], vec![Minimize, Maximize, Close]));+ let found = Found { gtk_theme: Some("Mint-Y".into()), layout: Some("close:".into()) };+ let other = resolve(Desktop::Other, &found, &none);+ assert_eq!((other.family, other.left, other.right), (CaptionFamily::Generic, vec![Close], vec![]));+ let forced = env(&[("VECTORCRAFT_CAPTION_STYLE", "Breeze"), ("VECTORCRAFT_CAPTION_LAYOUT", "minimize,close:maximize")]);+ let s = resolve(Desktop::Gnome, &found, &forced);+ assert_eq!((s.family, s.left, s.right), (CaptionFamily::Breeze, vec![Minimize, Close], vec![Maximize]));+ // An unknown override name falls back to what was found.+ assert_eq!(resolve(Desktop::Kde, &found, &env(&[("VECTORCRAFT_CAPTION_STYLE", "aqua")])).family, CaptionFamily::Breeze);+ }++ #[test]+ fn settings_files_are_read_from_the_config_dir() {+ let dir = std::env::temp_dir().join(format!("vectorcraft-caption-{}", std::process::id()));+ std::fs::create_dir_all(dir.join("gtk-3.0")).unwrap();+ std::fs::write(dir.join("gtk-3.0/settings.ini"), "[Settings]\ngtk-theme-name=Yaru\ngtk-decoration-layout=close,maximize:\n").unwrap();+ std::fs::write(dir.join("kwinrc"), "[org.kde.kdecoration2]\nButtonsOnRight=AX\n").unwrap();+ let var = env(&[("XDG_CONFIG_HOME", dir.to_str().unwrap())]);+ let other = from_files(Desktop::Other, &var);+ assert_eq!((other.gtk_theme.as_deref(), other.layout.as_deref()), (Some("Yaru"), Some("close,maximize:")));+ let kde = from_files(Desktop::Kde, &var);+ assert_eq!(kde.layout.as_deref(), Some(":maximize,close"));+ // A relative XDG_CONFIG_HOME is ignored, as the XDG spec says; no HOME: nothing found.+ assert_eq!(from_files(Desktop::Other, &env(&[("XDG_CONFIG_HOME", "rel")])).layout, None);+ let _ = std::fs::remove_dir_all(dir);+ }+}@@ -23,6 +23,7 @@ compile_error!("windows7 requires --target x86_64-win7-windows-msvc; the ordinar compile_error!("the win7 target requires --no-default-features --features windows7"); mod automation_args;+mod caption_style; mod clipboard; #[cfg(feature = "wgpu")] mod compositor_gpu;@@ -569,6 +570,8 @@ fn services(parent: Parent, ui: egui::Context) -> Services { screen_color: Some(mac_screen_color::sampler(sampled_ui)), // File → Print: the system's printers and print queue. print: Some(Box::new(printing::SystemPrint)),+ // Classic OG's caption buttons look like the desktop's (Linux).+ caption_style: caption_style::detect(), ..Default::default() } }@@ -268,7 +268,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[ "Brightness", choice(&[("dark", "Dark"), ("mediumDark", "Medium Dark"), ("mediumLight", "Medium Light"), ("light", "Light")]) ),- p!("uiColorScheme", "User Interface", "", "Color Scheme", choice(&[("modern", "Modern"), ("classic", "Classic")])),+ p!("uiColorScheme", "User Interface", "", "Color Scheme", choice(&[("modern", "Modern"), ("classic", "Classic"), ("classicOg", "Classic OG")])), p!( "uiAccent", "User Interface",@@ -916,8 +916,9 @@ pub struct Prefs { pub ui_brightness: String, /// User Interface › Accent Color: the colour of what is on, chosen or focused. pub ui_accent: String,- /// User Interface › Color Scheme: "modern" (with the accent above) or "classic" (neutral- /// greys and a blue accent).+ /// User Interface › Color Scheme: "modern" (with the accent above), "classic" (neutral greys+ /// and a blue accent) or "classicOg" (VectorCraft 0.11's look: Classic's greys on flat panels,+ /// with tab strips, pill buttons and the desktop's caption buttons, as in PhotoCraft). pub ui_color_scheme: String, pub canvas_color: String, pub auto_collapse_icon_panels: bool,@@ -26,10 +26,16 @@ const WIDGET_H: f32 = 22.0; /// On macOS, under the system's transparent title bar, its empty space drags and zooms the window /// too ([`titlebar::drag_area`]). 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.+///+/// With the Classic OG colour scheme it is laid out as PhotoCraft's: the caption buttons follow the+/// desktop and may sit at either end ([`titlebar::reserved`]), the workspace switcher is a boxed+/// dropdown, and the document's name (or VectorCraft) is centred in the room between the menus and+/// the controls. 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 (caption_left, caption_right) = titlebar::reserved(app, ui.ctx()); 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| {@@ -39,6 +45,11 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { titlebar::drag_area(ui, ui.max_rect()); } ui.horizontal_centered(|ui| {+ // Caption buttons at the left end (a desktop that puts them there): the bar starts+ // after them.+ if caption_left > 0.0 {+ ui.add_space((caption_left - f32::from(left) + 4.0).max(0.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());@@ -56,7 +67,7 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { // 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_edge = if custom { full.right() - caption_right - 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())); let room = right.width(); // Built-in workspace names translate; the user's own stay as typed.@@ -74,7 +85,18 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { let ui = &mut rui; // Workspace switcher: shows the current workspace, opens Window → Workspace. 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 });+ if t.flat {+ // Classic OG: a boxed dropdown, as PhotoCraft's workspace switcher.+ ui.painter().rect(+ wr,+ CornerRadius::same(4),+ if wresp.hovered() { t.hover } else { t.input },+ Stroke::new(1.0, t.input_border),+ egui::StrokeKind::Inside,+ );+ } else {+ 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"));@@ -95,7 +117,10 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(10.0); crate::community::discord_button(app, ui, false); }- if app.services.native_menu.is_some() {+ if t.flat && !system {+ // Classic OG: PhotoCraft's title, in the room between the menus and the controls.+ bar_title(app, ui, &t, full, menus_end, ui.min_rect().left());+ } else if app.services.native_menu.is_some() { let title = ui.painter().layout_no_wrap("VectorCraft".into(), egui::FontId::proportional(13.5), t.text); let title_rect = egui::Rect::from_center_size(full.center(), title.size()); // Keep the title centered only while the interactive groups leave room for it.@@ -110,6 +135,33 @@ pub fn app_bar(app: &mut VectorcraftApp, ui: &mut Ui) { } } +/// Room the bar's title keeps from the menus and the controls (PhotoCraft's).+const TITLE_GAP: f32 = 16.0;++/// The bar's title with Classic OG, as in PhotoCraft: the active document's name (`*` before it with+/// unsaved changes, as on its tab) or VectorCraft. It is centred on the window, slid along when that+/// would bring it near the menus (which end at `from`) or the controls (which start at `to`), and+/// shortened next to the menus when the room between them is too small for all of it; it is left+/// out when only a sliver is free.+fn bar_title(app: &VectorcraftApp, ui: &Ui, t: &Tokens, full: egui::Rect, from: f32, to: f32) {+ let title = app.session.active().map(|d| format!("{}{}", if d.is_dirty() { "*" } else { "" }, d.title())).unwrap_or_else(|| "VectorCraft".into());+ let font = theme::semibold(13.0);+ let galley = ui.painter().layout_no_wrap(title.clone(), font.clone(), t.text_dim);+ let y = full.center().y - galley.size().y / 2.0;+ let (lo, hi) = (from + TITLE_GAP, to - TITLE_GAP - galley.size().x);+ // `hi >= lo` is false when either is NaN, so the clamp below has a valid range.+ if hi >= lo {+ // The window's centre: the bar's own rect starts after its left margin.+ let x = (ui.ctx().content_rect().center().x - galley.size().x / 2.0).clamp(lo, hi);+ ui.painter().galley(egui::pos2(x, y), galley, t.text_dim);+ } else if to - from - 2.0 * TITLE_GAP > 80.0 {+ let mut job = egui::text::LayoutJob::simple_singleline(title, font, t.text_dim);+ job.wrap = egui::text::TextWrapping::truncate_at_width(to - from - 2.0 * TITLE_GAP);+ let g = ui.painter().layout_job(job);+ ui.painter().galley(egui::pos2(lo, y), g, t.text_dim);+ }+}+ /// 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@@ -12,8 +12,9 @@ const DISCORD: Color32 = Color32::from_rgb(0x58, 0x65, 0xF2); /// The Discord button's label, height and width. fn discord_layout(ui: &Ui, large: bool) -> (std::sync::Arc<egui::Galley>, f32, f32) {- let (h, font, label) =- if large { (36.0, theme::semibold(14.0), tl!("Join our Discord")) } else { (24.0, egui::FontId::proportional(12.0), "Discord") };+ // Classic OG: VectorCraft 0.11's semibold label in the app bar.+ let bar_font = if Tokens::get(ui.ctx()).flat { theme::semibold(12.0) } else { egui::FontId::proportional(12.0) };+ let (h, font, label) = if large { (36.0, theme::semibold(14.0), tl!("Join our Discord")) } else { (24.0, bar_font, "Discord") }; let galley = ui.painter().layout_no_wrap(label.to_string(), font, Color32::PLACEHOLDER); let w = galley.size().x + h * 0.55 + h * 0.9; (galley, h, w)@@ -24,14 +25,15 @@ pub fn discord_width(ui: &Ui, large: bool) -> f32 { discord_layout(ui, large).2 } -/// The Discord button: compact and quiet for the app bar, large for About / Home.+/// The Discord button: compact and quiet for the app bar, large for About / Home. Classic OG keeps+/// VectorCraft 0.11's Discord-coloured pill in the app bar too. pub fn discord_button(app: &mut VectorcraftApp, ui: &mut Ui, large: bool) -> egui::Response { let (galley, h, w) = discord_layout(ui, large); let icon = h * 0.55; let (r, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click()); let at = egui::pos2(0.0, r.center().y - galley.size().y / 2.0); let ir = egui::Rect::from_center_size(r.left_center() + vec2(h * 0.35 + icon / 2.0, 0.0), vec2(icon, icon));- if large {+ if large || Tokens::get(ui.ctx()).flat { let fill = if resp.hovered() { DISCORD.gamma_multiply(0.85) } else { DISCORD }; ui.painter().rect_filled(r, CornerRadius::same((h / 2.0) as u8), fill); icons::paint(ui, "message-circle", ir, Color32::WHITE);@@ -95,6 +95,10 @@ pub fn set_collapsed(app: &mut VectorcraftApp, collapsed: bool) { pub(crate) fn collapse_header(ui: &mut Ui, collapsed: bool) -> bool { let t = Tokens::get(ui.ctx()); let (hdr, resp) = ui.allocate_exact_size(vec2(ui.available_width(), HEADER), Sense::click());+ if t.flat {+ // Classic OG: VectorCraft 0.11's strip, in the tab strips' grey.+ ui.painter().rect_filled(hdr, 0.0, t.tab_strip);+ } let arrow = Rect::from_min_size(hdr.right_top() + vec2(-16.0, 1.0), vec2(12.0, 12.0)); if resp.hovered() { ui.painter().rect_filled(arrow, CornerRadius::same(2), t.hover);@@ -175,7 +179,8 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { if collapsed && collapse_header(ui, true) { toggle(app, false); }- egui::Frame::NONE.inner_margin(egui::Margin::symmetric(1, 4)).show(ui, |ui| {+ let margin = if t.flat { egui::Margin::symmetric(4, 6) } else { egui::Margin::symmetric(1, 4) };+ egui::Frame::NONE.inner_margin(margin).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 2.0; // The icons scroll in a window too short for them. widgets::strip_scroll(ui, "icon_column", |ui| {@@ -226,7 +231,13 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { fn tab_strip(app: &mut VectorcraftApp, ui: &mut Ui, active: DockTab) -> bool { let t = Tokens::get(ui.ctx()); let (strip, _) = ui.allocate_exact_size(vec2(ui.available_width(), 33.0), Sense::hover());- ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.divider));+ if t.flat {+ // Classic OG: VectorCraft 0.11's tab strip, the shown tab in the panel's grey on it.+ ui.painter().rect_filled(strip, 0.0, t.tab_strip);+ ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.border));+ } else {+ ui.painter().line_segment([strip.left_bottom(), strip.right_bottom()], Stroke::new(1.0, t.divider));+ } let outside = ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| !strip.contains(p)); // What a drag out of the strip floats: the panels, the one shown, where its tab began. let mut tear: Option<(Vec<&'static str>, &'static str, f32)> = None;@@ -315,11 +326,16 @@ pub(crate) fn panel_tabs( .sense(Sense::click_and_drag()) .focus_stroke(Stroke::new(1.0, t.accent)) .show_at(ui, rect, |ui, _| {- if i == selected {+ if i == selected && t.flat {+ // Classic OG: VectorCraft 0.11's tabs, the shown one in the panel's grey.+ ui.painter().rect_filled(rect, 0.0, t.panel);+ } else if i == selected { ui.painter().rect_filled(rect.shrink2(vec2(3.0, 5.0)), 6.0, t.button); } ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())).galley(rect.center() - galley.size() / 2.0, galley, t.text);- let _ = separators;+ if separators && t.flat {+ ui.painter().line_segment([rect.right_top(), rect.right_bottom()], Stroke::new(1.0, t.border));+ } }); out.responses.push((i, if shrunk { resp.on_hover_text(*label) } else { resp })); out.right = rect.right();@@ -5159,6 +5159,7 @@ theme Appearance Външен вид # User Interface › Color Scheme Color Scheme Цветова схема Modern Модерна+ Classic OG Класически OG # Symbol Options › Export Type Export Type: Тип на експортиране:@@ -5186,6 +5186,7 @@ mark-style Japanese Japonské # User Interface › Color Scheme Color Scheme Barevné schéma Modern Moderní+ Classic OG Klasické OG # Symbol Options › Export Type Export Type: Typ exportu:@@ -4947,6 +4947,7 @@ mark-style Japanese Japansk Violet Violet Color Scheme Farveskema Modern Moderne+ Classic OG Klassisk OG @msg `{key}` must be an array of these names: {_1} `{key}` skal være et array af disse navne: {_1} Mode Tilstand Blur Mode Slørtilstand@@ -4765,6 +4765,7 @@ effect Soft Weich # User Interface › Color Scheme Color Scheme Farbschema Modern Modern+ Classic OG Klassisch OG # Symbol Options › Export Type Export Type: Exporttyp:@@ -5138,6 +5138,7 @@ effect Points Σημεία # User Interface › Color Scheme Color Scheme Χρωματικός συνδυασμός Modern Σύγχρονο+ Classic OG Κλασικό OG # Symbol Options › Export Type Export Type: Τύπος εξαγωγής:@@ -5128,6 +5128,7 @@ mark-style Japanese Japonesas # User Interface › Color Scheme Color Scheme Esquema de color Modern Moderno+ Classic OG Clásico OG # Symbol Options › Export Type Export Type: Tipo de exportación:@@ -5150,6 +5150,7 @@ mark-style Japanese Japanilainen # User Interface › Color Scheme Color Scheme Väriteema Modern Moderni+ Classic OG Perinteinen OG # Symbol Options › Export Type Export Type: Vientityyppi:@@ -5185,6 +5185,7 @@ theme Appearance Apparence # User Interface › Color Scheme Color Scheme Jeu de couleurs Modern Moderne+ Classic OG Classique OG # Symbol Options › Export Type Export Type: Type d'exportation :@@ -5172,6 +5172,7 @@ imageTrace Outline Körvonal # User Interface › Color Scheme Color Scheme Színséma Modern Modern+ Classic OG Klasszikus OG # Symbol Options › Export Type Export Type: Exportálás típusa:@@ -4947,6 +4947,7 @@ mark-style Japanese Jepang Violet Violet Color Scheme Skema warna Modern Modern+ Classic OG Klasik OG @msg `{key}` must be an array of these names: {_1} `{key}` harus berupa array dari nama-nama ini: {_1} Mode Mode Blur Mode Mode blur@@ -4799,6 +4799,7 @@ lighting Light Luce # User Interface › Color Scheme Color Scheme Combinazione colori Modern Moderno+ Classic OG Classico OG # Symbol Options › Export Type Export Type: Tipo di esportazione:@@ -4839,6 +4839,7 @@ lighting Light ライト # User Interface › Color Scheme Color Scheme 配色 Modern モダン+ Classic OG クラシック OG # Symbol Options › Export Type Export Type: 書き出しタイプ:@@ -4917,6 +4917,7 @@ imageTrace Outline კონტური Dot წერტილი Color Scheme ფერის სქემა Modern თანამედროვე+ Classic OG კლასიკური OG Export Type: ექსპორტის ტიპი: Movie Clip ანიმაციური კლიპი @msg name is empty სახელი ცარიელია@@ -4762,6 +4762,7 @@ lighting Light 광원 # User Interface › Color Scheme Color Scheme 색 구성표 Modern 모던+ Classic OG 클래식 OG # Symbol Options › Export Type Export Type: 내보내기 유형:@@ -4959,6 +4959,7 @@ mark-style Japanese Japans Violet Violet Color Scheme Kleurschema Modern Modern+ Classic OG Klassiek OG @msg `{key}` must be an array of these names: {_1} `{key}` moet een matrix van deze namen zijn: {_1} Mode Modus Blur Mode Vervagingsmodus@@ -5038,6 +5038,7 @@ mark-style Japanese Japońskie # User Interface › Color Scheme Color Scheme Schemat kolorów Modern Nowoczesny+ Classic OG Klasyczne OG # Symbol Options › Export Type Export Type: Typ eksportu:@@ -5105,6 +5105,7 @@ mark-style Japanese Japonesas # User Interface › Color Scheme Color Scheme Esquema de cores Modern Moderno+ Classic OG Clássico OG # Symbol Options › Export Type @msg name is empty o nome está vazio@@ -4795,6 +4795,7 @@ lighting Light Свет # User Interface › Color Scheme Color Scheme Цветовая схема Modern Современная+ Classic OG Классический OG # Symbol Options › Export Type Export Type: Тип экспорта:@@ -5142,6 +5142,7 @@ theme Appearance Utseende # User Interface › Color Scheme Color Scheme Färgschema Modern Modern+ Classic OG Klassisk OG # Symbol Options › Export Type Export Type: Exporttyp:@@ -4779,6 +4779,7 @@ lighting Light Світло # User Interface › Color Scheme Color Scheme Колірна схема Modern Сучасна+ Classic OG Класичний OG # Symbol Options › Export Type Export Type: Тип експорту:@@ -4779,6 +4779,7 @@ theme Appearance Giao diện # User Interface › Color Scheme Color Scheme Bảng phối màu Modern Hiện đại+ Classic OG Cổ điển OG # Symbol Options › Export Type Export Type: Kiểu xuất:@@ -5019,6 +5019,7 @@ lighting Light 光源 # User Interface › Color Scheme Color Scheme 配色方案 Modern 现代+ Classic OG 经典 OG # Symbol Options › Export Type Export Type: 导出类型:@@ -4829,6 +4829,7 @@ lighting Light 光源 # User Interface › Color Scheme Color Scheme 配色方案 Modern 現代+ Classic OG 經典 OG # Symbol Options › Export Type Export Type: 轉存類型:@@ -299,6 +299,10 @@ pub struct Services { /// default one would never come back once the canvas hid it: the canvas keeps the system /// pointer visible next to the tool glyph ([`cursors::hide_system_pointer`], #2450). pub no_cursor_theme: bool,+ /// The desktop's caption buttons (Linux: GNOME's, KDE's or plain round ones, in the desktop's+ /// button layout), which the custom title bar draws with the Classic OG colour scheme+ /// ([`titlebar`]). Without it, Windows' buttons.+ pub caption_style: Option<titlebar::CaptionStyle>, } /// Cached canvas raster.@@ -680,11 +680,19 @@ pub(crate) fn show(app: &mut VectorcraftApp, ui: &mut egui::Ui) -> bool { } let mut style = craft_ui::docking::DockStyle::from_ui(ui); style.tab_height = 33.0;- // The dock is a card (`theme::card`): its panes and tab strip lie on the card's colour- // (square fills would cover its rounded corners). Floating groups get the fills below.- style.background = egui::Color32::TRANSPARENT;- style.tab_background = egui::Color32::TRANSPARENT;- style.active_background = t.button;+ if t.flat {+ // Classic OG: VectorCraft 0.11's dock, the shown tab in the panel's grey on a darker+ // tab strip.+ style.background = t.panel;+ style.tab_background = t.tab_strip;+ style.active_background = t.panel;+ } else {+ // The dock is a card (`theme::card`): its panes and tab strip lie on the card's colour+ // (square fills would cover its rounded corners). Floating groups get the fills below.+ style.background = egui::Color32::TRANSPARENT;+ style.tab_background = egui::Color32::TRANSPARENT;+ style.active_background = t.button;+ } style.text = t.text_strong; style.inactive_text = t.text_dim; style.border = egui::Stroke::new(1.0, t.border);@@ -85,22 +85,26 @@ impl Accent { } } -/// User Interface › Color Scheme: Modern (the default: slate greys and the chosen accent) or-/// Classic (the neutral greys and blue accent of earlier versions). Both keep the same layout,-/// cards and corners; only the colours differ.+/// User Interface › Color Scheme: Modern (the default: slate greys and the chosen accent), Classic+/// (the neutral greys and blue accent of earlier versions) or Classic OG (VectorCraft 0.11's look,+/// with the desktop's window buttons as in PhotoCraft). Modern and Classic keep the same layout,+/// cards and corners; only the colours differ. Classic OG also brings back 0.11's chrome+/// ([`Tokens::flat`]). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Scheme { #[default] Modern, Classic,+ ClassicOg, } impl Scheme {- pub const ALL: [Scheme; 2] = [Scheme::Modern, Scheme::Classic];+ pub const ALL: [Scheme; 3] = [Scheme::Modern, Scheme::Classic, Scheme::ClassicOg]; pub fn id(self) -> &'static str { match self { Scheme::Modern => "modern", Scheme::Classic => "classic",+ Scheme::ClassicOg => "classicOg", } } pub fn parse(s: &str) -> Option<Self> {@@ -164,6 +168,12 @@ pub struct Tokens { pub axis_x: Color32, pub axis_y: Color32, pub axis_z: Color32,+ /// VectorCraft 0.11's chrome (Classic OG): flat panels edged in the `border` colour instead of+ /// [`card`]s, panel tabs on a `tab_strip` with the shown tab in the panel colour, pill buttons+ /// ([`crate::widgets::primary_button`]), Source Sans 3 for the interface, and, as in PhotoCraft,+ /// caption buttons that follow the desktop ([`crate::titlebar`]), a boxed workspace switcher and+ /// a centred title ([`crate::chrome::app_bar`]).+ pub flat: bool, } impl Tokens {@@ -214,6 +224,7 @@ impl Tokens { axis_x: hex(0xef6058), axis_y: hex(0x45af63), axis_z: hex(0x378ef0),+ flat: false, }; match b { Brightness::Dark => base,@@ -418,14 +429,40 @@ impl Tokens { } } - /// The tokens for `b` in `scheme`; Modern takes `accent`, Classic has its own blue.+ /// The Classic OG colour scheme at brightness `b`: VectorCraft 0.11's look, from before the+ /// cards and soft corners came. Classic's greys and blue (which are 0.11's), with the title bar the+ /// panels' grey at Medium Dark as it was then, 0.11's small corners and Source Sans 3 for the+ /// interface; [`Self::flat`] brings back its flat panels, tab strips and pill buttons, and the+ /// caption buttons follow the desktop as PhotoCraft's do.+ pub fn classic_og(b: Brightness) -> Self {+ let hex = |s: u32| Color32::from_rgb((s >> 16) as u8, (s >> 8) as u8, s as u8);+ let classic = Self::classic(b);+ Tokens {+ flat: true,+ radius: 2,+ // Classic sets the frame a shade below the panels at Medium Dark so its cards stand out;+ // without cards the title bar is the panels' grey again.+ app_bar: if b == Brightness::MediumDark { hex(0x535353) } else { classic.app_bar },+ ..classic+ }+ }++ /// The tokens for `b` in `scheme`; Modern takes `accent`, Classic and Classic OG have their own+ /// blue. pub fn for_scheme(scheme: Scheme, b: Brightness, accent: Accent) -> Self { match scheme { Scheme::Modern => Self::for_brightness(b).with_accent(accent), Scheme::Classic => Self::classic(b),+ Scheme::ClassicOg => Self::classic_og(b), } } + /// The corners of a dialog button `height` points tall: a pill (Classic OG, as in 0.11 and+ /// PhotoCraft) or the usual soft corners (`radius`).+ pub fn button_radius(&self, height: f32, radius: u8) -> CornerRadius {+ if self.flat { CornerRadius::same((height / 2.0).clamp(0.0, f32::from(u8::MAX)) as u8) } else { CornerRadius::same(radius) }+ }+ /// These tokens with `accent`: the accent, buttons filled with it, the active item in flyouts and /// selected rows (the panel tinted towards it). pub fn with_accent(self, accent: Accent) -> Self {@@ -455,8 +492,20 @@ impl Tokens { } /// The toolbar, the panel dock and the icon rail are cards: `body` drawn on the panel colour with-/// soft corners, `margin` of the window's frame colour around it.+/// soft corners, `margin` of the window's frame colour around it. With [`Tokens::flat`] (Classic+/// OG) they are VectorCraft 0.11's flat panels instead: square, edged with the frame colour and no+/// margin. pub fn card<R>(ui: &mut egui::Ui, t: &Tokens, margin: egui::Margin, body: impl FnOnce(&mut egui::Ui) -> R) -> R {+ if t.flat {+ return egui::Frame::NONE+ .fill(t.panel)+ .stroke(Stroke::new(1.5, t.border))+ .show(ui, |ui| {+ ui.set_min_size(ui.available_size());+ body(ui)+ })+ .inner;+ } egui::Frame::NONE .fill(t.app_bar) .inner_margin(margin)@@ -495,6 +544,19 @@ pub fn mono(size: f32) -> FontId { FontId::new(size, FontFamily::Name(FONT_MONO.into())) } +/// Where [`apply_with`] notes whether the interface is in Source Sans 3 (Classic OG, as VectorCraft+/// 0.11 was) rather than Inter.+fn classic_face_id() -> egui::Id {+ egui::Id::new("vc-ui-face-classic")+}++/// Whether [`install_fonts`] puts Source Sans 3 first (Classic OG).+pub(crate) fn classic_face(ctx: &egui::Context) -> bool {+ ctx.data(|d| d.get_temp::<bool>(classic_face_id())).unwrap_or(false)+}++/// The app's fonts: Inter for the interface (Source Sans 3 with Classic OG, [`classic_face`]),+/// JetBrains Mono for numbers, the craft-fonts faces as fallbacks. pub fn install_fonts(ctx: &egui::Context) { let mut fonts = FontDefinitions::default(); let add = |f: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {@@ -510,13 +572,18 @@ pub fn install_fonts(ctx: &egui::Context) { add(&mut fonts, "SourceSans3-Semibold", include_bytes!("../../../assets/fonts/SourceSans3-Semibold.ttf")); add(&mut fonts, "JetBrainsMono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf")); let fallback: Vec<String> = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default();- let mut prop = vec!["Inter".to_string(), "SourceSans3".to_string()];+ let (regular, semibold) = if classic_face(ctx) {+ (["SourceSans3", "Inter"], ["SourceSans3-Semibold", "Inter-SemiBold"])+ } else {+ (["Inter", "SourceSans3"], ["Inter-SemiBold", "SourceSans3-Semibold"])+ };+ let mut prop: Vec<String> = regular.map(str::to_string).to_vec(); prop.extend(fallback.clone()); fonts.families.insert(FontFamily::Proportional, prop);- let mut semi = vec!["Inter-SemiBold".to_string(), "SourceSans3-Semibold".to_string()];+ let mut semi: Vec<String> = semibold.map(str::to_string).to_vec(); semi.extend(fallback.clone()); fonts.families.insert(FontFamily::Name(FONT_UI_SEMIBOLD.into()), semi);- let mut ui = vec!["Inter".to_string(), "SourceSans3".to_string()];+ let mut ui: Vec<String> = regular.map(str::to_string).to_vec(); ui.extend(fallback.clone()); fonts.families.insert(FontFamily::Name(FONT_UI.into()), ui); let mut mono = vec!["JetBrainsMono".to_string()];@@ -555,6 +622,11 @@ pub fn apply(ctx: &egui::Context, b: Brightness) { pub fn apply_with(ctx: &egui::Context, b: Brightness, accent: Accent, scheme: Scheme) { let t = Tokens::for_scheme(scheme, b, accent); ctx.data_mut(|d| d.insert_temp(egui::Id::NULL, t));+ // Classic OG sets the interface in Source Sans 3, as VectorCraft 0.11 did; the others in Inter.+ if classic_face(ctx) != t.flat {+ ctx.data_mut(|d| d.insert_temp(classic_face_id(), t.flat));+ install_fonts(ctx);+ } let mut v = if t.dark { Visuals::dark() } else { Visuals::light() }; v.panel_fill = t.panel; v.window_fill = t.panel;@@ -579,6 +651,11 @@ pub fn apply_with(ctx: &egui::Context, b: Brightness, accent: Accent, scheme: Sc w.inactive.bg_stroke = Stroke::NONE; w.hovered.bg_stroke = Stroke::new(1.0, t.input_border); w.active.bg_stroke = Stroke::new(1.0, t.accent);+ if t.flat {+ // VectorCraft 0.11's windows and menus.+ v.window_corner_radius = CornerRadius::same(6);+ v.menu_corner_radius = CornerRadius::same(6);+ } ctx.set_visuals(v); ctx.global_style_mut(|s| { s.spacing.item_spacing = egui::vec2(6.0, 5.0);@@ -635,6 +712,84 @@ mod tests { assert_ne!(classic.app_bar, classic.panel, "{b:?}: the cards need a frame"); assert_eq!(classic.accent, Color32::from_rgb(0x37, 0x8e, 0xf0)); assert_eq!(Scheme::parse(Scheme::Classic.id()), Some(Scheme::Classic));+ assert!(!classic.flat && !modern.flat, "{b:?}: only Classic OG is flat"); } }++ /// Classic OG is VectorCraft 0.11's look: Classic's greys and blue whatever accent is chosen,+ /// the title bar the panels' grey at Medium Dark, 0.11's small corners, on flat chrome with pill+ /// buttons.+ #[test]+ fn classic_og_is_0_11s_look() {+ assert_eq!(Scheme::parse("classicOg"), Some(Scheme::ClassicOg));+ assert_eq!(Scheme::parse("CLASSICOG"), Some(Scheme::ClassicOg));+ let hex = |s: u32| Color32::from_rgb((s >> 16) as u8, (s >> 8) as u8, s as u8);+ // 0.11's Medium Dark, the default brightness: one grey for the title bar and the panels.+ let medium = Tokens::for_scheme(Scheme::ClassicOg, Brightness::MediumDark, Accent::Green);+ assert_eq!((medium.app_bar, medium.panel, medium.tab_strip, medium.border), (hex(0x535353), hex(0x535353), hex(0x424242), hex(0x383838)));+ // 0.11's Dark: a darker title bar over #323232 panels.+ let dark = Tokens::for_scheme(Scheme::ClassicOg, Brightness::Dark, Accent::Pink);+ assert_eq!((dark.app_bar, dark.panel), (hex(0x262626), hex(0x323232)));+ for b in Brightness::ALL {+ let (og, classic) = (Tokens::for_scheme(Scheme::ClassicOg, b, Accent::Violet), Tokens::classic(b));+ assert!(og.flat, "{b:?}");+ assert_eq!((og.radius, og.dark), (2, classic.dark), "{b:?}");+ assert_eq!((og.panel, og.text, og.accent, og.tab_strip), (classic.panel, classic.text, classic.accent, classic.tab_strip), "{b:?}");+ assert_ne!(og.tab_strip, og.panel, "{b:?}: the shown tab stands out of its strip");+ assert_eq!(og.button_radius(28.0, 6), CornerRadius::same(14), "a pill");+ assert_eq!(Tokens::for_brightness(b).button_radius(28.0, 6), CornerRadius::same(6));+ }+ }++ /// Classic OG's cards are 0.11's flat panels: square, edged with the frame colour, no margin.+ #[test]+ fn flat_cards_are_square_panels() {+ let flat = Tokens::classic_og(Brightness::MediumDark);+ let ctx = egui::Context::default();+ let mut rects = vec![];+ let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {+ card(ui, &flat, egui::Margin::same(6), |ui| {+ ui.label("x");+ });+ });+ out.textures_delta.clear();+ fn walk(s: &egui::Shape, v: &mut Vec<egui::epaint::RectShape>) {+ match s {+ egui::Shape::Rect(r) => v.push(r.clone()),+ egui::Shape::Vec(s) => s.iter().for_each(|s| walk(s, v)),+ _ => {}+ }+ }+ out.shapes.iter().for_each(|c| walk(&c.shape, &mut rects));+ let panel = rects.iter().find(|r| r.fill == flat.panel).expect("the panel's fill");+ assert_eq!((panel.corner_radius, panel.stroke.color), (CornerRadius::ZERO, flat.border));+ assert_eq!(panel.rect.min, egui::Pos2::ZERO, "no margin");+ assert!(!rects.iter().any(|r| r.fill == flat.app_bar && r.rect != panel.rect), "no frame around it: {rects:?}");+ }++ /// Classic OG sets the interface in Source Sans 3, as 0.11 did, and the other schemes in Inter.+ #[test]+ fn classic_og_switches_the_interface_font() {+ let ctx = egui::Context::default();+ install_fonts(&ctx);+ apply(&ctx, Brightness::MediumDark);+ let first = |ctx: &egui::Context| {+ let mut first = String::new();+ let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {+ first =+ ui.ctx().fonts(|f| f.definitions().families.get(&FontFamily::Proportional).and_then(|v| v.first().cloned()).unwrap_or_default());+ });+ out.textures_delta.clear();+ first+ };+ // set_fonts lands with the next pass.+ let _ = first(&ctx);+ assert_eq!(first(&ctx), "Inter");+ apply_with(&ctx, Brightness::MediumDark, Accent::default(), Scheme::ClassicOg);+ let _ = first(&ctx);+ assert_eq!(first(&ctx), "SourceSans3");+ apply_with(&ctx, Brightness::MediumDark, Accent::default(), Scheme::Classic);+ let _ = first(&ctx);+ assert_eq!(first(&ctx), "Inter");+ } }@@ -3,8 +3,14 @@ //! double-click there maximizes it, caption buttons at the far right minimize, maximize/restore and //! close, and thin invisible zones along the window's edges resize it. On macOS the app bar sits //! under the system's transparent title bar and only its [`drag_area`] is used.+//!+//! The caption buttons are Windows' 46 pt buttons. With the Classic OG colour scheme they follow the+//! desktop instead, as PhotoCraft's do ([`CaptionStyle`], from [`crate::Services::caption_style`]):+//! on Linux libadwaita's round buttons (GNOME), Breeze's (KDE, SteamOS) or a plain round set+//! elsewhere, with the set of buttons and their side taken from the desktop's button layout. The+//! shapes are drawn here. -use egui::{CursorIcon, Id, LayerId, Order, PointerButton, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2};+use egui::{Color32, CursorIcon, Id, LayerId, Order, PointerButton, Pos2, Rect, ResizeDirection, Sense, Stroke, Ui, ViewportCommand, pos2, vec2}; use serde_json::json; use crate::VectorcraftApp;@@ -12,22 +18,160 @@ use crate::theme::Tokens; /// One caption button: as wide as the native Windows ones. pub const BUTTON_WIDTH: f32 = 46.0;-/// The three caption buttons together.+/// The three Windows caption buttons together. pub const WIDTH: f32 = 3.0 * BUTTON_WIDTH; /// Resize zone thickness along an edge, and the side of a corner zone. const EDGE: f32 = 5.0; const CORNER: f32 = 12.0;+/// Breeze's Close button on hover and press (KDE's Breeze decoration colours).+const BREEZE_CLOSE_HOVER: Color32 = Color32::from_rgb(0xff, 0x98, 0xa2);+const BREEZE_CLOSE_PRESSED: Color32 = Color32::from_rgb(0x6d, 0x22, 0x29); -#[derive(Clone, Copy, PartialEq, Debug)]-enum Caption {+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub enum Caption { Minimize, Maximize, Close, } -/// Left to right.+/// Windows' order, left to right. const CAPTIONS: [Caption; 3] = [Caption::Minimize, Caption::Maximize, Caption::Close]; +/// Which desktop's caption buttons to look like (Classic OG).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum CaptionFamily {+ /// Windows 10/11: full-height buttons 46 pt wide; Close turns red on hover.+ #[default]+ Windows,+ /// GNOME (libadwaita): 24 pt round buttons on a faint disc that darkens on hover and press.+ Adwaita,+ /// KDE (Breeze decoration): smaller round buttons, bare at rest, inverted on hover.+ Breeze,+ /// Other Linux desktops: libadwaita's buttons without the disc at rest.+ Generic,+}++impl CaptionFamily {+ /// `windows`, `adwaita`, `breeze` or `generic`, in any case.+ pub fn parse(name: &str) -> Option<Self> {+ match name.trim().to_ascii_lowercase().as_str() {+ "windows" => Some(Self::Windows),+ "adwaita" => Some(Self::Adwaita),+ "breeze" => Some(Self::Breeze),+ "generic" => Some(Self::Generic),+ _ => None,+ }+ }++ /// The button side, gap on each side of it, and gap between the outermost button and the+ /// window's edge. libadwaita: 24 pt buttons 13 pt apart, 10 pt from the edge (the gap is split+ /// between neighbours, so the hit areas touch). Breeze sizes buttons by the font (about 1.3 em,+ /// 18 pt at KDE's default 10 pt font), half a button apart, 8 pt from the edge.+ fn metrics(self) -> (f32, f32, f32) {+ match self {+ Self::Windows => (BUTTON_WIDTH, 0.0, 0.0),+ Self::Adwaita | Self::Generic => (24.0, 6.5, 10.0),+ Self::Breeze => (18.0, 4.5, 8.0),+ }+ }+}++/// The caption buttons' look and placement.+#[derive(Clone, PartialEq, Debug)]+pub struct CaptionStyle {+ pub family: CaptionFamily,+ /// The buttons at the bar's left end, left to right.+ pub left: Vec<Caption>,+ /// The buttons at the bar's right end, left to right.+ pub right: Vec<Caption>,+}++impl Default for CaptionStyle {+ fn default() -> Self {+ Self { family: CaptionFamily::Windows, left: vec![], right: CAPTIONS.to_vec() }+ }+}++impl CaptionStyle {+ /// `family` with the buttons a GTK decoration layout names (`gtk-decoration-layout`, GNOME's+ /// `button-layout`, e.g. `appmenu:minimize,maximize,close`): those before the colon go left,+ /// those after it right; without a colon all go left, as in GTK. Names other than `minimize`,+ /// `maximize` and `close` (`appmenu`, `icon`, `spacer`, …) are skipped, and a button named+ /// twice is placed once.+ pub fn from_gtk_layout(family: CaptionFamily, layout: &str) -> Self {+ let (left, right) = layout.split_once(':').unwrap_or((layout, ""));+ let mut seen = vec![];+ let mut side = |names: &str| {+ let mut v = vec![];+ for name in names.split(',') {+ let c = match name.trim() {+ "minimize" => Caption::Minimize,+ "maximize" => Caption::Maximize,+ "close" => Caption::Close,+ _ => continue,+ };+ if !seen.contains(&c) {+ seen.push(c);+ v.push(c);+ }+ }+ v+ };+ let left = side(left);+ let right = side(right);+ Self { family, left, right }+ }+}++/// The caption style in use: the desktop's (as the host reports it) with Classic OG, Windows'+/// otherwise and when the host reports none.+fn style(app: &VectorcraftApp, ctx: &egui::Context) -> CaptionStyle {+ if Tokens::get(ctx).flat { app.services.caption_style.clone().unwrap_or_default() } else { CaptionStyle::default() }+}++/// The width the caption buttons take at the bar's (left, right) end: zero for a side without+/// buttons, and for both without the custom title bar.+pub fn reserved(app: &VectorcraftApp, ctx: &egui::Context) -> (f32, f32) {+ if !app.custom_titlebar {+ return (0.0, 0.0);+ }+ let s = style(app, ctx);+ let width = |side: &[Caption]| slots(s.family, side, 0.0, true).last().map_or(0.0, |(_, r, _)| r.1);+ (width(&s.left), width(&s.right))+}++/// Each button's horizontal extent (left, right) and face centre on a side starting at `from`, in+/// order. `outer_left` puts the edge gap on the first button's left (the left end) instead of the+/// last button's right.+fn slots(family: CaptionFamily, side: &[Caption], from: f32, outer_left: bool) -> Vec<(Caption, (f32, f32), f32)> {+ let (size, gap, end) = family.metrics();+ let mut x = from;+ let n = side.len();+ side.iter()+ .enumerate()+ .map(|(i, &c)| {+ let before = if outer_left && i == 0 { end } else { gap };+ let after = if !outer_left && i + 1 == n { end } else { gap };+ let slot = (x, x + before + size + after);+ let centre = x + before + size / 2.0;+ x = slot.1;+ (c, slot, centre)+ })+ .collect()+}++/// Every caption button's hit rect (full bar height down to its bottom border, flush with the+/// window's edge) and face centre, for a bar `bar` between the window's `left` and `right` edges.+fn layout(s: &CaptionStyle, bar: Rect, left: f32, right: f32) -> Vec<(Caption, Rect, Pos2)> {+ let rect = |(c, (l, r), centre): (Caption, (f32, f32), f32)| {+ (c, Rect::from_min_max(pos2(l, bar.top()), pos2(r, bar.bottom() - 1.0)), pos2(centre, bar.center().y))+ };+ let lefts = slots(s.family, &s.left, left, true);+ let width = slots(s.family, &s.right, 0.0, false).last().map_or(0.0, |(_, r, _)| r.1);+ let rights = slots(s.family, &s.right, right - width, false);+ lefts.into_iter().chain(rights).map(rect).collect()+}+ /// Caption button ids (stable, so tests and agents can find them). fn caption_id(c: Caption) -> Id { Id::new(("titlebar-caption", c as u8))@@ -69,33 +213,47 @@ fn handed_to_os(ctx: &egui::Context) { ctx.input_mut(|i| i.pointer = Default::default()); } -/// Minimize, Maximize/Restore and Close at the right end of `bar` (the app bar's outer rect), full-/// bar height down to its bottom border, flush with the window's edge so a maximized window's-/// corner hits Close. Close runs `app.quit`, which asks about unsaved documents first.+/// Minimize, Maximize/Restore and Close at the ends of `bar` (the app bar's outer rect), full bar+/// height down to its bottom border, flush with the window's edges so a maximized window's corner+/// hits the outermost one: Windows' buttons at the right end, or with Classic OG the desktop's+/// ([`style`]). Close runs `app.quit`, which asks about unsaved documents first. The desktop's round+/// buttons drag the window too, as GTK's and KDE's do. pub fn caption_buttons(app: &mut VectorcraftApp, ui: &mut Ui, bar: Rect) { let t = Tokens::get(ui.ctx());+ let s = style(app, ui.ctx()); let max = maximized(ui.ctx());+ let content = ui.ctx().content_rect();+ let windows = s.family == CaptionFamily::Windows; 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() - 1.0));- let resp = ui.interact(r, caption_id(c), Sense::click());- let close = c == Caption::Close;- let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {- (true, _) if close => t.caption_close.gamma_multiply(0.8),- (_, true) if close => t.caption_close,- (true, _) => t.tool_active,- (_, true) => t.hover,- _ => egui::Color32::TRANSPARENT,- };- ui.painter().rect_filled(r, 0.0, fill);- let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon };- paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));+ for (c, r, centre) in layout(&s, bar, bar.left().max(content.left()), bar.right().min(content.right())) {+ let resp = ui.interact(r, caption_id(c), if windows { Sense::click() } else { Sense::click_and_drag() });+ if resp.drag_started_by(PointerButton::Primary) {+ ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);+ handed_to_os(ui.ctx());+ }+ if windows {+ let close = c == Caption::Close;+ let fill = match (resp.is_pointer_button_down_on(), resp.hovered()) {+ (true, _) if close => t.caption_close.gamma_multiply(0.8),+ (_, true) if close => t.caption_close,+ (true, _) => t.tool_active,+ (_, true) => t.hover,+ _ => Color32::TRANSPARENT,+ };+ ui.painter().rect_filled(r, 0.0, fill);+ let ink = if close && resp.hovered() { t.caption_close_text } else { t.icon };+ paint_glyph(ui, c, max, r.center(), Stroke::new(1.0, ink));+ } else {+ let pressed = resp.is_pointer_button_down_on() && resp.hovered();+ paint_round(ui, &t, s.family, c, max, centre, resp.hovered(), pressed);+ } let tip = match c { Caption::Minimize => tl!("Minimize"), Caption::Maximize if max => tl!("Restore"), Caption::Maximize => tl!("Maximize"), Caption::Close => tl!("Close"), };+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tip)); if resp.on_hover_text(tip).clicked() { clicked = Some(c); }@@ -110,7 +268,7 @@ pub fn caption_buttons(app: &mut VectorcraftApp, ui: &mut Ui, bar: Rect) { } } -/// The 10 pt line glyphs of the caption buttons.+/// The 10 pt line glyphs of the Windows caption buttons. fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) { let p = ui.painter(); let g = Rect::from_center_size(at, vec2(10.0, 10.0));@@ -134,6 +292,84 @@ fn paint_glyph(ui: &Ui, c: Caption, maximized: bool, at: egui::Pos2, s: Stroke) } } +/// A round Linux caption button centred at `centre`: its disc, then its glyph on a 16 pt grid. The+/// disc is the icon colour at a fraction of its opacity (libadwaita's `alpha(currentColor)`), so it+/// suits light and dark themes alike; Breeze instead fills it with the icon colour on hover and+/// draws the glyph in the bar's colour. The same shapes as PhotoCraft's.+#[allow(clippy::too_many_arguments)]+fn paint_round(ui: &Ui, t: &Tokens, family: CaptionFamily, c: Caption, maximized: bool, centre: Pos2, hovered: bool, pressed: bool) {+ let (size, _, _) = family.metrics();+ // Whole pixels, so the glyphs' edges stay sharp.+ let ppp = ui.ctx().pixels_per_point();+ let centre = pos2((centre.x * ppp).round() / ppp, (centre.y * ppp).round() / ppp);+ let close = c == Caption::Close;+ let (fill, ink) = if family == CaptionFamily::Breeze {+ match (hovered, pressed) {+ (_, true) if close => (BREEZE_CLOSE_PRESSED, t.app_bar),+ (true, _) if close => (BREEZE_CLOSE_HOVER, t.app_bar),+ (_, true) => (t.icon.gamma_multiply(0.5), t.app_bar),+ (true, _) => (t.icon, t.app_bar),+ _ => (Color32::TRANSPARENT, t.icon),+ }+ } else {+ let rest = if family == CaptionFamily::Adwaita { 0.10 } else { 0.0 };+ let target = if pressed {+ 0.30+ } else if hovered {+ 0.15+ } else {+ rest+ };+ // libadwaita eases the disc over 0.2 s.+ let a = ui.ctx().animate_value_with_time(caption_id(c).with("disc"), target, 0.2);+ (t.icon.gamma_multiply(a), t.icon)+ };+ let p = ui.painter();+ if fill.a() > 0 {+ p.circle_filled(centre, size / 2.0, fill);+ }+ // The glyph grid: 16 pt, or 2 pt less than a smaller button.+ let k = (size - 2.0).min(16.0) / 16.0;+ let at = |x: f32, y: f32| centre + vec2(x - 8.0, y - 8.0) * k;+ if family == CaptionFamily::Breeze {+ // Breeze: 1 pt chevrons (down to minimize, up to maximize), a diamond to restore, an X.+ let s = Stroke::new(1.0, ink);+ match c {+ Caption::Minimize => {+ p.line(vec![at(2.5, 5.5), at(8.0, 11.0), at(13.5, 5.5)], s);+ }+ Caption::Maximize if maximized => {+ p.add(egui::Shape::closed_line(vec![at(8.0, 3.0), at(13.0, 8.0), at(8.0, 13.0), at(3.0, 8.0)], s));+ }+ Caption::Maximize => {+ p.line(vec![at(2.5, 10.5), at(8.0, 5.0), at(13.5, 10.5)], s);+ }+ Caption::Close => {+ let s = Stroke::new(1.2, ink);+ p.line_segment([at(3.6, 3.6), at(12.4, 12.4)], s);+ p.line_segment([at(12.4, 3.6), at(3.6, 12.4)], s);+ }+ }+ } else {+ // libadwaita: a 2 pt bar low down, a 2 pt square outline (smaller to restore), an X.+ let frame = |a: f32, b: f32| Rect::from_min_max(at(a, a), at(b, b));+ match c {+ Caption::Minimize => {+ p.rect_filled(Rect::from_min_max(at(4.0, 10.0), at(12.0, 12.0)), 0.0, ink);+ }+ Caption::Maximize => {+ let square = if maximized { frame(5.0, 11.0) } else { frame(4.0, 12.0) };+ p.rect_stroke(square, 0.0, Stroke::new(2.0 * k, ink), egui::StrokeKind::Inside);+ }+ Caption::Close => {+ let s = Stroke::new(1.5 * k, ink);+ p.line_segment([at(4.5, 4.5), at(11.5, 11.5)], s);+ p.line_segment([at(11.5, 4.5), at(4.5, 11.5)], s);+ }+ }+ }+}+ /// The edge and corner zones of a window with content rect `w`: (zone, direction, cursor). Corners /// come last so they win where they overlap the edges. fn zones(w: Rect) -> [(Rect, ResizeDirection, CursorIcon); 8] {@@ -419,4 +655,127 @@ mod tests { cmds.extend(w.click(moved)); assert!(cmds.is_empty(), "{cmds:?}"); }++ /// A [`Win`] in the Classic OG colour scheme on a desktop whose caption buttons are `style`.+ fn classic_og(width: f32, style: CaptionStyle) -> Win {+ let mut w = Win::new(width);+ crate::theme::apply_with(&w.ctx, Default::default(), Default::default(), crate::theme::Scheme::ClassicOg);+ w.app.services.caption_style = Some(style);+ w+ }++ /// Classic OG draws the desktop's caption buttons, as PhotoCraft does: round buttons where the+ /// desktop's layout puts them, flush with the window's edges, the bar's widgets clear of them;+ /// they click, and a drag that starts on one moves the window.+ #[test]+ fn classic_og_draws_the_desktops_buttons_where_its_layout_puts_them() {+ use Caption::*;+ for (family, size, gap, end) in [(CaptionFamily::Adwaita, 24.0, 6.5, 10.0), (CaptionFamily::Breeze, 18.0, 4.5, 8.0)] {+ let width = 1100.0;+ let mut w = classic_og(width, CaptionStyle { family, left: vec![Close], right: vec![Minimize, Maximize] });+ w.frame(vec![]);+ w.frame(vec![]);+ let [min, max, close] = CAPTIONS.map(|c| w.caption(c));+ assert_eq!((close.left(), close.width()), (0.0, end + size + gap), "{family:?}");+ assert_eq!((max.right(), max.width()), (width, gap + size + end), "{family:?}");+ assert_eq!((min.right(), min.width()), (max.left(), 2.0 * gap + size), "{family:?}");+ assert_eq!(reserved(&w.app, &w.ctx), (end + size + gap, 2.0 * gap + size + gap + size + end), "{family:?}");+ for a in w.bar_widgets() {+ assert!(a.left() >= close.right() && a.right() <= min.left(), "{a:?} runs into the caption buttons ({family:?})");+ }+ assert!(w.click(max.center()).contains(&ViewportCommand::Maximized(true)));+ assert!(w.click(min.center()).contains(&ViewportCommand::Minimized(true)));+ // A drag that starts on a button moves the window instead of pressing it.+ let at = min.center();+ let cmds = w.gesture(&[at, at + vec2(12.0, 4.0), at + vec2(30.0, 8.0)]);+ assert_eq!(cmds.iter().filter(|c| matches!(c, ViewportCommand::StartDrag)).count(), 1, "{cmds:?}");+ assert!(!cmds.contains(&ViewportCommand::Minimized(true)), "{cmds:?}");+ // Close quits, asking first about unsaved work.+ w.click(close.center());+ assert_eq!(w.app.ui.status, "quit", "{family:?}");+ }+ }++ /// KDE's default layout (SteamOS's desktop mode): Breeze's three buttons at the right end, and+ /// the bar's controls stop before them.+ #[test]+ fn classic_og_on_kde_puts_breezes_buttons_at_the_right() {+ let style = CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, ":minimize,maximize,close");+ let mut w = classic_og(1440.0, style);+ w.frame(vec![]);+ w.frame(vec![]);+ let [min, max, close] = CAPTIONS.map(|c| w.caption(c));+ assert_eq!(close.right(), 1440.0);+ assert!(min.right() <= max.left() && max.right() <= close.left());+ assert!([min, max, close].iter().all(|r| r.width() < BUTTON_WIDTH), "round buttons, not Windows' 46 pt ones");+ for a in w.bar_widgets() {+ assert!(a.right() <= min.left() - 8.0, "{a:?} runs into the caption buttons");+ }+ }++ /// The other colour schemes keep Windows' buttons whatever the desktop.+ #[test]+ fn other_schemes_keep_windows_buttons() {+ let mut w = Win::new(1100.0);+ w.app.services.caption_style = Some(CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, "close:minimize"));+ w.frame(vec![]);+ w.frame(vec![]);+ let [min, max, close] = CAPTIONS.map(|c| w.caption(c));+ assert!([min, max, close].iter().all(|r| r.width() == BUTTON_WIDTH));+ assert_eq!(close.right(), 1100.0);+ assert_eq!(reserved(&w.app, &w.ctx), (0.0, WIDTH));+ // Without the custom title bar no side is reserved.+ w.app.custom_titlebar = false;+ assert_eq!(reserved(&w.app, &w.ctx), (0.0, 0.0));+ }++ /// Classic OG's bar shows the title in the room between the menus and the controls, as+ /// PhotoCraft's does: centred on the window, slid along near the menus, shortened, or left out,+ /// and never over them.+ #[test]+ fn classic_og_centres_the_title() {+ let mut slid = false;+ for width in (700..=1440).step_by(20) {+ let width = width as f32;+ let mut w = classic_og(width, CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, ":minimize,maximize,close"));+ w.frame(vec![]);+ let out = w.frame(vec![]);+ // The title, whole or shortened ("Vect…"); never over the menus or the controls.+ let titles: Vec<(String, Rect)> = texts(&out)+ .into_iter()+ .filter(|(t, _)| {+ let start = t.trim_end_matches('…');+ !start.is_empty() && "VectorCraft".starts_with(start)+ })+ .collect();+ for (t, r) in &titles {+ for a in w.bar_widgets() {+ assert!(!a.intersects(r.shrink(0.5)), "{width}: {t} over {a:?}");+ }+ }+ let whole = titles.iter().find(|(t, _)| t == "VectorCraft").map(|(_, r)| *r);+ if width == 1440.0 {+ let title = whole.expect("the whole title");+ assert!((title.center().x - 720.0).abs() < 1.0, "centred on the window: {title:?}");+ }+ // Where centring would bring it near the menus, PhotoCraft's title slides along whole.+ slid |= whole.is_some_and(|r| (r.center().x - width / 2.0).abs() > 1.0);+ }+ assert!(slid, "some width slides the title along instead of shortening it");+ }++ #[test]+ fn gtk_layouts_pick_the_buttons_and_their_sides() {+ use Caption::*;+ let s = CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "appmenu:minimize,maximize,close");+ assert_eq!((s.left, s.right), (vec![], vec![Minimize, Maximize, Close]));+ // Unknown names are skipped, spaces trimmed, a repeat placed once, a second colon is a name.+ let s = CaptionStyle::from_gtk_layout(CaptionFamily::Adwaita, "close,close , minimize:icon,maximize:close");+ assert_eq!((s.left, s.right), (vec![Close, Minimize], vec![]));+ let s = CaptionStyle::from_gtk_layout(CaptionFamily::Breeze, "minimize,close");+ assert_eq!((s.left, s.right), (vec![Minimize, Close], vec![]), "no colon: GTK puts them all at the start");+ assert_eq!(CaptionFamily::parse(" Breeze "), Some(CaptionFamily::Breeze));+ assert_eq!(CaptionFamily::parse("aqua"), None);+ assert_eq!(CaptionStyle::default(), CaptionStyle { family: CaptionFamily::Windows, left: vec![], right: CAPTIONS.to_vec() });+ } }@@ -105,6 +105,14 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let edge = ui.available_rect_before_wrap(); ui.ctx().data_mut(|d| d.insert_temp(crate::floating::tools_zone_id(), egui::Rect::from_min_size(edge.min, vec2(WIDTH, edge.height())))); match app.ui.toolbar_pos {+ None if t.flat => {+ // Classic OG: VectorCraft 0.11's flat toolbar, edged like the panels.+ let frame = egui::Frame::NONE+ .fill(t.panel)+ .inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 })+ .stroke(Stroke::new(1.5, t.border));+ egui::Panel::left("toolbar").resizable(false).exact_size(w).frame(frame).show(ui, |ui| body(app, ui, cols, None));+ } None => { let margin = egui::Margin { left: 6, right: 0, top: 6, bottom: 6 }; egui::Panel::left("toolbar").resizable(false).exact_size(w + 6.0).frame(egui::Frame::NONE).show(ui, |ui| {@@ -224,11 +232,13 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu let Some(shown) = tool_info(&shown_id).or_else(|| tool_info(slot[0])) else { continue }; let is_active = slot.contains(&active); let (rect, resp) = ui.allocate_exact_size(vec2(36.0, PITCH - 1.0), Sense::click_and_drag());- let well = egui::Rect::from_center_size(rect.center(), vec2(30.0, 27.5));+ // Classic OG: VectorCraft 0.11's wells, the slot's width with square corners.+ let (well_w, corners) = if t.flat { (35.5, 1) } else { (30.0, 6) };+ let well = egui::Rect::from_center_size(rect.center(), vec2(well_w, 27.5)); if is_active {- ui.painter().rect_filled(well, CornerRadius::same(6), t.tool_active);+ ui.painter().rect_filled(well, CornerRadius::same(corners), t.tool_active); } else if resp.hovered() {- ui.painter().rect_filled(well, CornerRadius::same(6), t.hover);+ ui.painter().rect_filled(well, CornerRadius::same(corners), t.hover); } let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0)); icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon });@@ -697,10 +707,11 @@ fn tool_strip(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String]) -> Option for tool in tools.iter().filter_map(|id| tool_info(id)) { let (r, resp) = ui.allocate_exact_size(STRIP_BUTTON, Sense::click()); let well = r.shrink(1.0);+ let corners = CornerRadius::same(if t.flat { 1 } else { 6 }); if active == tool.id {- ui.painter().rect_filled(well, CornerRadius::same(6), t.tool_active);+ ui.painter().rect_filled(well, corners, t.tool_active); } else if resp.hovered() {- ui.painter().rect_filled(well, CornerRadius::same(6), t.hover);+ ui.painter().rect_filled(well, corners, t.hover); } let ir = egui::Rect::from_center_size(r.center(), vec2(18.0, 18.0)); icons::paint(ui, icons::tool_icon(tool.icon), ir, if active == tool.id { t.text_strong } else { t.icon });@@ -32,6 +32,12 @@ impl UiFonts { /// After a frame is laid out: add the fallback fonts found since the last frame, and look for /// fonts covering the characters this frame painted that the UI fonts lack. pub(crate) fn frame(&mut self, ctx: &egui::Context) {+ // A change to or from the Classic OG colour scheme installs the UI fonts again+ // (`theme::apply_with`), without the fallbacks added here: look for them again.+ if !self.added.is_empty() && ctx.fonts(|f| self.added.iter().any(|name| !f.definitions().font_data.contains_key(name))) {+ self.added.clear();+ self.seen.clear();+ } #[cfg(not(target_arch = "wasm32"))] if let Some(faces) = self.pending.as_ref().and_then(|rx| rx.try_recv().ok()) { self.pending = None;@@ -25,25 +25,42 @@ pub fn paint_icon_button(ui: &mut Ui, resp: Response, icon: &str, tip: &str, sel } else { Color32::TRANSPARENT };- ui.painter().rect_filled(rect, CornerRadius::same(5), bg);+ // Classic OG: VectorCraft 0.11's smaller corners and lettering.+ ui.painter().rect_filled(rect, CornerRadius::same(if t.flat { 3 } else { 5 }), bg); let pad = (size * 0.2).round();- icons::paint(ui, icon, rect.shrink(pad), if selected { t.text_strong } else { t.icon });+ let selected_ink = if t.flat { t.text } else { t.text_strong };+ icons::paint(ui, icon, rect.shrink(pad), if selected { selected_ink } else { t.icon }); resp.widget_info(|| icon_button_info(ui.is_enabled(), selected, tl!(tip))); if !tip.is_empty() { resp.on_hover_text(tl!(tip)) } else { resp } } -/// Small flat text button (Quick Actions style).+/// Small flat text button (Quick Actions style). Classic OG draws VectorCraft 0.11's outlined one. pub fn flat_button(ui: &mut Ui, text: &str, width: f32) -> Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::click()); let rect = rect.shrink2(vec2(0.0, 0.5));- ui.painter().rect_filled(rect, CornerRadius::same(5), t.button);- flat_fill(ui, rect, &resp);+ if t.flat {+ flat_fill(ui, rect, &resp);+ outline(ui, &t, rect, resp.hovered());+ } else {+ ui.painter().rect_filled(rect, CornerRadius::same(5), t.button);+ flat_fill(ui, rect, &resp);+ } ui.painter().with_clip_rect(rect).text(rect.center(), egui::Align2::CENTER_CENTER, tl!(text), egui::FontId::proportional(12.5), t.text_strong); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); resp } +/// The outline of VectorCraft 0.11's flat buttons (Classic OG): brighter under the pointer.+fn outline(ui: &Ui, t: &Tokens, rect: Rect, hovered: bool) {+ ui.painter().rect_stroke(rect, flat_corners(t), Stroke::new(1.0, if hovered { t.text } else { t.button_border }), StrokeKind::Inside);+}++/// The corners of flat buttons and their fills: 0.11's 2 pt with Classic OG.+fn flat_corners(t: &Tokens) -> CornerRadius {+ CornerRadius::same(if t.flat { 2 } else { 5 })+}+ /// Width of a [`split_button`]'s arrow. pub const SPLIT_ARROW: f32 = 18.0; @@ -64,10 +81,18 @@ pub fn split_button_named(ui: &mut Ui, text: &str, arrow_tip: &str, width: f32) let arrow_rect = Rect::from_min_max(pos2(split, rect.top()), rect.max); let main = ui.interact(main_rect, ui.id().with((text, "split")), Sense::click()); let arrow = ui.interact(arrow_rect, ui.id().with((text, "split-arrow")), Sense::click());- ui.painter().rect_filled(rect, CornerRadius::same(5), t.button);- flat_fill(ui, main_rect, &main);- flat_fill(ui, arrow_rect, &arrow);- ui.painter().vline(split, rect.y_range().shrink(5.0), Stroke::new(1.0, t.panel));+ if t.flat {+ // VectorCraft 0.11's outlined button, its arrow set off by a line in the outline's colour.+ flat_fill(ui, main_rect, &main);+ flat_fill(ui, arrow_rect, &arrow);+ outline(ui, &t, rect, main.hovered() || arrow.hovered());+ ui.painter().vline(split, rect.y_range().shrink(4.0), Stroke::new(1.0, t.button_border));+ } else {+ ui.painter().rect_filled(rect, CornerRadius::same(5), t.button);+ flat_fill(ui, main_rect, &main);+ flat_fill(ui, arrow_rect, &arrow);+ ui.painter().vline(split, rect.y_range().shrink(5.0), Stroke::new(1.0, t.panel));+ } ui.painter().with_clip_rect(main_rect).text( main_rect.center(), egui::Align2::CENTER_CENTER,@@ -87,10 +112,11 @@ pub fn split_button_named(ui: &mut Ui, text: &str, arrow_tip: &str, width: f32) /// The pressed or hovered fill of a flat button part. fn flat_fill(ui: &Ui, rect: Rect, resp: &Response) { let t = Tokens::get(ui.ctx());+ let r = flat_corners(&t); if resp.is_pointer_button_down_on() {- ui.painter().rect_filled(rect, CornerRadius::same(5), t.tool_active);+ ui.painter().rect_filled(rect, r, t.tool_active); } else if resp.hovered() {- ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover);+ ui.painter().rect_filled(rect, r, t.hover); } } @@ -117,21 +143,22 @@ fn primary_button_sized(ui: &mut Ui, text: &str, enabled: bool, min_width: f32) let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), fg); let w = galley.size().x + 32.0; let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), if enabled { Sense::click() } else { Sense::hover() });+ let r = t.button_radius(rect.height(), 6); let bg = match (enabled, resp.hovered()) { (false, _) => t.input, (true, true) => t.accent, (true, false) => t.accent_strong, };- ui.painter().rect_filled(rect, CornerRadius::same(6), bg);+ ui.painter().rect_filled(rect, r, bg); if !enabled {- ui.painter().rect_stroke(rect, CornerRadius::same(6), egui::Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside);+ ui.painter().rect_stroke(rect, r, egui::Stroke::new(1.0, t.button_border), egui::StrokeKind::Inside); } ui.painter().galley(rect.center() - galley.size() / 2.0, galley, fg); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled && ui.is_enabled(), tl!(text))); resp } -/// The quiet secondary button (dialog Cancel).+/// The quiet secondary button (dialog Cancel). Classic OG draws it as an outlined pill. pub fn secondary_button(ui: &mut Ui, text: &str) -> Response { secondary_button_width(ui, text, 72.0) }@@ -142,7 +169,16 @@ pub(crate) fn secondary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> let galley = ui.painter().layout_no_wrap(tl!(text).to_string(), theme::semibold(12.5), t.text); let w = galley.size().x + 32.0; let (rect, resp) = ui.allocate_exact_size(vec2(w.max(min_width), 28.0), Sense::click());- ui.painter().rect_filled(rect, CornerRadius::same(6), if resp.hovered() { t.hover } else { t.button });+ if t.flat {+ // VectorCraft 0.11's outlined pill.+ let r = t.button_radius(rect.height(), 6);+ if resp.hovered() {+ ui.painter().rect_filled(rect, r, t.hover);+ }+ ui.painter().rect_stroke(rect, r, Stroke::new(1.5, t.text_dim), StrokeKind::Inside);+ } else {+ ui.painter().rect_filled(rect, CornerRadius::same(6), if resp.hovered() { t.hover } else { t.button });+ } ui.painter().galley(rect.center() - galley.size() / 2.0, galley, t.text); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), tl!(text))); resp@@ -152,7 +188,9 @@ pub(crate) fn secondary_button_width(ui: &mut Ui, text: &str, min_width: f32) -> pub fn section_header(ui: &mut Ui, text: &str) { let t = Tokens::get(ui.ctx()); ui.add_space(2.0);- ui.label(egui::RichText::new(tl!(text)).size(12.5).color(t.text_strong));+ // Classic OG: VectorCraft 0.11's headers.+ let (size, color) = if t.flat { (13.0, t.text) } else { (12.5, t.text_strong) };+ ui.label(egui::RichText::new(tl!(text)).size(size).color(color)); ui.add_space(2.0); } @@ -573,7 +611,8 @@ fn recessed_text(ui: &mut Ui, id: egui::Id, shown: &str, width: f32, rows: usize .allocate_ui_with_layout(vec2(width, height), layout, |ui| { ui.set_min_size(vec2(width, height)); let rect = ui.max_rect();- ui.painter().rect(rect, 5.0, t.input, Stroke::new(1.0, if editing { t.accent } else { t.input_border }), StrokeKind::Inside);+ let corners = if t.flat { 2.0 } else { 5.0 };+ ui.painter().rect(rect, corners, t.input, Stroke::new(1.0, if editing { t.accent } else { t.input_border }), StrokeKind::Inside); let margin = egui::Margin { left: 7, right: 7, top: if rows > 1 { 4 } else { 0 }, bottom: 0 }; let resp = egui::Frame::NONE .inner_margin(margin)@@ -1881,7 +1920,7 @@ pub fn hex_field(ui: &mut Ui, id: impl std::hash::Hash + std::fmt::Debug, hex: & let resp = egui::Frame::NONE .fill(t.input) .stroke(Stroke::new(1.0, if editing { t.accent } else { t.input_border }))- .corner_radius(5)+ .corner_radius(if t.flat { 2 } else { 5 }) .inner_margin(egui::Margin::symmetric(6, 4)) .show(ui, |ui| { // As tall as the other fields (26 points with the margin), whatever the font's line height.@@ -278,7 +278,12 @@ not commands, so drive them with `ui.click` if needed. Close runs `app.quit` (th 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.+drops its brand mark. On macOS the window buttons are centred on the app bar; the web build is unchanged. With the+Classic OG colour scheme (`prefs.set {key: "uiColorScheme", value: "classicOg"}`) the caption buttons look like the+desktop's, as PhotoCraft's do: Linux draws GNOME's (libadwaita), KDE's (Breeze, also SteamOS) or plain round buttons, in+the desktop's button layout (either end of the bar), and `VECTORCRAFT_CAPTION_STYLE` (`windows`, `adwaita`, `breeze`,+`generic`) and `VECTORCRAFT_CAPTION_LAYOUT` (a GTK layout such as `close,minimize:`) override what is found. The bar+then also centres the active document's name (or VectorCraft) between the menus and the controls. 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);