Skip to content

Commit bae124c

Browse files
feat(linux): add GTK HeaderBar with dynamic theme colors
Replace macOS overlay titlebar with native GTK HeaderBar on Linux. The HeaderBar background color updates dynamically when themes change. Backend changes: - Add titlebar.rs module with setup_headerbar() and set_titlebar_color() - Use thread_local CssProvider + glib::idle_add_once for main thread updates - Register set_titlebar_color Tauri command (no-op on non-Linux) - Add gtk = "0.18" as Linux-only dependency Frontend changes: - Add _syncTitlebarColor() to ui store (converts CSS colors to RGB) - Set data-platform="linux" on <html> for platform-specific CSS - Hide macOS drag region and remove pt-7 padding on Linux - Convert oklch/hsl colors to rgb() for GTK3 CSS compatibility Tested on RPi CM5 (Debian Trixie, GNOME/X11) via Tauri MCP. Closes task-264 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 4295a4c commit bae124c

7 files changed

Lines changed: 165 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

app/frontend/js/stores/ui.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,40 @@ export function createUIStore(Alpine) {
213213
} catch (e) {
214214
console.warn('[ui] Failed to set Tauri window theme:', e);
215215
}
216+
217+
// Sync GTK HeaderBar color on Linux
218+
this._syncTitlebarColor();
219+
},
220+
221+
/**
222+
* Send the computed bg-background color to the GTK HeaderBar (Linux only).
223+
* No-op on other platforms.
224+
*/
225+
async _syncTitlebarColor() {
226+
if (!window.__TAURI__) return;
227+
228+
try {
229+
// Wait a frame for CSS variables to settle after theme switch
230+
await new Promise((r) => requestAnimationFrame(r));
231+
232+
// Convert any CSS color (oklch, hsl, etc.) to rgb() for GTK3 CSS
233+
const raw = getComputedStyle(document.body).backgroundColor;
234+
if (!raw) return;
235+
const canvas = document.createElement('canvas');
236+
canvas.width = 1;
237+
canvas.height = 1;
238+
const ctx = canvas.getContext('2d');
239+
ctx.fillStyle = raw;
240+
ctx.fillRect(0, 0, 1, 1);
241+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
242+
const color = `rgb(${r}, ${g}, ${b})`;
243+
244+
await window.__TAURI__.core.invoke('set_titlebar_color', { color });
245+
console.log('[ui] Set titlebar color:', color);
246+
} catch (e) {
247+
// Expected to silently succeed on non-Linux (no-op command)
248+
console.warn('[ui] Failed to set titlebar color:', e);
249+
}
216250
},
217251

