| 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 |
| 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; |
| 19 | |
| 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}; |
| 24 | |
| 25 | use crate::{WINDOW, config, tray}; |
| 26 | |
| 27 | /// The bar's height, in CSS pixels. |
| 28 | pub const HEIGHT: f64 = 36.0; |
| 29 | |
| 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 | |
| 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 | |
| 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> { |
| 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. |
| 161 | pub fn menu_bar(window: &Window, custom: bool) { |
| 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 | |
| 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> { |
| 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. |
| 195 | pub fn menu(window: &Window, x: f64, y: f64) -> Result<(), String> { |
| 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 | } |
| 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 | // The app's settings, a click away rather than only in its menu. |
| 267 | "settings" => { |
| 268 | crate::settings::open(app); |
| 269 | return Ok(()); |
| 270 | } |
| 271 | "focus" => {} |
| 272 | other => return Err(format!("the bar cannot {other}")), |
| 273 | } |
| 274 | let _ = site.set_focus(); |
| 275 | Ok(()) |
| 276 | } |