User Interface › System Title Bar: native decorations on Windows/Linux, file in OS title

This commit is contained in:
rafi612 authored and bflatastic committed 2026-10-10 21:01:38 +07:00
1 parent 9f659195c3
commit 12f8a77a00
17 files changed
+243 -21

No files matched your search

+50 -17
View File
@@ -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,27 @@ fn main() -> std::process::ExitCode {
mod tests {
use super::*;
fn ui_state_with_prefs(prefs: serde_json::Value) -> vectorcraft_ui_egui::UiState {
let mut ui = vectorcraft_ui_egui::UiState::default();
ui.engine_prefs = prefs;
ui
}
#[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>"]
+1
View File
@@ -282,6 +282,7 @@ pub const PREF_SPECS: &[PrefSpec] = &[
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.
p!("interfaceLanguage", "User Interface", "Language", "Language", text),
p!("systemTitleBar", "User Interface", "", "System Title Bar", bool),
// Performance
p!("gpuPerformance", "Performance", "GPU Performance", "GPU Performance", bool),
p!("animatedZoom", "Performance", "GPU Performance", "Animated Zoom", bool),
+5
View File
@@ -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"),
+9
View File
@@ -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();
+115 -4
View File
@@ -18,9 +18,12 @@ pub const APP_BAR_HEIGHT: f32 = 44.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,10 +31,12 @@ 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(22.0, 22.0), Sense::hover());
crate::brand::paint_mark(ui, r);
ui.add_space(4.0);
}
let on_home = menus::home_showing(app);
if widgets::icon_button(ui, "house", tl!("Home"), on_home, 24.0).clicked() {
app.run("app.home", json!({})).ok();
@@ -109,6 +114,35 @@ 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));
}
}
/// 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 +999,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");
}
}
+2
View File
@@ -673,3 +673,5 @@
Patchwork… Patchwork…
Stained Glass… Vitráž…
Texturizer… Texturovač…
System Title Bar Systémové záhlaví okna
Applies at next launch. Projeví se při příštím spuštění.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3975,3 +3975,5 @@ 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
Applies at next launch. Se aplicará en el próximo inicio.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3987,3 +3987,5 @@ axis Both Les deux
Bottom Right En bas à droite
effect Soft Doux
Edge Bord
System Title Bar Barre de titre du système
Applies at next launch. Prend effet au prochain démarrage.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3977,3 +3977,5 @@ axis Both Entrambi
effect Soft Morbido
effect Regular Normale
Edge Bordo
System Title Bar Barra del titolo di sistema
Applies at next launch. Si applica al prossimo avvio.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3970,3 +3970,5 @@ effect Soft ソフト
effect Horizontal 水平
effect Vertical 垂直
Edge エッジ
System Title Bar システムのタイトルバー
Applies at next launch. 次回の起動時に適用されます。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -2733,3 +2733,5 @@
Patchwork… Colcha de retalhos…
Stained Glass… Vitral…
Texturizer… Texturizador…
System Title Bar Barra de título do sistema
Applies at next launch. Aplica-se na próxima inicialização.
Can't render this file because it contains an unexpected character in line 6 and column 47.
+2
View File
@@ -3973,3 +3973,5 @@ axis Both Оба
Top Right Сверху справа
Bottom Right Снизу справа
Edge Край
System Title Bar Системная строка заголовка
Applies at next launch. Применится при следующем запуске.
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3958,3 +3958,5 @@ axis Both Обидві
Top Right Вгорі праворуч
Bottom Right Знизу праворуч
Edge Край
System Title Bar Системний рядок заголовка
Applies at next launch. Набуде чинності після перезапуску.
Can't render this file because it contains an unexpected character in line 659 and column 40.
+2
View File
@@ -3606,3 +3606,5 @@ effect Soft 柔和
effect Horizontal 水平
effect Vertical 垂直
Edge 边缘
System Title Bar 系统标题栏
Applies at next launch. 下次启动时生效。
Can't render this file because it contains an unexpected character in line 2 and column 73.
+2
View File
@@ -3387,3 +3387,5 @@ axis Both 兩者
Bottom Right 右下
effect Soft 柔和
Edge 邊緣
System Title Bar 系統標題列
Applies at next launch. 下次啟動時套用。
Can't render this file because it contains an unexpected character in line 6 and column 47.
+7
View File
@@ -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();
+36
View File
@@ -261,6 +261,11 @@ 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));
}
PrefKind::Bool if sp.key == "systemTitleBar" && 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 at next launch.")).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 +535,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 at next launch."), "{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]