diff --git a/CHANGELOG.md b/CHANGELOG.md index 10960ac..17207cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to LowQuery are documented here. The format follows [Keep a ## [Unreleased] +### Added +- A title bar in the LowBloat style on Windows and Linux, with File, Query, View and Help menus and themed window buttons. macOS keeps its traffic lights and gets the same menus in the menu bar. Alt or F10 moves to the menus; Help → Check for updates reports when LowQuery is up to date ([#20](https://github.com/LowBloat/LowQuery/issues/20)). + +### Fixed +- The window no longer flashes white or jumps in size on startup: it stays hidden until the interface has painted ([#20](https://github.com/LowBloat/LowQuery/issues/20)). + ## [0.5.2] - 2026-10-23 ### Added diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 34502f2..610fbc9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -25,6 +25,7 @@ LowQuery is a Tauri 2 desktop app. The interface is Svelte 5 with the [CodeAtlas | File | Responsibility | | --- | --- | | `lib.rs` | Tauri commands, session registry, password resolution | +| `window.rs` | The main window starts hidden and is shown once the page has painted (`window_painted`), with a five-second fallback; `tauri.macos.conf.json` keeps the native traffic lights | | `profiles.rs` | `connections.json` in the user config folder; passwords in the keychain (`keyring`) | | `model.rs` | Serializable types shared with the interface | | `error.rs` | `AppError` with kind, native code, position, detail and hint | @@ -106,6 +107,7 @@ LowQuery is a Tauri 2 desktop app. The interface is Svelte 5 with the [CodeAtlas | `lib/state/importer.svelte.ts` | Import dialog: detected tool files, preview, duplicate check | | `lib/state/tns.svelte.ts` | Detected `tnsnames.ora` sources and aliases for the connection and import dialogs | | `lib/components/` | Editor, grid, results, sidebar, dialogs | +| `lib/app-menu.ts`, `lib/components/TitleBar.svelte`, `lib/state/window-frame.svelte.ts` | Title bar and File/Query/View/Help menus (in the title bar on Windows and Linux, in the menu bar on macOS) | | `lib/dev-mock.ts` | Development-only backend for `bun run dev` in a browser | ## Object DDL and tree actions diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index db0e26a..242d38f 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,10 +7,15 @@ ], "permissions": [ "core:default", + "core:window:allow-minimize", + "core:window:allow-toggle-maximize", + "core:window:allow-close", + "core:window:allow-start-dragging", "dialog:allow-save", "dialog:allow-open", "updater:default", "process:allow-restart", + "process:allow-exit", "opener:default" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f83b89a..deb4068 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod model; mod profiles; mod transfer; mod updates; +mod window; use std::collections::HashMap; use std::sync::Arc; @@ -435,6 +436,11 @@ pub fn run() { .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_opener::init()) .manage(update_policy) + .manage(window::FirstShow::default()) + .setup(|app| { + window::start_fallback(app.handle()); + Ok(()) + }) .manage(AppState { profiles, sessions: RwLock::new(HashMap::new()), @@ -473,6 +479,7 @@ pub fn run() { ai_activity, ai_activity_clear, updates::update_policy, + window::window_painted, ]) .run(tauri::generate_context!()) .expect("error while starting LowQuery"); diff --git a/src-tauri/src/window.rs b/src-tauri/src/window.rs new file mode 100644 index 0000000..b35d391 --- /dev/null +++ b/src-tauri/src/window.rs @@ -0,0 +1,47 @@ +//! The main window starts hidden (`visible: false` in tauri.conf.json), so the +//! webview's white background never shows while the page loads. The page +//! shows it once it has painted. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use tauri::{AppHandle, Manager, Runtime}; + +#[derive(Default)] +pub struct FirstShow(AtomicBool); + +/// How long the window waits for the page before it is shown anyway, so a +/// page that fails to load never leaves an invisible app. +const FIRST_SHOW_FALLBACK: Duration = Duration::from_secs(5); + +fn show_first_time(app: &AppHandle) { + if app.state::().0.swap(true, Ordering::SeqCst) { + return; + } + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } +} + +/// Shows the window after `FIRST_SHOW_FALLBACK` if the page has not. +pub fn start_fallback(app: &AppHandle) { + let app = app.clone(); + std::thread::spawn(move || { + std::thread::sleep(FIRST_SHOW_FALLBACK); + show_first_time(&app); + }); +} + +/// Called by the page once it has painted and whenever the theme changes: the +/// native window takes the page background (`#rrggbb`), so resizing never +/// uncovers white, and appears the first time. +#[tauri::command] +pub fn window_painted(background: String, app: AppHandle) { + if let Some(window) = app.get_webview_window("main") { + if let Ok(color) = background.parse::() { + let _ = window.set_background_color(Some(color)); + } + } + show_first_time(&app); +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 852698a..208c582 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -17,7 +17,9 @@ "height": 800, "minWidth": 720, "minHeight": 480, - "maximized": true + "maximized": true, + "visible": false, + "decorations": false } ], "security": { diff --git a/src-tauri/tauri.macos.conf.json b/src-tauri/tauri.macos.conf.json new file mode 100644 index 0000000..948c615 --- /dev/null +++ b/src-tauri/tauri.macos.conf.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "app": { + "windows": [ + { + "title": "LowQuery", + "width": 1280, + "height": 800, + "minWidth": 720, + "minHeight": 480, + "maximized": true, + "visible": false, + "decorations": true, + "titleBarStyle": "Overlay", + "hiddenTitle": true + } + ] + } +} diff --git a/src/App.svelte b/src/App.svelte index f814f17..329e415 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -1,6 +1,16 @@ + + + +
+ {#if !IS_MAC} + + + {/if} + + +
+ {title} + {#if detail}— {detail}{/if} +
+ +
+ + {#if !IS_MAC} +
+ + + +
+ {/if} +
+ + diff --git a/src/lib/state/window-frame.svelte.ts b/src/lib/state/window-frame.svelte.ts new file mode 100644 index 0000000..a09fb1f --- /dev/null +++ b/src/lib/state/window-frame.svelte.ts @@ -0,0 +1,72 @@ +import { invoke } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; +import type { UnlistenFn } from "@tauri-apps/api/event"; + +// Outside the desktop app (the browser dev mock) there is no window to drive. +function current() { + try { + return getCurrentWindow(); + } catch { + return null; + } +} + +/** The page background as `#rrggbb`, whatever color syntax the theme uses. */ +function backgroundHex(): string { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 1; + const context = canvas.getContext("2d"); + if (!context) return ""; + context.fillStyle = getComputedStyle(document.body).backgroundColor; + context.fillRect(0, 0, 1, 1); + const [r, g, b] = context.getImageData(0, 0, 1, 1).data; + return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`; +} + +/** The window frame drawn by the title bar: maximized and focus state, and the + * actions behind its buttons. */ +class WindowFrame { + maximized = $state(false); + focused = $state(true); + private unlisteners: UnlistenFn[] = []; + + /** The window starts hidden so the webview's white background never shows; + * this gives it the page background and shows it the first time. Theme + * changes call it again. */ + painted() { + const background = backgroundHex(); + if (background && "__TAURI_INTERNALS__" in window) void invoke("window_painted", { background }).catch(() => {}); + } + + async start() { + const win = current(); + if (!win) return; + try { + this.unlisteners.push( + await win.onResized(() => void this.refresh()), + await win.onFocusChanged(({ payload }) => (this.focused = payload)), + ); + } catch { + // Without events the buttons still work; only the icons may lag. + } + await this.refresh(); + } + + stop() { + this.unlisteners.splice(0).forEach((unlisten) => unlisten()); + } + + private async refresh() { + try { + this.maximized = (await current()?.isMaximized()) ?? false; + } catch { + // Keep the last known state. + } + } + + minimize = () => void current()?.minimize().catch(() => {}); + toggleMaximize = () => void current()?.toggleMaximize().then(() => this.refresh(), () => {}); + close = () => void current()?.close().catch(() => {}); +} + +export const windowFrame = new WindowFrame();