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:
parent
5c7f28b656
commit
fbd88c6281
1 changed files with 144 additions and 9 deletions
153
src/webview.rs
153
src/webview.rs
|
|
@ -25,7 +25,7 @@ const DEFAULT_HOME: &str = "https://duckduckgo.com";
|
||||||
const NEW_TAB_URL: &str = "https://duckduckgo.com";
|
const NEW_TAB_URL: &str = "https://duckduckgo.com";
|
||||||
const SEARCH_URL_PREFIX: &str = "https://duckduckgo.com/?q=";
|
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 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_DOWNLOADS_EXTRA: f64 = 104.0;
|
||||||
const CHROME_HEIGHT_POPUP_PROMPT_EXTRA: f64 = 44.0;
|
const CHROME_HEIGHT_POPUP_PROMPT_EXTRA: f64 = 44.0;
|
||||||
const MAX_HISTORY_ENTRIES: usize = 250;
|
const MAX_HISTORY_ENTRIES: usize = 250;
|
||||||
|
|
@ -206,7 +206,7 @@ body {
|
||||||
/* ---------- Tab strip ---------- */
|
/* ---------- Tab strip ---------- */
|
||||||
#tabs-row {
|
#tabs-row {
|
||||||
display: flex; align-items: center; gap: 6px;
|
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 {
|
#tabs {
|
||||||
display: flex; align-items: center; gap: 4px;
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
|
@ -216,6 +216,26 @@ body {
|
||||||
#tabs::-webkit-scrollbar { display: none; }
|
#tabs::-webkit-scrollbar { display: none; }
|
||||||
#add-btn { width: 28px; height: 28px; }
|
#add-btn { width: 28px; height: 28px; }
|
||||||
#add-btn svg { width: 15px; height: 15px; }
|
#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 {
|
.tab {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex; align-items: center; gap: 4px;
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
|
@ -383,12 +403,27 @@ body {
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<script>if (__RUSTYWOLF_FRAMELESS__) document.body.classList.add('frameless');</script>
|
||||||
<div id="chrome">
|
<div id="chrome">
|
||||||
<div id="tabs-row">
|
<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">
|
<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>
|
<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>
|
</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>
|
||||||
<div id="nav-row">
|
<div id="nav-row">
|
||||||
<button id="back" class="icon-btn" title="Back" aria-label="Back">
|
<button id="back" class="icon-btn" title="Back" aria-label="Back">
|
||||||
|
|
@ -467,6 +502,9 @@ document.getElementById('props-btn').addEventListener('click', () => post('open_
|
||||||
document.getElementById('adblock-btn').addEventListener('click', () => post('toggle_adblock'));
|
document.getElementById('adblock-btn').addEventListener('click', () => post('toggle_adblock'));
|
||||||
document.getElementById('downloads-btn').addEventListener('click', () => post('toggle_downloads'));
|
document.getElementById('downloads-btn').addEventListener('click', () => post('toggle_downloads'));
|
||||||
document.getElementById('add-btn').addEventListener('click', () => post('new_tab'));
|
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-yes').addEventListener('click', () => post('popup_allow_yes'));
|
||||||
document.getElementById('popup-no').addEventListener('click', () => post('popup_allow_no'));
|
document.getElementById('popup-no').addEventListener('click', () => post('popup_allow_no'));
|
||||||
|
|
||||||
|
|
@ -496,6 +534,38 @@ tabStrip.addEventListener('wheel', (ev) => {
|
||||||
tabStrip.scrollLeft += ev.deltaY;
|
tabStrip.scrollLeft += ev.deltaY;
|
||||||
}, { passive: false });
|
}, { 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) => {
|
const setSiteIcon = (url) => {
|
||||||
let state = 'search';
|
let state = 'search';
|
||||||
let hint = 'Search or enter address';
|
let hint = 'Search or enter address';
|
||||||
|
|
@ -617,8 +687,9 @@ const makeTab = (tab, i, activeId) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
window.__rustywolfSetTabs = (state) => {
|
window.__rustywolfSetTabs = (state) => {
|
||||||
tabStrip.innerHTML = '';
|
const addBtn = document.getElementById('add-btn');
|
||||||
state.tabs.forEach((tab, i) => tabStrip.appendChild(makeTab(tab, i, state.activeId)));
|
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);
|
const active = state.tabs.find((t) => t.id === state.activeId);
|
||||||
currentUrl = (active && active.url && active.url !== 'about:blank') ? active.url : '';
|
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_title(title)
|
||||||
.with_window_icon(load_window_icon())
|
.with_window_icon(load_window_icon())
|
||||||
.with_inner_size(LogicalSize::new(1000.0_f64, 600.0_f64))
|
.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)
|
.build(&event_loop)
|
||||||
.map_err(|err| anyhow!("Failed to create window: {err}"))?;
|
.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 (gtk_chrome_container, gtk_content_container) = if host_mode == WebViewHostMode::Window {
|
||||||
let (chrome, content) = create_linux_webview_containers(&window)?;
|
let (chrome, content) = create_linux_webview_containers(&window)?;
|
||||||
set_linux_chrome_container_height(&chrome, CHROME_HEIGHT_BASE);
|
set_linux_chrome_container_height(&chrome, CHROME_HEIGHT_BASE);
|
||||||
|
promote_chrome_to_titlebar(&window, &chrome)?;
|
||||||
(Some(chrome), Some(content))
|
(Some(chrome), Some(content))
|
||||||
} else {
|
} else {
|
||||||
(None, None)
|
(None, None)
|
||||||
|
|
@ -1095,6 +1170,7 @@ pub fn launch_webkit(args: &Cli) -> Result<()> {
|
||||||
};
|
};
|
||||||
|
|
||||||
open_tab(&mut state, &window, &proxy, start_url)?;
|
open_tab(&mut state, &window, &proxy, start_url)?;
|
||||||
|
push_window_state(&state, &window);
|
||||||
|
|
||||||
let tick_proxy = proxy.clone();
|
let tick_proxy = proxy.clone();
|
||||||
thread::spawn(move || {
|
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| {
|
event_loop.run(move |event, _, control_flow| {
|
||||||
*control_flow = ControlFlow::Wait;
|
*control_flow = ControlFlow::Wait;
|
||||||
match event {
|
match event {
|
||||||
|
|
@ -1121,6 +1200,7 @@ pub fn launch_webkit(args: &Cli) -> Result<()> {
|
||||||
..
|
..
|
||||||
} => {
|
} => {
|
||||||
reflow_layout(&window, &state);
|
reflow_layout(&window, &state);
|
||||||
|
push_window_state(&state, &window);
|
||||||
}
|
}
|
||||||
Event::WindowEvent {
|
Event::WindowEvent {
|
||||||
event: WindowEvent::CloseRequested,
|
event: WindowEvent::CloseRequested,
|
||||||
|
|
@ -1562,6 +1642,27 @@ fn handle_chrome_ipc(
|
||||||
return Ok(());
|
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 message == "close_active_tab" {
|
||||||
if !state.tabs.is_empty() {
|
if !state.tabs.is_empty() {
|
||||||
let tab_id = state.tabs[state.active].id;
|
let tab_id = state.tabs[state.active].id;
|
||||||
|
|
@ -1620,10 +1721,18 @@ fn build_chrome(
|
||||||
#[cfg(target_os = "linux")] gtk_chrome_container: Option<>k::Box>,
|
#[cfg(target_os = "linux")] gtk_chrome_container: Option<>k::Box>,
|
||||||
) -> Result<(WebView, WebViewHostMode)> {
|
) -> Result<(WebView, WebViewHostMode)> {
|
||||||
let chrome_rect = chrome_bounds(window, CHROME_HEIGHT_BASE, host_mode);
|
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 builder = || {
|
||||||
let chrome_proxy = proxy.clone();
|
let chrome_proxy = proxy.clone();
|
||||||
WebViewBuilder::new()
|
WebViewBuilder::new()
|
||||||
.with_html(CHROME_HTML)
|
.with_html(chrome_html.clone())
|
||||||
.with_bounds(chrome_rect)
|
.with_bounds(chrome_rect)
|
||||||
.with_ipc_handler(move |req| {
|
.with_ipc_handler(move |req| {
|
||||||
let _ = chrome_proxy.send_event(UserEvent::ChromeIpc(req.body().to_string()));
|
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);
|
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) {
|
fn push_popup_prompt(state: &BrowserState) {
|
||||||
let payload = match &state.pending_popup {
|
let payload = match &state.pending_popup {
|
||||||
Some(pending) => json!({
|
Some(pending) => json!({
|
||||||
|
|
@ -2176,6 +2297,20 @@ fn create_linux_webview_containers(window: &Window) -> Result<(gtk::Box, gtk::Bo
|
||||||
Ok((chrome, content))
|
Ok((chrome, content))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn promote_chrome_to_titlebar(window: &Window, chrome_container: >k::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")]
|
#[cfg(target_os = "linux")]
|
||||||
fn set_linux_chrome_container_height(container: >k::Box, chrome_h: f64) {
|
fn set_linux_chrome_container_height(container: >k::Box, chrome_h: f64) {
|
||||||
container.set_size_request(-1, chrome_h.max(0.0).round() as i32);
|
container.set_size_request(-1, chrome_h.max(0.0).round() as i32);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue