Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again. | 1 | //! The window's frame. g1t draws its own title bar, as Slack and Discord |
| 2 | //! do, so the window is all g1t and looks the same on every system: on | |
| 3 | //! Windows and Linux the system's frame goes and the bar has the menu | |
| 4 | //! button, back and forward, search, and minimize, maximize and close; on | |
| Merge branch 'main' of github.com:flagon-io/g1t | 5 | //! macOS the bar sits under the window's own traffic lights. |
| 6 | //! | |
| 7 | //! The main window holds two web views, one above the other: the bar, a | |
| 8 | //! page bundled with the app (`launcher/titlebar.html`), across the top, | |
| 9 | //! and the site under it. The site is never drawn under the bar, so no page | |
| 10 | //! of it, or of GitHub's while signing in, needs to know the bar is there. | |
| 11 | //! What the bar shows of the page (its title, its search, whether there is | |
| 12 | //! a way back, its colours) the page's bridge reports (`page`), and what | |
| 13 | //! the bar is asked to do comes back to the page through the app (`act`). | |
| 14 | //! | |
| 15 | //! Settings can give the window back its system frame, and the menu bar | |
| 16 | //! with it, from the next start; the site then has the window to itself. | |
| 17 | ||
| 18 | use std::sync::Mutex; | |
| The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again. | 19 | |
| Merge branch 'main' of github.com:flagon-io/g1t | 20 | use serde::{Deserialize, Serialize}; |
| 21 | use tauri::webview::WebviewBuilder; | |
| 22 | use tauri::window::{Color, WindowBuilder}; | |
| 23 | use tauri::{AppHandle, Emitter, EventTarget, LogicalPosition, LogicalSize, Manager, Runtime, Webview, WebviewUrl, WebviewWindowBuilder, Window}; | |
| The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again. | 24 | |
| 25 | use crate::{WINDOW, config, tray}; | |
| 26 | ||
| Merge branch 'main' of github.com:flagon-io/g1t | 27 | /// The bar's height, in CSS pixels. |
| The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again. | 28 | pub const HEIGHT: f64 = 36.0; |
| 29 | ||
| Merge branch 'main' of github.com:flagon-io/g1t | 30 | /// The site's web view, in the main window. |
| 31 | pub const SITE: &str = "site"; | |
| 32 | /// The bar's web view, across the top of the main window. | |
| 33 | pub const BAR: &str = "titlebar"; | |
| 34 | ||
| 35 | /// What the bar listens for: what to show (`Page`). | |
| 36 | pub const PAGE_EVENT: &str = "g1t-title-bar"; | |
| 37 | /// What the site listens for: the bar's search was clicked. | |
| 38 | pub const SEARCH_EVENT: &str = "g1t-search"; | |
| 39 | ||
| 40 | /// g1t's rail, dark, which the window is painted while a page loads. | |
| 41 | const RAIL: Color = Color(5, 5, 7, 255); | |
| 42 | ||
| The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again. | 43 | /// Whether g1t draws its own title bar, as the settings say now. |
| 44 | pub fn custom(app: &AppHandle) -> bool { | |
| 45 | !config::settings(app).system_title_bar | |
| 46 | } | |
| 47 | ||
| 48 | /// The title bar g1t started with, which its windows keep until it restarts. | |
| 49 | static STARTED_CUSTOM: std::sync::OnceLock<bool> = std::sync::OnceLock::new(); | |
| 50 | ||
| 51 | /// Whether this run draws g1t's own title bar: decided once, at the start. | |
| 52 | pub fn started(app: &AppHandle) -> bool { | |
| 53 | *STARTED_CUSTOM.get_or_init(|| custom(app)) | |
| 54 | } | |
| 55 | ||
| Merge branch 'main' of github.com:flagon-io/g1t | 56 | /// The main window, holding the bar and the site. |
| 57 | pub fn window(app: &AppHandle) -> Option<Window> { | |
| 58 | app.get_window(WINDOW) | |
| 59 | } | |
| 60 | ||
| 61 | /// The site's web view. | |
| 62 | pub fn site(app: &AppHandle) -> Option<Webview> { | |
| 63 | app.get_webview(SITE) | |
| 64 | } | |
| 65 | ||
| 66 | /// The main window, before its web views: g1t's frame, or the system's. | |
| 67 | pub fn main_window<'a, R: Runtime, M: Manager<R>>(app: &'a M, custom: bool) -> tauri::Result<WindowBuilder<'a, R, M>> { | |
| 68 | #[cfg(target_os = "macos")] | |
| 69 | let builder = if custom { | |
| 70 | // The traffic lights stay, over the bar and centred in it. | |
| 71 | let config = tauri::utils::config::WindowConfig { | |
| 72 | label: WINDOW.into(), | |
| 73 | traffic_light_position: Some(tauri::utils::config::LogicalPosition { x: 14.0, y: (HEIGHT - 14.0) / 2.0 }), | |
| 74 | ..Default::default() | |
| 75 | }; | |
| 76 | WindowBuilder::from_config(app, &config)?.title_bar_style(tauri::TitleBarStyle::Overlay).hidden_title(true) | |
| 77 | } else { | |
| 78 | WindowBuilder::new(app, WINDOW) | |
| 79 | }; | |
| 80 | #[cfg(not(target_os = "macos"))] | |
| 81 | let builder = WindowBuilder::new(app, WINDOW).decorations(!custom); | |
| 82 | Ok(builder.title("g1t").inner_size(1280.0, 840.0).min_inner_size(720.0, 480.0).visible(false)) | |
| 83 | } | |
| 84 | ||
| 85 | /// The bar's and the site's web views, in the main window, as tall as the window leaves them. | |
| 86 | pub fn fill(window: &Window, custom: bool, site: WebviewBuilder<tauri::Wry>) -> tauri::Result<()> { | |
| 87 | let size = logical_size(window); | |
| 88 | let top = if custom { HEIGHT } else { 0.0 }; | |
| 89 | if custom { | |
| 90 | window.add_child( | |
| 91 | WebviewBuilder::new(BAR, WebviewUrl::App("titlebar.html".into())).background_color(RAIL).on_navigation(config::is_launcher), | |
| 92 | LogicalPosition::new(0.0, 0.0), | |
| 93 | LogicalSize::new(size.width, HEIGHT), | |
| 94 | )?; | |
| 95 | } | |
| 96 | window.add_child(site.background_color(RAIL), LogicalPosition::new(0.0, top), LogicalSize::new(size.width, (size.height - top).max(0.0)))?; | |
| 97 | #[cfg(target_os = "linux")] | |
| 98 | if custom { | |
| 99 | pack_bar(window); | |
| 100 | } | |
| 101 | Ok(()) | |
| 102 | } | |
| 103 | ||
| 104 | /// On Linux the web views sit in a GTK box, one under the other, which | |
| 105 | /// shares the window between them equally whatever size they are given: | |
| 106 | /// the bar is told to keep its height, and the site takes the rest. | |
| 107 | #[cfg(target_os = "linux")] | |
| 108 | fn pack_bar(window: &Window) { | |
| 109 | use gtk::prelude::*; | |
| 110 | let Ok(vbox) = window.default_vbox() else { return }; | |
| 111 | // The bar is the first web view added; the menu bar, hidden, is not one. | |
| 112 | if let Some(bar) = vbox.children().into_iter().find(|child| child.type_().name() == "WebKitWebView") { | |
| 113 | vbox.set_child_packing(&bar, false, true, 0, gtk::PackType::Start); | |
| 114 | bar.set_size_request(-1, HEIGHT as i32); | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | fn logical_size(window: &Window) -> LogicalSize<f64> { | |
| 119 | let scale = window.scale_factor().unwrap_or(1.0); | |
| 120 | window.inner_size().map(|size| size.to_logical(scale)).unwrap_or(LogicalSize::new(1280.0, 840.0)) | |
| 121 | } | |
| 122 | ||
| 123 | /// The window changed size: the bar keeps its height, and the site takes the rest. | |
| 124 | pub fn layout(window: &Window) { | |
| 125 | let app = window.app_handle(); | |
| 126 | let size = logical_size(window); | |
| 127 | let bar = app.get_webview(BAR); | |
| 128 | let top = if bar.is_some() { HEIGHT } else { 0.0 }; | |
| 129 | if let Some(bar) = bar { | |
| 130 | let _ = bar.set_position(LogicalPosition::new(0.0, 0.0)); | |
| 131 | let _ = bar.set_size(LogicalSize::new(size.width, HEIGHT)); | |
| 132 | } | |
| 133 | if let Some(site) = site(app) { | |
| 134 | let _ = site.set_position(LogicalPosition::new(0.0, top)); | |
| 135 | let _ = site.set_size(LogicalSize::new(size.width, (size.height - top).max(0.0))); | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | /// The settings window: g1t's frame, or the system's. Its page draws its | |
| 140 | /// own bar (launcher/settings.html), being g1t's own. | |
| 141 | pub fn dialog<'a, R: Runtime, M: Manager<R>>(builder: WebviewWindowBuilder<'a, R, M>, custom: bool) -> WebviewWindowBuilder<'a, R, M> { | |
| The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again. | 142 | if !custom { |
| 143 | return builder; | |
| 144 | } | |
| 145 | #[cfg(target_os = "macos")] | |
| 146 | { | |
| 147 | builder | |
| 148 | .title_bar_style(tauri::TitleBarStyle::Overlay) | |
| 149 | .hidden_title(true) | |
| 150 | .traffic_light_position(LogicalPosition::new(14.0, (HEIGHT - 14.0) / 2.0)) | |
| 151 | } | |
| 152 | #[cfg(not(target_os = "macos"))] | |
| 153 | { | |
| 154 | builder.decorations(false) | |
| 155 | } | |
| 156 | } | |
| 157 | ||
| 158 | /// The main window's menu bar: shown under the system's frame, and kept | |
| 159 | /// hidden under g1t's, whose menu button opens the same menu (`menu`) and | |
| 160 | /// whose shortcuts still work. | |
| Merge branch 'main' of github.com:flagon-io/g1t | 161 | pub fn menu_bar(window: &Window, custom: bool) { |
| The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again. | 162 | let _ = if custom { window.hide_menu() } else { window.show_menu() }; |
| 163 | // GTK shows everything in a window each time the window is shown, the | |
| 164 | // hidden menu bar too, unless the menu bar is told to sit that out. | |
| 165 | #[cfg(target_os = "linux")] | |
| 166 | if let Ok(vbox) = window.default_vbox() { | |
| 167 | use gtk::prelude::*; | |
| 168 | for child in vbox.children().into_iter().filter(|child| child.is::<gtk::MenuBar>()) { | |
| 169 | child.set_no_show_all(custom); | |
| 170 | } | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| Merge branch 'main' of github.com:flagon-io/g1t | 174 | /// What a window button asks of the window it is in: `minimize`, |
| 175 | /// `maximize` (which toggles), `close`, `drag`, or `maximized` to ask. | |
| 176 | /// Answers whether the window is maximized. | |
| 177 | pub fn control(app: &AppHandle, window: &Window, action: &str) -> Result<bool, String> { | |
| The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again. | 178 | let main = window.label() == WINDOW; |
| 179 | match action { | |
| 180 | "minimize" => window.minimize().map_err(|error| error.to_string())?, | |
| 181 | "maximize" => { | |
| 182 | let maximized = window.is_maximized().unwrap_or(false); | |
| 183 | let _ = if maximized { window.unmaximize() } else { window.maximize() }; | |
| 184 | } | |
| 185 | "close" if main => tray::close(app), | |
| 186 | "close" => window.close().map_err(|error| error.to_string())?, | |
| 187 | "drag" => window.start_dragging().map_err(|error| error.to_string())?, | |
| 188 | "maximized" => {} | |
| 189 | other => return Err(format!("the window cannot {other}")), | |
| 190 | } | |
| 191 | Ok(window.is_maximized().unwrap_or(false)) | |
| 192 | } | |
| 193 | ||
| 194 | /// The menu button: the window's menu, under the button. | |
| Merge branch 'main' of github.com:flagon-io/g1t | 195 | pub fn menu(window: &Window, x: f64, y: f64) -> Result<(), String> { |
| The desktop app draws its own title bar, as Slack and Discord do, and the site can call the app again. | 196 | #[cfg(target_os = "macos")] |
| 197 | let menu = window.app_handle().menu(); | |
| 198 | #[cfg(not(target_os = "macos"))] | |
| 199 | let menu = window.menu(); | |
| 200 | let menu = menu.ok_or("the window has no menu")?; | |
| 201 | window.popup_menu_at(&menu, LogicalPosition::new(x, y)).map_err(|error| error.to_string()) | |
| 202 | } | |
| Merge branch 'main' of github.com:flagon-io/g1t | 203 | |
| 204 | /// The page's colours, as the bar takes them: CSS colours, resolved. | |
| 205 | #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] | |
| 206 | pub struct Colors { | |
| 207 | pub background: String, | |
| 208 | pub foreground: String, | |
| 209 | pub muted: String, | |
| 210 | pub accent: String, | |
| 211 | /// Whether the page is dark, for the bar's own hover shades. | |
| 212 | pub dark: bool, | |
| 213 | } | |
| 214 | ||
| 215 | /// What the bar shows of the page in the window under it. | |
| 216 | #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] | |
| 217 | #[serde(rename_all = "camelCase")] | |
| 218 | pub struct Page { | |
| 219 | pub title: String, | |
| 220 | /// The site's search, by its label ("Search Flagon"), when it has handed one over. | |
| 221 | pub search: Option<String>, | |
| 222 | /// Whether there is a page to go back or forward to; absent where the web view cannot tell. | |
| 223 | pub can_go_back: Option<bool>, | |
| 224 | pub can_go_forward: Option<bool>, | |
| 225 | pub colors: Option<Colors>, | |
| 226 | } | |
| 227 | ||
| 228 | /// The page as last reported, kept for a bar that loads after it. | |
| 229 | #[derive(Default)] | |
| 230 | pub struct State(Mutex<Page>); | |
| 231 | ||
| 232 | /// The site's page reported what it is: the bar is told. | |
| 233 | pub fn page(app: &AppHandle, mut page: Page) { | |
| 234 | page.title.truncate(300); | |
| 235 | if let Some(search) = page.search.as_mut() { | |
| 236 | search.truncate(120); | |
| 237 | } | |
| 238 | let state = app.state::<State>(); | |
| 239 | let mut kept = state.0.lock().unwrap(); | |
| 240 | // A page that knows no colours of its own keeps the last ones. | |
| 241 | if page.colors.is_none() { | |
| 242 | page.colors = kept.colors.clone(); | |
| 243 | } | |
| 244 | if *kept == page { | |
| 245 | return; | |
| 246 | } | |
| 247 | *kept = page.clone(); | |
| 248 | drop(kept); | |
| 249 | let _ = app.emit_to(EventTarget::webview(BAR), PAGE_EVENT, page); | |
| 250 | } | |
| 251 | ||
| 252 | /// What the bar shows now: it asks as it loads. | |
| 253 | pub fn current(app: &AppHandle) -> Page { | |
| 254 | app.state::<State>().0.lock().unwrap().clone() | |
| 255 | } | |
| 256 | ||
| 257 | /// A button on the bar that acts on the page: `back`, `forward`, `search`, | |
| 258 | /// or `focus`, which hands the keyboard back to the page after a click on | |
| 259 | /// the bar. | |
| 260 | pub fn act(app: &AppHandle, action: &str) -> Result<(), String> { | |
| 261 | let site = site(app).ok_or("there is no page")?; | |
| 262 | match action { | |
| 263 | "back" => site.eval("history.back()").map_err(|error| error.to_string())?, | |
| 264 | "forward" => site.eval("history.forward()").map_err(|error| error.to_string())?, | |
| 265 | "search" => app.emit_to(EventTarget::webview(SITE), SEARCH_EVENT, ()).map_err(|error| error.to_string())?, | |
| 266 | "focus" => {} | |
| 267 | other => return Err(format!("the bar cannot {other}")), | |
| 268 | } | |
| 269 | let _ = site.set_focus(); | |
| 270 | Ok(()) | |
| 271 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.