Skip to content
288 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 // Never an inspector: the devtools feature gives every web view
92 // one unless it is told otherwise (src/devtools.rs).
93 WebviewBuilder::new(BAR, WebviewUrl::App("titlebar.html".into())).background_color(RAIL).on_navigation(config::is_launcher).devtools(false),
94 LogicalPosition::new(0.0, 0.0),
95 LogicalSize::new(size.width, HEIGHT),
96 )?;
97 }
98 window.add_child(site.background_color(RAIL), LogicalPosition::new(0.0, top), LogicalSize::new(size.width, (size.height - top).max(0.0)))?;
99 #[cfg(target_os = "linux")]
100 if custom {
101 pack_bar(window);
102 }
103 Ok(())
104}
105
106/// On Linux the web views sit in a GTK box, one under the other, which
107/// shares the window between them equally whatever size they are given:
108/// the bar is told to keep its height, and the site takes the rest.
109#[cfg(target_os = "linux")]
110fn pack_bar(window: &Window) {
111 use gtk::prelude::*;
112 let Ok(vbox) = window.default_vbox() else { return };
113 // The bar is the first web view added; the menu bar, hidden, is not one.
114 if let Some(bar) = vbox.children().into_iter().find(|child| child.type_().name() == "WebKitWebView") {
115 vbox.set_child_packing(&bar, false, true, 0, gtk::PackType::Start);
116 bar.set_size_request(-1, HEIGHT as i32);
117 }
118}
119
120fn logical_size(window: &Window) -> LogicalSize<f64> {
121 let scale = window.scale_factor().unwrap_or(1.0);
122 window.inner_size().map(|size| size.to_logical(scale)).unwrap_or(LogicalSize::new(1280.0, 840.0))
123}
124
125/// The window changed size: the bar keeps its height, and the site takes the rest.
126pub fn layout(window: &Window) {
127 let app = window.app_handle();
128 let size = logical_size(window);
129 let bar = app.get_webview(BAR);
130 let top = if bar.is_some() { HEIGHT } else { 0.0 };
131 if let Some(bar) = bar {
132 let _ = bar.set_position(LogicalPosition::new(0.0, 0.0));
133 let _ = bar.set_size(LogicalSize::new(size.width, HEIGHT));
134 }
135 if let Some(site) = site(app) {
136 let _ = site.set_position(LogicalPosition::new(0.0, top));
137 let _ = site.set_size(LogicalSize::new(size.width, (size.height - top).max(0.0)));
138 }
139}
140
141/// The settings window: g1t's frame, or the system's. Its page draws its
142/// own bar (launcher/settings.html), being g1t's own.
143pub fn dialog<'a, R: Runtime, M: Manager<R>>(builder: WebviewWindowBuilder<'a, R, M>, custom: bool) -> WebviewWindowBuilder<'a, R, M> {
144 if !custom {
145 return builder;
146 }
147 #[cfg(target_os = "macos")]
148 {
149 builder
150 .title_bar_style(tauri::TitleBarStyle::Overlay)
151 .hidden_title(true)
152 .traffic_light_position(LogicalPosition::new(14.0, (HEIGHT - 14.0) / 2.0))
153 }
154 #[cfg(not(target_os = "macos"))]
155 {
156 builder.decorations(false)
157 }
158}
159
160/// The main window's menu bar: shown under the system's frame, and kept
161/// hidden under g1t's, whose menu button opens the same menu (`menu`) and
162/// whose shortcuts still work.
163pub fn menu_bar(window: &Window, custom: bool) {
164 let _ = if custom { window.hide_menu() } else { window.show_menu() };
165 // GTK shows everything in a window each time the window is shown, the
166 // hidden menu bar too, unless the menu bar is told to sit that out.
167 #[cfg(target_os = "linux")]
168 if let Ok(vbox) = window.default_vbox() {
169 use gtk::prelude::*;
170 for child in vbox.children().into_iter().filter(|child| child.is::<gtk::MenuBar>()) {
171 child.set_no_show_all(custom);
172 }
173 }
174}
175
176/// What a window button asks of the window it is in: `minimize`,
177/// `maximize` (which toggles), `close`, `drag`, or `maximized` to ask.
178/// Answers whether the window is maximized.
179pub fn control(app: &AppHandle, window: &Window, action: &str) -> Result<bool, String> {
180 let main = window.label() == WINDOW;
181 match action {
182 "minimize" => window.minimize().map_err(|error| error.to_string())?,
183 "maximize" => {
184 let maximized = window.is_maximized().unwrap_or(false);
185 let _ = if maximized { window.unmaximize() } else { window.maximize() };
186 }
187 "close" if main => tray::close(app),
188 "close" => window.close().map_err(|error| error.to_string())?,
189 "drag" => window.start_dragging().map_err(|error| error.to_string())?,
190 "maximized" => {}
191 other => return Err(format!("the window cannot {other}")),
192 }
193 Ok(window.is_maximized().unwrap_or(false))
194}
195
196/// The menu button: the window's menu, under the button.
197pub fn menu(window: &Window, x: f64, y: f64) -> Result<(), String> {
198 #[cfg(target_os = "macos")]
199 let menu = window.app_handle().menu();
200 #[cfg(not(target_os = "macos"))]
201 let menu = window.menu();
202 let menu = menu.ok_or("the window has no menu")?;
203 window.popup_menu_at(&menu, LogicalPosition::new(x, y)).map_err(|error| error.to_string())
204}
205
206/// The page's colours, as the bar takes them: CSS colours, resolved.
207#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
208pub struct Colors {
209 pub background: String,
210 pub foreground: String,
211 pub muted: String,
212 pub accent: String,
213 /// Whether the page is dark, for the bar's own hover shades.
214 pub dark: bool,
215}
216
217/// What the bar shows of the page in the window under it.
218#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)]
219#[serde(rename_all = "camelCase")]
220pub struct Page {
221 pub title: String,
222 /// The site's search, by its label ("Search Flagon"), when it has handed one over.
223 pub search: Option<String>,
224 /// Whether there is a page to go back or forward to; absent where the web view cannot tell.
225 pub can_go_back: Option<bool>,
226 pub can_go_forward: Option<bool>,
227 pub colors: Option<Colors>,
228 /// The runner menu the site draws under the bar's dot is open.
229 #[serde(default)]
230 pub runner_menu: bool,
231}
232
233/// The page as last reported, kept for a bar that loads after it.
234#[derive(Default)]
235pub struct State(Mutex<Page>);
236
237/// The site's page reported what it is: the bar is told.
238pub fn page(app: &AppHandle, mut page: Page) {
239 page.title.truncate(300);
240 if let Some(search) = page.search.as_mut() {
241 search.truncate(120);
242 }
243 let state = app.state::<State>();
244 let mut kept = state.0.lock().unwrap();
245 // A page that knows no colours of its own keeps the last ones.
246 if page.colors.is_none() {
247 page.colors = kept.colors.clone();
248 }
249 if *kept == page {
250 return;
251 }
252 *kept = page.clone();
253 drop(kept);
254 let _ = app.emit_to(EventTarget::webview(BAR), PAGE_EVENT, page);
255}
256
257/// What the bar shows now: it asks as it loads.
258pub fn current(app: &AppHandle) -> Page {
259 app.state::<State>().0.lock().unwrap().clone()
260}
261
262/// A button on the bar that acts on the page: `back`, `forward`, `search`,
263/// `settings`, `runner` (the dot beside the settings, `at` where it is
264/// across the bar), or `focus`, which hands the keyboard back to the page
265/// after a click on the bar.
266pub fn act(app: &AppHandle, action: &str, at: Option<f64>) -> Result<(), String> {
267 let site = site(app).ok_or("there is no page")?;
268 match action {
269 "back" => site.eval("history.back()").map_err(|error| error.to_string())?,
270 "forward" => site.eval("history.forward()").map_err(|error| error.to_string())?,
271 "search" => app.emit_to(EventTarget::webview(SITE), SEARCH_EVENT, ()).map_err(|error| error.to_string())?,
272 // The app's settings, a click away rather than only in its menu:
273 // a dialog the site draws over itself.
274 "settings" => {
275 crate::settings::open(app);
276 return Ok(());
277 }
278 // The runner's menu, hanging under its dot beside the settings button.
279 "runner" => {
280 crate::settings::runner_menu(app, true, at);
281 return Ok(());
282 }
283 "focus" => {}
284 other => return Err(format!("the bar cannot {other}")),
285 }
286 let _ = site.set_focus();
287 Ok(())
288}