Size the rail to the traffic lights instead of guessing
The collapsed rail was a fixed 72px, which left the cluster with more room on its left than its right. The width now comes from the buttons themselves - the close button's own inset plus the zoom button's right edge - so the margin matches on both sides. That inset is macOS's to choose and has changed between releases, so it is asked for rather than assumed. Measured at startup rather than on demand. The answer arrives on the main thread, and a command waiting for it there deadlocks until the timeout and silently returns the fallback - which is what the first attempt did.
This commit is contained in:
@@ -150,7 +150,11 @@ ladder, and light and dark both designed rather than one derived from the other.
|
||||
- **Nav, expanded (~240px)** — traffic-light drag inset, title with cog and collapse
|
||||
chevron, then groups as collapsible sections with uppercase tracked labels, apps as
|
||||
favicon-and-name rows. The active row inverts to `bg-slate-900 text-white`.
|
||||
- **Nav, collapsed (~52px)** — favicons only, active marked with a left accent bar, name on
|
||||
- **Nav, collapsed** — as wide as the traffic lights need, which is asked of AppKit
|
||||
rather than guessed: the rail is the close button's own left inset plus the zoom
|
||||
button's right edge, so the cluster keeps the same margin on both sides. That inset is
|
||||
macOS's to choose and has changed between releases.
|
||||
- **Nav, collapsed (old note)** — favicons only, active marked with a left accent bar, name on
|
||||
hover, hairline dividers between groups. Still clickable, so switching does not require
|
||||
expanding.
|
||||
- **Top bar (~38px)** — back, forward, reload, the current URL muted and truncated, and
|
||||
|
||||
@@ -20,6 +20,10 @@ pub struct AppState {
|
||||
/// Title bar height: how far a child webview's origin sits above the
|
||||
/// content the shell measures from.
|
||||
pub chrome: Mutex<f64>,
|
||||
/// Narrowest the collapsed rail can be while the traffic lights keep equal
|
||||
/// margins. Measured at startup: asking on demand deadlocks, because the
|
||||
/// answer arrives on the main thread and a command may be waiting on it.
|
||||
pub rail: Mutex<f64>,
|
||||
/// When each app last raised a notification in its own words, so a count
|
||||
/// does not immediately say the same thing again in worse words.
|
||||
pub last_spoke: Mutex<std::collections::HashMap<String, std::time::Instant>>,
|
||||
@@ -83,6 +87,7 @@ pub fn build_state(handle: &AppHandle) -> Result<AppState, String> {
|
||||
dir,
|
||||
radius: Mutex::new(0.0),
|
||||
chrome: Mutex::new(0.0),
|
||||
rail: Mutex::new(72.0),
|
||||
last_spoke: Mutex::new(std::collections::HashMap::new()),
|
||||
pending_password: Mutex::new(None),
|
||||
config: Mutex::new(config),
|
||||
@@ -724,6 +729,24 @@ pub fn paint_window_chrome(app: &AppHandle, dark: bool) {
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn paint_window_chrome(_: &AppHandle, _: bool) {}
|
||||
|
||||
/// The narrowest the collapsed rail can be while the traffic lights keep the
|
||||
/// same margin on their right as macOS gave them on their left.
|
||||
#[tauri::command]
|
||||
pub fn rail_width(state: State<'_, AppState>) -> f64 {
|
||||
*state.rail.lock().unwrap()
|
||||
}
|
||||
|
||||
/// Works out that width, once, while nothing is waiting on the main thread.
|
||||
pub fn measure_rail(app: &AppHandle) -> f64 {
|
||||
match webviews::traffic_lights(app) {
|
||||
// Equal by construction: the left margin is the cluster's own inset, so
|
||||
// the same gap on the right means a rail of `right + left`.
|
||||
Some((left, right)) => right + left,
|
||||
// Nothing to measure against; the old fixed width, which was close.
|
||||
None => 72.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// How tall the title bar is, so the shell can keep clear of it.
|
||||
#[tauri::command]
|
||||
pub fn chrome_height(app: AppHandle) -> f64 {
|
||||
|
||||
@@ -91,6 +91,9 @@ pub fn run() {
|
||||
let chrome = webviews::measure_chrome(&handle);
|
||||
*handle.state::<commands::AppState>().chrome.lock().unwrap() = chrome;
|
||||
|
||||
let rail = commands::measure_rail(&handle);
|
||||
*handle.state::<commands::AppState>().rail.lock().unwrap() = rail;
|
||||
|
||||
let theme = app.state::<commands::AppState>().cfg().settings.theme;
|
||||
commands::apply_window_theme(&app.handle().clone(), &theme);
|
||||
Ok(())
|
||||
@@ -120,6 +123,7 @@ pub fn run() {
|
||||
commands::reset_config,
|
||||
commands::set_window_chrome,
|
||||
commands::chrome_height,
|
||||
commands::rail_width,
|
||||
commands::unread_counts,
|
||||
commands::focus_window,
|
||||
commands::save_password,
|
||||
|
||||
@@ -805,6 +805,61 @@ pub fn measure_chrome(_: &AppHandle) -> f64 {
|
||||
0.0
|
||||
}
|
||||
|
||||
/// Where the traffic lights actually sit, as (left inset, right edge) in
|
||||
/// logical pixels.
|
||||
///
|
||||
/// Asked rather than assumed. The collapsed rail has to be wide enough to give
|
||||
/// the cluster the same margin on its right as macOS gives it on its left, and
|
||||
/// those numbers are macOS's to choose — they differ by window style and have
|
||||
/// changed between releases.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn traffic_lights(handle: &AppHandle) -> Option<(f64, f64)> {
|
||||
use objc2::runtime::AnyObject;
|
||||
use objc2_foundation::NSRect;
|
||||
|
||||
let wv = handle.get_webview_window("main")?;
|
||||
let (tx, rx) = std::sync::mpsc::channel::<Option<(f64, f64)>>();
|
||||
|
||||
let sent = wv.with_webview(move |platform| unsafe {
|
||||
let view = platform.inner() as *mut AnyObject;
|
||||
if view.is_null() {
|
||||
let _ = tx.send(None);
|
||||
return;
|
||||
}
|
||||
let window: *mut AnyObject = objc2::msg_send![view, window];
|
||||
if window.is_null() {
|
||||
let _ = tx.send(None);
|
||||
return;
|
||||
}
|
||||
|
||||
// NSWindowCloseButton = 0, NSWindowZoomButton = 2.
|
||||
let close: *mut AnyObject = objc2::msg_send![window, standardWindowButton: 0isize];
|
||||
let zoom: *mut AnyObject = objc2::msg_send![window, standardWindowButton: 2isize];
|
||||
if close.is_null() || zoom.is_null() {
|
||||
let _ = tx.send(None);
|
||||
return;
|
||||
}
|
||||
|
||||
let nil: *mut AnyObject = std::ptr::null_mut();
|
||||
let cb: NSRect = objc2::msg_send![close, bounds];
|
||||
let zb: NSRect = objc2::msg_send![zoom, bounds];
|
||||
let c: NSRect = objc2::msg_send![close, convertRect: cb, toView: nil];
|
||||
let z: NSRect = objc2::msg_send![zoom, convertRect: zb, toView: nil];
|
||||
|
||||
let _ = tx.send(Some((c.origin.x, z.origin.x + z.size.width)));
|
||||
});
|
||||
|
||||
if sent.is_err() {
|
||||
return None;
|
||||
}
|
||||
rx.recv_timeout(std::time::Duration::from_millis(400)).ok().flatten()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn traffic_lights(_: &AppHandle) -> Option<(f64, f64)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The title bar's height, for the shell to keep clear of.
|
||||
///
|
||||
/// Not an offset for positioning apps. The window's content view runs the full
|
||||
|
||||
@@ -27,6 +27,7 @@ export default function App() {
|
||||
opaque bar was only hiding that. With the bar painted the nav's colour, the
|
||||
shell has to keep clear of it or the nav lands on the traffic lights. */
|
||||
const [chrome, setChrome] = useState(0);
|
||||
const [rail, setRail] = useState(72);
|
||||
const [offer, setOffer] = useState<PasswordOffer | null>(null);
|
||||
const [saved, setSaved] = useState<string | null>(null);
|
||||
|
||||
@@ -42,6 +43,7 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
void api.chromeHeight().then(setChrome);
|
||||
void api.railWidth().then(setRail);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -215,6 +217,7 @@ export default function App() {
|
||||
onReload={() => activeId && api.historyGo(activeId, 0)}
|
||||
unread={unread}
|
||||
chrome={chrome}
|
||||
rail={rail}
|
||||
/>
|
||||
|
||||
{/* The hole an app's native webview is positioned into. It stays empty
|
||||
|
||||
@@ -66,3 +66,4 @@ export const setWindowChrome = (dark: boolean) =>
|
||||
export const chromeHeight = () => invoke<number>("chrome_height");
|
||||
export const setCountNotifications = (enabled: boolean) =>
|
||||
invoke<Config>("set_count_notifications", { enabled });
|
||||
export const railWidth = () => invoke<number>("rail_width");
|
||||
|
||||
@@ -17,21 +17,20 @@ interface Props {
|
||||
onReload: () => void;
|
||||
/** Height of the title bar the window's content runs underneath. */
|
||||
chrome: number;
|
||||
/**
|
||||
* How wide the collapsed rail has to be for the traffic lights to keep the
|
||||
* same margin on their right as macOS gave them on their left. Measured from
|
||||
* the buttons themselves, since that inset is macOS's to choose.
|
||||
*/
|
||||
rail: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wide enough that the macOS traffic lights fit inside the rail rather than
|
||||
* spilling over the page. Everything else about the rail follows from that.
|
||||
*/
|
||||
const RAIL = 72;
|
||||
const PANEL = 240;
|
||||
|
||||
export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL);
|
||||
|
||||
export default function Nav({
|
||||
config, activeId, collapsed, unread,
|
||||
onSelect, onToggleCollapse, onOpenSettings, onToggleGroup,
|
||||
onBack, onForward, onReload, chrome,
|
||||
onBack, onForward, onReload, chrome, rail,
|
||||
}: Props) {
|
||||
const groups = [...config.groups].sort((a, b) => a.order - b.order);
|
||||
const inGroup = (id: string | null) =>
|
||||
@@ -125,7 +124,7 @@ export default function Nav({
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className={`${shell} items-center`} style={{ width: RAIL }}>
|
||||
<aside className={`${shell} items-center`} style={{ width: rail }}>
|
||||
<div data-tauri-drag-region className="w-full shrink-0" style={{ height: chrome }} />
|
||||
{/* Only the expander survives the rail's header. Back and forward are
|
||||
a two-finger swipe and reload is ⌘R, so a toolbar here would be
|
||||
|
||||
Reference in New Issue
Block a user