Skip to content
276 linesCodeBlameRaw
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
18use std::sync::Mutex;
19
20use serde::{Deserialize, Serialize};
21use tauri::webview::WebviewBuilder;
22use tauri::window::{Color, WindowBuilder};
23use tauri::{AppHandle, Emitter, EventTarget, LogicalPosition, LogicalSize, Manager, Runtime, Webview, WebviewUrl, WebviewWindowBuilder, Window};
24
25use crate::{WINDOW, config, tray};
26
27/// The bar's height, in CSS pixels.
28pub const HEIGHT: f64 = 36.0;
29
30/// The site's web view, in the main window.
31pub const SITE: &str = "site";
32/// The bar's web view, across the top of the main window.
33pub const BAR: &str = "titlebar";
34
35/// What the bar listens for: what to show (`Page`).
36pub const PAGE_EVENT: &str = "g1t-title-bar";
37/// What the site listens for: the bar's search was clicked.
38pub const SEARCH_EVENT: &str = "g1t-search";
39
40/// g1t's rail, dark, which the window is painted while a page loads.
41const RAIL: Color = Color(5, 5, 7, 255);
42
43/// Whether g1t draws its own title bar, as the settings say now.
44pub 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.
49static 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.
52pub 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.
57pub fn window(app: &AppHandle) -> Option<Window> {
58 app.get_window(WINDOW)
59}
60
61/// The site's web view.
62pub 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.
67pub 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.
86pub 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")]
108fn 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
118fn 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.
124pub 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.
141pub 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.
161pub 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.
177pub 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.
195pub 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)]
206pub 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")]
218pub 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)]
230pub struct State(Mutex<Page>);
231
232/// The site's page reported what it is: the bar is told.
233pub 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.
253pub 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.
260pub 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}