218252
applyTheme() {

app/frontend/main.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,11 @@ async function initApp() {
225225
// which can cause the sidebar to briefly render with wrong theme colors.
226226
applyInitialTheme();
227227

228+
// Set platform attribute for CSS (Linux uses GTK HeaderBar, no overlay titlebar)
229+
if (navigator.platform?.startsWith('Linux')) {
230+
document.documentElement.dataset.platform = 'linux';
231+
}
232+
228233
// Disable the default browser/webview context menu globally
229234
// App-specific context menus (tracks, headers, playlists) handle their own rendering
230235
document.addEventListener('contextmenu', (e) => e.preventDefault());

app/frontend/styles.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@
3535
background-color: #1e1e1e;
3636
}
3737

38+
/* Linux uses GTK HeaderBar — hide overlay drag region and remove top padding */
39+
[data-platform='linux'] .titlebar-drag-region {
40+
display: none;
41+
}
42+
43+
[data-platform='linux'] body > .flex > .pt-7 {
44+
padding-top: 0;
45+
}
46+
3847
.track-row-even {
3948
background-color: var(--itunes-row-alt);
4049
}

crates/mt-tauri/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ devtools = ["dep:tauri-plugin-devtools", "tauri/devtools"]
7777
mcp = ["dep:tauri-plugin-mcp-bridge"]
7878
rust-lru-cache = [] # Legacy Rust LRU cache (Zig implementation is default)
7979

80+
[target.'cfg(target_os = "linux")'.dependencies]
81+
gtk = "0.18"
82+
8083
[dev-dependencies]
8184
tempfile = "3"
8285
proptest = "1.5"

crates/mt-tauri/src/lib.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ pub mod library;
88
pub mod media_keys;
99
pub mod metadata;
1010
pub mod scanner;
11+
pub mod titlebar;
1112
pub mod watcher;
1213

1314
// Re-export FFI from mt-core for backward compatibility
@@ -48,6 +49,7 @@ use watcher::{
4849
watched_folders_add, watched_folders_get, watched_folders_list, watched_folders_remove,
4950
watched_folders_rescan, watched_folders_status, watched_folders_update, WatcherManager,
5051
};
52+
use titlebar::set_titlebar_color;
5153
use serde::Serialize;
5254
use std::time::Duration;
5355
use tokio::io::AsyncWriteExt;
@@ -274,6 +276,7 @@ pub fn run() {
274276
settings_set,
275277
settings_update,
276278
settings_reset,
279+
set_titlebar_color,
277280
])
278281
.setup(|app| {
279282
// Initialize database
@@ -328,6 +331,16 @@ pub fn run() {
328331
}
329332
}
330333

334+
// Set GTK HeaderBar as CSD titlebar on Linux (before window.show())
335+
#[cfg(target_os = "linux")]
336+
{
337+
if let Some(window) = app.get_webview_window("main") {
338+
if let Err(e) = titlebar::setup_headerbar(&window) {
339+
eprintln!("Failed to setup GTK HeaderBar: {}", e);
340+
}
341+
}
342+
}
343+
331344
#[cfg(feature = "mcp")]
332345
{
333346
app.handle().plugin(tauri_plugin_mcp_bridge::init())?;

crates/mt-tauri/src/titlebar.rs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/// Linux GTK HeaderBar for native CSD titlebar.
2+
///
3+
/// Replaces the standard window manager titlebar with a GTK HeaderBar
4+
/// whose background color adapts to the active frontend theme.
5+
///
6+
/// GTK objects are `!Sync`, so the [`CssProvider`] lives in a main-thread
7+
/// `thread_local`. The Tauri command [`set_titlebar_color`] bounces updates
8+
/// back to the main thread via `glib::idle_add_once`.
9+
#[cfg(target_os = "linux")]
10+
use std::cell::RefCell;
11+
12+
#[cfg(target_os = "linux")]
13+
use gtk::prelude::*;
14+
15+
#[cfg(target_os = "linux")]
16+
thread_local! {
17+
static CSS_PROVIDER: RefCell<Option<gtk::CssProvider>> = const { RefCell::new(None) };
18+
}
19+
20+
/// Create a GTK HeaderBar and set it as the window's titlebar.
21+
///
22+
/// Must be called during `setup()` before the window is shown.
23+
#[cfg(target_os = "linux")]
24+
pub fn setup_headerbar(window: &tauri::WebviewWindow) -> Result<(), Box<dyn std::error::Error>> {
25+
let gtk_window = window.gtk_window()?;
26+
27+
let header_bar = gtk::HeaderBar::new();
28+
header_bar.set_show_close_button(true);
29+
header_bar.set_title(None::<&str>);
30+
header_bar.set_has_subtitle(false);
31+
32+
// Apply an initial transparent style so basecoat bg-background shows through
33+
let provider = gtk::CssProvider::new();
34+
provider
35+
.load_from_data(
36+
b"headerbar {
37+
background: transparent;
38+
border: none;
39+
box-shadow: none;
40+
min-height: 28px;
41+
padding: 0 6px;
42+
}",
43+
)
44+
.ok();
45+
46+
// Disambiguate: GtkWindowExt::screen (not WidgetExt::screen)
47+
let screen = gtk::prelude::GtkWindowExt::screen(&gtk_window)
48+
.expect("GTK window must have a screen");
49+
gtk::StyleContext::add_provider_for_screen(
50+
&screen,
51+
&provider,
52+
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
53+
);
54+
55+
// Store the provider in a main-thread thread_local for later updates
56+
CSS_PROVIDER.with(|cell| {
57+
*cell.borrow_mut() = Some(provider);
58+
});
59+
60+
gtk_window.set_titlebar(Some(&header_bar));
61+
println!("GTK HeaderBar set as CSD titlebar");
62+
63+
Ok(())
64+
}
65+
66+
/// Update the HeaderBar background color from the frontend theme.
67+
///
68+
/// The frontend calls this whenever the theme changes, passing the
69+
/// computed CSS `bg-background` color (e.g. `"rgb(30, 30, 30)"` or `"rgb(255, 255, 255)"`).
70+
///
71+
/// Because Tauri commands run on a Tokio thread, this dispatches the GTK
72+
/// update back to the main thread via `glib::idle_add_once`.
73+
#[cfg(target_os = "linux")]
74+
#[tauri::command]
75+
pub fn set_titlebar_color(color: String) -> Result<(), String> {
76+
gtk::glib::idle_add_once(move || {
77+
CSS_PROVIDER.with(|cell| {
78+
if let Some(provider) = cell.borrow().as_ref() {
79+
let css = format!(
80+
"headerbar {{
81+
background: {color};
82+
border: none;
83+
box-shadow: none;
84+
min-height: 28px;
85+
padding: 0 6px;
86+
}}"
87+
);
88+
provider.load_from_data(css.as_bytes()).ok();
89+
}
90+
});
91+
});
92+
Ok(())
93+
}
94+
95+
/// No-op on non-Linux platforms.
96+
#[cfg(not(target_os = "linux"))]
97+
#[tauri::command]
98+
pub fn set_titlebar_color(_color: String) -> Result<(), String> {
99+
Ok(())
100+
}

0 commit comments

Comments
 (0)