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.
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 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>, | |
| Merge branch 'main' into rm-agents | 226 | /// The runner menu the site draws under the bar's dot is open. |
| 227 | #[serde(default)] | |
| 228 | pub runner_menu: bool, | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 229 | } |
| 230 | ||
| 231 | /// The page as last reported, kept for a bar that loads after it. | |
| 232 | #[derive(Default)] | |
| 233 | pub struct State(Mutex<Page>); | |
| 234 | ||
| 235 | /// The site's page reported what it is: the bar is told. | |
| 236 | pub fn page(app: &AppHandle, mut page: Page) { | |
| 237 | page.title.truncate(300); | |
| 238 | if let Some(search) = page.search.as_mut() { | |
| 239 | search.truncate(120); | |
| 240 | } | |
| 241 | let state = app.state::<State>(); | |
| 242 | let mut kept = state.0.lock().unwrap(); | |
| 243 | // A page that knows no colours of its own keeps the last ones. | |
| 244 | if page.colors.is_none() { | |
| 245 | page.colors = kept.colors.clone(); | |
| 246 | } | |
| 247 | if *kept == page { | |
| 248 | return; | |
| 249 | } | |
| 250 | *kept = page.clone(); | |
| 251 | drop(kept); | |
| 252 | let _ = app.emit_to(EventTarget::webview(BAR), PAGE_EVENT, page); | |
| 253 | } | |
| 254 | ||
| 255 | /// What the bar shows now: it asks as it loads. | |
| 256 | pub fn current(app: &AppHandle) -> Page { | |
| 257 | app.state::<State>().0.lock().unwrap().clone() | |
| 258 | } | |
| 259 | ||
| 260 | /// A button on the bar that acts on the page: `back`, `forward`, `search`, | |
| Merge branch 'main' into rm-agents | 261 | /// `settings`, `runner` (the dot beside the settings, `at` where it is |
| 262 | /// across the bar), or `focus`, which hands the keyboard back to the page | |
| 263 | /// after a click on the bar. | |
| 264 | pub fn act(app: &AppHandle, action: &str, at: Option<f64>) -> Result<(), String> { | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 265 | let site = site(app).ok_or("there is no page")?; |
| 266 | match action { | |
| 267 | "back" => site.eval("history.back()").map_err(|error| error.to_string())?, | |
| 268 | "forward" => site.eval("history.forward()").map_err(|error| error.to_string())?, | |
| 269 | "search" => app.emit_to(EventTarget::webview(SITE), SEARCH_EVENT, ()).map_err(|error| error.to_string())?, | |
| Merge branch 'main' into rm-agents | 270 | // The app's settings, a click away rather than only in its menu: |
| 271 | // a dialog the site draws over itself. | |
| The desktop app's settings are a button in its title bar. A Settings button sits at the right of the bar, before the window's own buttons (and at its end on macOS), so the runner and the rest are a click away instead of only in the File menu, the tray or the account menu. | 272 | "settings" => { |
| 273 | crate::settings::open(app); | |
| 274 | return Ok(()); | |
| 275 | } | |
| Merge branch 'main' into rm-agents | 276 | // The runner's menu, hanging under its dot beside the settings button. |
| The desktop app is 0.3.4, and the runner has a window of its own. Beside the title bar's Settings button a chip says what the runner is doing, live, with a dot in its colour (Runner running, Running a job, Running 2 jobs, Runner starting, Runner updating, Runner stopped, Runner offline, Runner needs you), as Docker Desktop shows its engine; in a narrow window only the dot shows. It opens the Runner window, as the tray's runner row now does: at its top what the whole runner is doing, with what it is running, its version and workspace, and its own Stop or Start; then each kind of job's card as before; then its setup (running jobs here at all, how jobs run, the workspace, the computer with Check for runner updates and View on g1t); and its log, open and following along as it is written. Settings keeps the runner as one line, what it is doing with Open runner, and each window saves only its own settings over what the app has now, so neither undoes the other's. The chip's words are diagnosis.rs's, the Runner window is made off the main thread as Settings now is, and it has a capability of its own with only the commands it uses; the title bar may now ask the runner's status. Two things fixed on the way: a settings row hidden until it applies, Restart to change the title bar, showed all the time, since its row's display beat the hidden attribute; and in a narrow window the title bar's buttons ran over the search, which now shrinks instead. The desktop guide describes the chip, the Runner window and where each runner setting now is. | 277 | "runner" => { |
| Merge branch 'main' into rm-agents | 278 | crate::settings::runner_menu(app, true, at); |
| The desktop app is 0.3.4, and the runner has a window of its own. Beside the title bar's Settings button a chip says what the runner is doing, live, with a dot in its colour (Runner running, Running a job, Running 2 jobs, Runner starting, Runner updating, Runner stopped, Runner offline, Runner needs you), as Docker Desktop shows its engine; in a narrow window only the dot shows. It opens the Runner window, as the tray's runner row now does: at its top what the whole runner is doing, with what it is running, its version and workspace, and its own Stop or Start; then each kind of job's card as before; then its setup (running jobs here at all, how jobs run, the workspace, the computer with Check for runner updates and View on g1t); and its log, open and following along as it is written. Settings keeps the runner as one line, what it is doing with Open runner, and each window saves only its own settings over what the app has now, so neither undoes the other's. The chip's words are diagnosis.rs's, the Runner window is made off the main thread as Settings now is, and it has a capability of its own with only the commands it uses; the title bar may now ask the runner's status. Two things fixed on the way: a settings row hidden until it applies, Restart to change the title bar, showed all the time, since its row's display beat the hidden attribute; and in a narrow window the title bar's buttons ran over the search, which now shrinks instead. The desktop guide describes the chip, the Runner window and where each runner setting now is. | 279 | return Ok(()); |
| 280 | } | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 281 | "focus" => {} |
| 282 | other => return Err(format!("the bar cannot {other}")), | |
| 283 | } | |
| 284 | let _ = site.set_focus(); | |
| 285 | Ok(()) | |
| 286 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.