integrate the titlebar into the tab strip (no more native GTK titlebar)

- Linux: the chrome webview is promoted into the GTK titlebar slot via
  gtk_window_set_titlebar, so the window keeps GTK's client-side shadow,
  edge-resizing and snap behaviour while drawing its own titlebar
- custom minimize / maximize-restore / close buttons in the tab strip row,
  with hover states and tooltips; window state is synced on resize
- drag the window from the grip next to the tabs or from the empty tab
  strip area; double-click toggles maximize
- '+' new tab button now sits right next to the last tab instead of the
  far right edge
- window is created hidden and shown after the titlebar is installed,
  avoiding Gtk's realized-window warning on startup
- non-Linux builds keep native decorations and hide the custom controls
- tab row height 34 -> 38 (chrome height 84 -> 88)
This commit is contained in:
Martin Dvořák 2026-10-11 18:22:56 +02:00
commit fbd88c6281

View file

@ -25,7 +25,7 @@ const DEFAULT_HOME: &str = "https://duckduckgo.com";
const NEW_TAB_URL: &str = "https://duckduckgo.com";
const SEARCH_URL_PREFIX: &str = "https://duckduckgo.com/?q=";
const MODERN_BROWSER_UA_BASE: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36";
const CHROME_HEIGHT_BASE: f64 = 84.0;
const CHROME_HEIGHT_BASE: f64 = 88.0;
const CHROME_HEIGHT_DOWNLOADS_EXTRA: f64 = 104.0;
const CHROME_HEIGHT_POPUP_PROMPT_EXTRA: f64 = 44.0;
const MAX_HISTORY_ENTRIES: usize = 250;
@ -206,7 +206,7 @@ body {
/* ---------- Tab strip ---------- */
#tabs-row {
display: flex; align-items: center; gap: 6px;
flex: 0 0 34px; height: 34px; min-width: 0;
flex: 0 0 38px; height: 38px; min-width: 0;
}
#tabs {
display: flex; align-items: center; gap: 4px;
@ -216,6 +216,26 @@ body {
#tabs::-webkit-scrollbar { display: none; }
#add-btn { width: 28px; height: 28px; }
#add-btn svg { width: 15px; height: 15px; }
#drag-grip { flex: 0 0 auto; width: 14px; height: 100%; }
#window-controls { display: flex; align-items: center; gap: 2px; margin-left: 2px; }
body:not(.frameless) #drag-grip,
body:not(.frameless) #window-controls { display: none; }
.win-btn {
flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
width: 36px; height: 30px; padding: 0;
border: 0; border-radius: var(--radius-sm);
background: transparent; color: var(--text-dim);
cursor: pointer; outline: none;
transition: background-color var(--speed) var(--ease), color var(--speed) var(--ease);
}
.win-btn svg { display: block; width: 14px; height: 14px; }
.win-btn:hover { background: var(--surface-hover); color: var(--text); }
.win-btn:focus-visible { box-shadow: 0 0 0 2px var(--accent-soft); color: var(--text); }
#win-close:hover { background: #d64550; color: #fff; }
#win-max .icon-restore { display: none; }
body.maximized #win-max .icon-max { display: none; }
body.maximized #win-max .icon-restore { display: block; }
.tab {
position: relative;
display: flex; align-items: center; gap: 4px;
@ -383,13 +403,28 @@ body {
</style>
</head>
<body>
<script>if (__RUSTYWOLF_FRAMELESS__) document.body.classList.add('frameless');</script>
<div id="chrome">
<div id="tabs-row">
<div id="tabs" role="tablist" aria-label="Tabs"></div>
<div id="tabs" role="tablist" aria-label="Tabs">
<button id="add-btn" class="icon-btn" title="New tab (Ctrl+T)" aria-label="New tab">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M12 5v14"/><path d="M5 12h14"/></svg>
</button>
</div>
<div id="drag-grip" aria-hidden="true"></div>
<div id="window-controls">
<button id="win-min" class="win-btn" title="Minimize" aria-label="Minimize">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 12h14"/></svg>
</button>
<button id="win-max" class="win-btn" title="Maximize" aria-label="Maximize">
<svg class="icon-max" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="5.5" y="5.5" width="13" height="13" rx="2"/></svg>
<svg class="icon-restore" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="8.5" y="4.5" width="11" height="11" rx="2"/><path d="M5.5 8.5v9a2 2 0 0 0 2 2h9"/></svg>
</button>
<button id="win-close" class="win-btn" title="Close window" aria-label="Close window">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button>
</div>
</div>
<div id="nav-row">
<button id="back" class="icon-btn" title="Back" aria-label="Back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
@ -467,6 +502,9 @@ document.getElementById('props-btn').addEventListener('click', () => post('open_
document.getElementById('adblock-btn').addEventListener('click', () => post('toggle_adblock'));
document.getElementById('downloads-btn').addEventListener('click', () => post('toggle_downloads'));
document.getElementById('add-btn').addEventListener('click', () => post('new_tab'));
document.getElementById('win-min').addEventListener('click', () => post('window_minimize'));
document.getElementById('win-max').addEventListener('click', () => post('window_toggle_maximize'));
document.getElementById('win-close').addEventListener('click', () => post('window_close'));
document.getElementById('popup-yes').addEventListener('click', () => post('popup_allow_yes'));
document.getElementById('popup-no').addEventListener('click', () => post('popup_allow_no'));
@ -496,6 +534,38 @@ tabStrip.addEventListener('wheel', (ev) => {
tabStrip.scrollLeft += ev.deltaY;
}, { passive: false });
// Frameless window: move or maximize from the titlebar grip / tab strip background.
if (document.body.classList.contains('frameless')) {
let dragOrigin = null;
const dragRegions = [document.getElementById('drag-grip'), tabStrip];
dragRegions.forEach((el) => {
el.addEventListener('mousedown', (ev) => {
if (ev.button !== 0 || ev.target !== el) return;
dragOrigin = { x: ev.clientX, y: ev.clientY };
});
el.addEventListener('dblclick', (ev) => {
if (ev.target !== el) return;
post('window_toggle_maximize');
});
});
window.addEventListener('mousemove', (ev) => {
if (!dragOrigin) return;
if (Math.abs(ev.clientX - dragOrigin.x) < 5 && Math.abs(ev.clientY - dragOrigin.y) < 5) return;
dragOrigin = null;
post('window_drag');
});
window.addEventListener('mouseup', () => {
dragOrigin = null;
});
}
window.__rustywolfSetWindowMaximized = (on) => {
document.body.classList.toggle('maximized', !!on);
const button = document.getElementById('win-max');
button.title = on ? 'Restore' : 'Maximize';
button.setAttribute('aria-label', on ? 'Restore' : 'Maximize');
};
const setSiteIcon = (url) => {
let state = 'search';
let hint = 'Search or enter address';
@ -617,8 +687,9 @@ const makeTab = (tab, i, activeId) => {
};
window.__rustywolfSetTabs = (state) => {
tabStrip.innerHTML = '';
state.tabs.forEach((tab, i) => tabStrip.appendChild(makeTab(tab, i, state.activeId)));
const addBtn = document.getElementById('add-btn');
tabStrip.querySelectorAll('.tab').forEach((el) => el.remove());
state.tabs.forEach((tab, i) => tabStrip.insertBefore(makeTab(tab, i, state.activeId), addBtn));
const active = state.tabs.find((t) => t.id === state.activeId);
currentUrl = (active && active.url && active.url !== 'about:blank') ? active.url : '';
@ -1054,6 +1125,9 @@ pub fn launch_webkit(args: &Cli) -> Result<()> {
.with_title(title)
.with_window_icon(load_window_icon())
.with_inner_size(LogicalSize::new(1000.0_f64, 600.0_f64))
// Keep the window hidden until the chrome has been promoted to the GTK
// titlebar slot; GTK warns when set_titlebar runs on a realized window.
.with_visible(!cfg!(target_os = "linux"))
.build(&event_loop)
.map_err(|err| anyhow!("Failed to create window: {err}"))?;
@ -1061,6 +1135,7 @@ pub fn launch_webkit(args: &Cli) -> Result<()> {
let (gtk_chrome_container, gtk_content_container) = if host_mode == WebViewHostMode::Window {
let (chrome, content) = create_linux_webview_containers(&window)?;
set_linux_chrome_container_height(&chrome, CHROME_HEIGHT_BASE);
promote_chrome_to_titlebar(&window, &chrome)?;
(Some(chrome), Some(content))
} else {
(None, None)
@ -1095,6 +1170,7 @@ pub fn launch_webkit(args: &Cli) -> Result<()> {
};
open_tab(&mut state, &window, &proxy, start_url)?;
push_window_state(&state, &window);
let tick_proxy = proxy.clone();
thread::spawn(move || {
@ -1106,6 +1182,9 @@ pub fn launch_webkit(args: &Cli) -> Result<()> {
}
});
#[cfg(target_os = "linux")]
window.set_visible(true);
event_loop.run(move |event, _, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
@ -1121,6 +1200,7 @@ pub fn launch_webkit(args: &Cli) -> Result<()> {
..
} => {
reflow_layout(&window, &state);
push_window_state(&state, &window);
}
Event::WindowEvent {
event: WindowEvent::CloseRequested,
@ -1562,6 +1642,27 @@ fn handle_chrome_ipc(
return Ok(());
}
if message == "window_drag" {
let _ = window.drag_window();
return Ok(());
}
if message == "window_minimize" {
window.set_minimized(true);
return Ok(());
}
if message == "window_toggle_maximize" {
window.set_maximized(!window.is_maximized());
push_window_state(state, window);
return Ok(());
}
if message == "window_close" {
let _ = proxy.send_event(UserEvent::Quit);
return Ok(());
}
if message == "close_active_tab" {
if !state.tabs.is_empty() {
let tab_id = state.tabs[state.active].id;
@ -1620,10 +1721,18 @@ fn build_chrome(
#[cfg(target_os = "linux")] gtk_chrome_container: Option<&gtk::Box>,
) -> Result<(WebView, WebViewHostMode)> {
let chrome_rect = chrome_bounds(window, CHROME_HEIGHT_BASE, host_mode);
let chrome_html = CHROME_HTML.replace(
"__RUSTYWOLF_FRAMELESS__",
if cfg!(target_os = "linux") {
"true"
} else {
"false"
},
);
let builder = || {
let chrome_proxy = proxy.clone();
WebViewBuilder::new()
.with_html(CHROME_HTML)
.with_html(chrome_html.clone())
.with_bounds(chrome_rect)
.with_ipc_handler(move |req| {
let _ = chrome_proxy.send_event(UserEvent::ChromeIpc(req.body().to_string()));
@ -2059,6 +2168,18 @@ fn push_adblock(state: &BrowserState) {
let _ = state.chrome.evaluate_script(&script);
}
fn push_window_state(state: &BrowserState, window: &Window) {
let maximized = if window.is_maximized() {
"true"
} else {
"false"
};
let script = format!(
"window.__rustywolfSetWindowMaximized && window.__rustywolfSetWindowMaximized({maximized});"
);
let _ = state.chrome.evaluate_script(&script);
}
fn push_popup_prompt(state: &BrowserState) {
let payload = match &state.pending_popup {
Some(pending) => json!({
@ -2176,6 +2297,20 @@ fn create_linux_webview_containers(window: &Window) -> Result<(gtk::Box, gtk::Bo
Ok((chrome, content))
}
#[cfg(target_os = "linux")]
fn promote_chrome_to_titlebar(window: &Window, chrome_container: &gtk::Box) -> Result<()> {
// Replace the default GTK titlebar with the chrome webview so the window
// keeps GTK's client-side decorations (shadow, edge resizing, snapping)
// while the tab strip and window controls are drawn by our own UI.
let gtk_window = window.gtk_window();
if let Some(vbox) = window.default_vbox() {
vbox.remove(chrome_container);
}
gtk_window.set_titlebar(Some(chrome_container));
chrome_container.show_all();
Ok(())
}
#[cfg(target_os = "linux")]
fn set_linux_chrome_container_height(container: &gtk::Box, chrome_h: f64) {
container.set_size_request(-1, chrome_h.max(0.0).round() as i32);