//! The command surface. The shell owns "which app is active"; Rust owns the //! webviews and the file on disk. use std::path::PathBuf; use std::sync::Mutex; use tauri::{AppHandle, Emitter, Manager, State}; use url::Url; use crate::config::{self, App, Config, Group}; use crate::webviews; /// Stage rect in logical pixels: x, y, width, height. pub type Stage = (f64, f64, f64, f64); pub struct AppState { pub dir: PathBuf, /// Corner radius the window's inner frame is currently drawn with. pub radius: Mutex, /// Title bar height: how far a child webview's origin sits above the /// content the shell measures from. pub chrome: Mutex, /// A login waiting on an answer: host, account, password. Held only until /// it is saved or declined, and never written anywhere but the Keychain. pub pending_password: Mutex>, pub config: Mutex, pub active: Mutex>, pub stage: Mutex, /// Webviews exist. Guards against bootstrapping twice on a hot reload. pub booted: Mutex, /// What each app's page last reported about itself, keyed by app id. pub diag: Mutex>, /// The last notification a page raised, and what macOS did with it. pub last_notification: Mutex, /// How much has arrived in each app since you last looked at it. pub unread: Mutex>, /// Size of the last dialog backdrop still, or why there wasn't one. pub last_snapshot: Mutex, } impl AppState { pub fn cfg(&self) -> Config { self.config.lock().unwrap().clone() } fn persist(&self) -> Result<(), String> { config::save(&self.dir, &self.config.lock().unwrap()) } /// Same, for the sentinel handlers that live outside this module. pub fn save(&self) -> Result<(), String> { self.persist() } /// Records a selector chosen by right-clicking it in the page. pub fn add_hidden(&self, app_id: &str, selector: &str) -> Result<(), String> { { let mut cfg = self.config.lock().unwrap(); let app = cfg .apps .iter_mut() .find(|a| a.id == app_id) .ok_or_else(|| format!("no app {app_id}"))?; if app.hidden.iter().any(|s| s == selector) { return Ok(()); } app.hidden.push(selector.to_string()); } self.persist() } } pub fn build_state(handle: &AppHandle) -> Result { let dir = handle .path() .app_config_dir() .map_err(|e| format!("no config directory: {e}"))?; let config = config::load(&dir)?; Ok(AppState { dir, radius: Mutex::new(0.0), chrome: Mutex::new(0.0), pending_password: Mutex::new(None), config: Mutex::new(config), active: Mutex::new(None), stage: Mutex::new((240.0, 38.0, 800.0, 600.0)), booted: Mutex::new(false), diag: Mutex::new(std::collections::HashMap::new()), last_notification: Mutex::new(String::new()), unread: Mutex::new(std::collections::HashMap::new()), last_snapshot: Mutex::new(String::new()), }) } #[tauri::command] pub fn get_config(state: State<'_, AppState>) -> Config { state.cfg() } /// Records the rect the shell has left for an app, and resizes the one showing. #[tauri::command] pub fn set_stage( x: f64, y: f64, width: f64, height: f64, radius: f64, app: AppHandle, state: State<'_, AppState>, ) { let stage = (x, y, width.max(0.0), height.max(0.0)); *state.stage.lock().unwrap() = stage; *state.radius.lock().unwrap() = radius; let active = state.active.lock().unwrap().clone(); webviews::set_stage(&app, active.as_deref(), stage, radius); } /// Creates every app's webview: the active one first, the rest staggered. /// /// Called by the shell once it has measured the stage, so the views are born /// at the right size instead of being built against a guess and corrected. #[tauri::command] pub fn bootstrap(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { { let mut booted = state.booted.lock().unwrap(); if *booted { return Ok(()); } *booted = true; } let cfg = state.cfg(); let stage = *state.stage.lock().unwrap(); let active = state.active.lock().unwrap().clone(); let first = active .clone() .or_else(|| cfg.ordered().first().map(|a| a.id.clone())); if let Some(id) = &first { if let Some(a) = cfg.app(id) { webviews::create(&app, a, &cfg, stage)?; webviews::set_corner_radius(&app, &a.id, *state.radius.lock().unwrap()); *state.active.lock().unwrap() = Some(id.clone()); webviews::show_only(&app, Some(id), &cfg, stage); } } // The rest follow with a gap, so launching does not fire a dozen // simultaneous page loads at the network and the CPU. let rest: Vec = cfg .ordered() .into_iter() .filter(|a| Some(&a.id) != first.as_ref()) .cloned() .collect(); tauri::async_runtime::spawn(async move { for a in rest { tokio::time::sleep(std::time::Duration::from_millis(350)).await; let state = app.state::(); let cfg = state.cfg(); let stage = *state.stage.lock().unwrap(); if let Err(e) = webviews::create(&app, &a, &cfg, stage) { eprintln!("could not create webview for {}: {e}", a.name); } // Each new view is added on top of its siblings, so the app being // looked at has to be put back in front after every one. let active = state.active.lock().unwrap().clone(); webviews::show_only(&app, active.as_deref(), &cfg, stage); } }); Ok(()) } #[tauri::command] pub fn set_active(app_id: String, app: AppHandle, state: State<'_, AppState>) { let cfg = state.cfg(); let stage = *state.stage.lock().unwrap(); *state.active.lock().unwrap() = Some(app_id.clone()); webviews::show_only(&app, Some(&app_id), &cfg, stage); // Looking at an app is what clears its count. Nothing else does. state.unread.lock().unwrap().remove(&app_id); let _ = app.emit("unread-changed", unread_list(&state)); } /// Every app's count, in the shape the nav wants. pub fn unread_list(state: &AppState) -> Vec<(String, u32)> { state .unread .lock() .unwrap() .iter() .map(|(k, v)| (k.clone(), *v)) .collect() } #[tauri::command] pub fn unread_counts(state: State<'_, AppState>) -> Vec<(String, u32)> { unread_list(&state) } /// Hides every app, so a dialog is not painted over by a native view. /// A still of the app on screen, taken before a dialog covers it. /// /// Async, and the wait happens on a blocking worker: the snapshot's completion /// handler runs on the main thread, so waiting for it *on* the main thread /// deadlocks until the timeout and returns nothing every time. #[tauri::command] pub async fn stage_snapshot(app: AppHandle) -> Option { let id = { let state = app.state::(); let active = state.active.lock().unwrap(); active.clone()? }; let handle = app.clone(); let shot = tauri::async_runtime::spawn_blocking(move || webviews::snapshot(&app, &id)) .await .ok() .flatten(); // Recorded so the diagnostic can say whether the still was taken at all, // rather than leaving a flat backdrop to be interpreted by eye. *handle.state::().last_snapshot.lock().unwrap() = match &shot { Some(d) => format!("{} bytes", d.len()), None => "none".into(), }; shot } #[tauri::command] pub fn hide_stage(app: AppHandle, state: State<'_, AppState>) { let stage = *state.stage.lock().unwrap(); webviews::hide_all(&app, &state.cfg(), stage); } /// Puts the active app back after a dialog closes. #[tauri::command] pub fn show_stage(app: AppHandle, state: State<'_, AppState>) { let cfg = state.cfg(); let stage = *state.stage.lock().unwrap(); let active = state.active.lock().unwrap().clone(); webviews::show_only(&app, active.as_deref(), &cfg, stage); } #[tauri::command] pub fn navigate_app(app_id: String, url: String, app: AppHandle) -> Result<(), String> { let wv = app .get_webview(&webviews::label_for(&app_id)) .ok_or_else(|| format!("{app_id} has no webview"))?; let url = Url::parse(&url).map_err(|e| e.to_string())?; wv.navigate(url).map_err(|e| e.to_string()) } /// Back, forward and reload, run inside the app's own page. #[tauri::command] pub fn history_go(app_id: String, delta: i32, app: AppHandle) -> Result<(), String> { let wv = app .get_webview(&webviews::label_for(&app_id)) .ok_or_else(|| format!("{app_id} has no webview"))?; let script = if delta == 0 { "location.reload()".to_string() } else { format!("history.go({delta})") }; wv.eval(&script).map_err(|e| e.to_string()) } #[tauri::command] pub fn current_url(app_id: String, app: AppHandle) -> Option { app.get_webview(&webviews::label_for(&app_id)) .and_then(|wv| wv.url().ok()) .map(|u| u.to_string()) } #[tauri::command] pub fn open_external(url: String) -> Result<(), String> { tauri_plugin_opener::open_url(url, None::<&str>).map_err(|e| e.to_string()) } // ---------------------------------------------------------------- apps #[tauri::command] pub fn add_app( name: String, url: String, group_id: Option, app: AppHandle, state: State<'_, AppState>, ) -> Result { let url = config::normalize_url(&url); let scope = config::default_scope(&url) .ok_or_else(|| format!("{url} is not a URL this can open"))?; let new = { let mut cfg = state.config.lock().unwrap(); let order = cfg .apps .iter() .filter(|a| a.group_id == group_id) .map(|a| a.order) .max() .map_or(0, |m| m + 1); let new = App { id: config::new_id(), name: name.trim().to_string(), url, scope: vec![scope], group_id, user_agent: None, hidden: Vec::new(), icon: None, zoom: 1.0, order, }; cfg.apps.push(new.clone()); new }; state.persist()?; let cfg = state.cfg(); let stage = *state.stage.lock().unwrap(); if *state.booted.lock().unwrap() { webviews::create(&app, &new, &cfg, stage)?; let active = state.active.lock().unwrap().clone(); webviews::show_only(&app, active.as_deref(), &cfg, stage); } Ok(cfg) } /// Applies an edit. A change to url, scope or user agent rebuilds the webview, /// since none of the three can be altered on a live one. #[tauri::command] pub fn update_app( updated: App, app: AppHandle, state: State<'_, AppState>, ) -> Result { let rebuild = { let mut cfg = state.config.lock().unwrap(); let existing = cfg .apps .iter_mut() .find(|a| a.id == updated.id) .ok_or_else(|| format!("no app {}", updated.id))?; let rebuild = existing.url != updated.url || existing.scope != updated.scope || existing.user_agent != updated.user_agent; *existing = updated.clone(); rebuild }; state.persist()?; let cfg = state.cfg(); let stage = *state.stage.lock().unwrap(); if rebuild && *state.booted.lock().unwrap() { webviews::destroy(&app, &updated.id); webviews::create(&app, &updated, &cfg, stage)?; let active = state.active.lock().unwrap().clone(); webviews::show_only(&app, active.as_deref(), &cfg, stage); } Ok(cfg) } #[tauri::command] pub fn delete_app( app_id: String, app: AppHandle, state: State<'_, AppState>, ) -> Result { { let mut cfg = state.config.lock().unwrap(); cfg.apps.retain(|a| a.id != app_id); } state.persist()?; webviews::destroy(&app, &app_id); let mut active = state.active.lock().unwrap(); if active.as_deref() == Some(app_id.as_str()) { *active = None; } Ok(state.cfg()) } /// Rewrites group membership and position in one go, for a drag. #[tauri::command] pub fn reorder_apps( ordering: Vec<(String, Option, i32)>, state: State<'_, AppState>, ) -> Result { { let mut cfg = state.config.lock().unwrap(); for (id, group_id, order) in ordering { if let Some(a) = cfg.apps.iter_mut().find(|a| a.id == id) { a.group_id = group_id; a.order = order; } } } state.persist()?; Ok(state.cfg()) } // -------------------------------------------------------------- groups #[tauri::command] pub fn add_group(name: String, state: State<'_, AppState>) -> Result { { let mut cfg = state.config.lock().unwrap(); let order = cfg.groups.iter().map(|g| g.order).max().map_or(0, |m| m + 1); cfg.groups.push(Group { id: config::new_id(), name: name.trim().to_string(), collapsed: false, order, }); } state.persist()?; Ok(state.cfg()) } #[tauri::command] pub fn update_group(updated: Group, state: State<'_, AppState>) -> Result { { let mut cfg = state.config.lock().unwrap(); let g = cfg .groups .iter_mut() .find(|g| g.id == updated.id) .ok_or_else(|| format!("no group {}", updated.id))?; *g = updated; } state.persist()?; Ok(state.cfg()) } /// Removes a group. Its apps survive, ungrouped — deleting a folder should /// never be a way to lose the things inside it by accident. #[tauri::command] pub fn delete_group(group_id: String, state: State<'_, AppState>) -> Result { { let mut cfg = state.config.lock().unwrap(); cfg.groups.retain(|g| g.id != group_id); for a in cfg.apps.iter_mut() { if a.group_id.as_deref() == Some(group_id.as_str()) { a.group_id = None; } } } state.persist()?; Ok(state.cfg()) } // ------------------------------------------------------------ settings #[tauri::command] pub fn set_nav_collapsed(collapsed: bool, state: State<'_, AppState>) -> Result<(), String> { state.config.lock().unwrap().settings.nav_collapsed = collapsed; state.persist() } #[tauri::command] pub fn set_theme(theme: String, app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { state.config.lock().unwrap().settings.theme = theme.clone(); apply_window_theme(&app, &theme); state.persist() } /// Puts the window's own title bar in the same light or dark as the app. /// /// `None` hands it back to the system, which is what "System" means — the bar /// then follows the OS the way every other window does. pub fn apply_window_theme(app: &AppHandle, theme: &str) { let wanted = match theme { "light" => Some(tauri::Theme::Light), "dark" => Some(tauri::Theme::Dark), _ => None, }; if let Some(w) = app.get_window("main") { let _ = w.set_theme(wanted); } } // ------------------------------------------------------- hidden elements /// Replaces an app's hidden selectors and re-applies them without a reload. #[tauri::command] pub fn set_hidden( app_id: String, hidden: Vec, app: AppHandle, state: State<'_, AppState>, ) -> Result { { let mut cfg = state.config.lock().unwrap(); let target = cfg .apps .iter_mut() .find(|a| a.id == app_id) .ok_or_else(|| format!("no app {app_id}"))?; target.hidden = hidden.clone(); } state.persist()?; webviews::push_hidden(&app, &app_id, &hidden); Ok(state.cfg()) } /// Permission state, and what a notification raised straight from Rust does. /// /// Splits the chain in two: if this succeeds and the page test does not, the /// shim is at fault; if this fails, macOS never granted permission. #[tauri::command] pub fn notification_status(app: AppHandle) -> String { use tauri_plugin_notification::NotificationExt; let state = match app.notification().permission_state() { Ok(s) => format!("{s:?}"), Err(e) => format!("unknown ({e})"), }; let raised = match app .notification() .builder() .title("Work") .body("Notifications are working.") .show() { Ok(()) => "raised".to_string(), Err(e) => format!("failed: {e}"), }; let app_state = app.state::(); let last = app_state.last_notification.lock().unwrap().clone(); let from_page = if last.is_empty() { "none yet".into() } else { last }; let stage = *app_state.stage.lock().unwrap(); let offset = webviews::chrome_offset(&app); let shot = app_state.last_snapshot.lock().unwrap().clone(); let shot = if shot.is_empty() { "not taken".into() } else { shot }; format!( "permission: {state} · direct: {raised} · from page: {from_page} · backdrop: {shot} \ · stage: {:.0},{:.0} {:.0}×{:.0} · chrome offset: {offset:.1}", stage.0, stage.1, stage.2, stage.3 ) } /// Asks macOS for notification permission, and claims this app's identity. /// /// The identity has to be set explicitly. Left alone, mac-notification-sys /// looks up an application literally named "use_default", fails, and falls back /// to `com.apple.Finder` — so every notification arrives as Finder, wearing /// Finder's alert style, which is why they land silently in Notification Centre /// instead of on screen. It is registered before the first notification because /// the crate's own lazy fallback runs once and cannot be corrected afterwards. pub fn ensure_notification_permission(app: &AppHandle) { use tauri_plugin_notification::NotificationExt; #[cfg(target_os = "macos")] { let bundle = app.config().identifier.clone(); if let Err(e) = mac_notification_sys::set_application(&bundle) { eprintln!("could not claim {bundle} for notifications: {e}"); } present_notifications_while_frontmost(); } let granted = matches!( app.notification().permission_state(), Ok(tauri_plugin_notification::PermissionState::Granted) ); if !granted { if let Err(e) = app.notification().request_permission() { eprintln!("notification permission was refused: {e}"); } } } /// Fires a notification the way a page would. /// /// Deliberately routed through the injected shim rather than raised directly: /// the thing worth testing is the whole chain — page API, sentinel, Rust, and /// macOS — not whether this process can show a notification. #[tauri::command] pub fn test_notification(app_id: String, app: AppHandle) -> Result<(), String> { let wv = app .get_webview(&webviews::label_for(&app_id)) .ok_or_else(|| format!("{app_id} has no webview"))?; // The probe reports what it found before raising anything, so a // notification that never appears still says why. wv.eval( r#"(function () { var kind = typeof window.Notification; var shim = !!(window.Notification && window.Notification.__work); var err = ''; try { new Notification('Test notification', { body: 'If you can see this, pages can reach you.' }); } catch (e) { err = String(e); } if (window.__workAppSend) { window.__workAppSend('diag', { api: kind, shim: shim ? 'yes' : 'no', err: err }); } })();"#, ) .map_err(|e| e.to_string()) } // ----------------------------------------------------------------- zoom /// The zoom ladder, so a keystroke lands on a sensible size rather than /// drifting by a multiplier that never returns to exactly 100%. const ZOOM_STEPS: [f64; 13] = [ 0.5, 0.67, 0.75, 0.8, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, ]; fn step_zoom(current: f64, direction: i32) -> f64 { // The nearest rung, so a hand-edited value still moves somewhere sane. let idx = ZOOM_STEPS .iter() .enumerate() .min_by(|a, b| { (a.1 - current).abs().partial_cmp(&(b.1 - current).abs()).unwrap() }) .map(|(i, _)| i as i32) .unwrap_or(5); let next = (idx + direction).clamp(0, ZOOM_STEPS.len() as i32 - 1); ZOOM_STEPS[next as usize] } /// Applies a zoom change to whichever app is showing, and remembers it. /// /// Per app, because a dense ERP and a mail client do not want the same size. pub fn adjust_zoom(app: &AppHandle, direction: Option) { let state = app.state::(); let Some(id) = state.active.lock().unwrap().clone() else { return }; let zoom = { let mut cfg = state.config.lock().unwrap(); let Some(target) = cfg.apps.iter_mut().find(|a| a.id == id) else { return }; target.zoom = match direction { Some(d) => step_zoom(target.zoom, d), None => 1.0, }; target.zoom }; let _ = state.persist(); if let Some(wv) = app.get_webview(&webviews::label_for(&id)) { let _ = wv.set_zoom(zoom); } let _ = app.emit("zoom-changed", (id, zoom)); } #[tauri::command] pub fn set_zoom(app_id: String, zoom: f64, app: AppHandle, state: State<'_, AppState>) -> Result { { let mut cfg = state.config.lock().unwrap(); let target = cfg .apps .iter_mut() .find(|a| a.id == app_id) .ok_or_else(|| format!("no app {app_id}"))?; target.zoom = zoom.clamp(0.25, 5.0); } state.persist()?; if let Some(wv) = app.get_webview(&webviews::label_for(&app_id)) { let _ = wv.set_zoom(zoom); } Ok(state.cfg()) } /// Makes macOS show a banner even when this app is the frontmost one. /// /// `NSUserNotificationCenter` suppresses the banner whenever the posting /// application is in front, delivering it silently to Notification Centre /// instead, unless the delegate says otherwise. That default is wrong here: an /// app showing Odoo is still frontmost when Gmail — a hidden webview in the /// same window — has something to say, and that notification is the entire /// point of keeping the other apps loaded. /// /// The delegate belongs to mac-notification-sys and implements only delivery /// and activation, so the missing method is added to its class at runtime. #[cfg(target_os = "macos")] fn present_notifications_while_frontmost() { use objc2::ffi::class_addMethod; use objc2::runtime::{AnyClass, AnyObject, Bool, Sel}; extern "C" fn should_present( _this: *mut AnyObject, _cmd: Sel, _center: *mut AnyObject, _notification: *mut AnyObject, ) -> Bool { Bool::YES } let Some(class) = AnyClass::get(c"NotificationCenterDelegate") else { eprintln!("notification delegate class is missing; banners will stay silent"); return; }; let added = unsafe { class_addMethod( class as *const AnyClass as *mut AnyClass, objc2::sel!(userNotificationCenter:shouldPresentNotification:), std::mem::transmute::<*const (), unsafe extern "C-unwind" fn()>( should_present as *const (), ), c"c@:@@".as_ptr(), ) }; if !added.as_bool() { eprintln!("could not force banner presentation; notifications may stay silent"); } } /// Reloads whichever app is showing, for the menu bar's Reload item. pub fn reload_active(app: &AppHandle) { let state = app.state::(); let Some(id) = state.active.lock().unwrap().clone() else { return }; if let Some(wv) = app.get_webview(&webviews::label_for(&id)) { let _ = wv.eval("location.reload()"); } } /// Asks every app's page what it currently believes about itself. /// /// Built because guessing was not working: it separates "the page is asleep" /// from "the page is awake and thinks it is on screen" from "the page is awake, /// knows it is hidden, and simply has nothing to say". #[tauri::command] pub fn probe_apps(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> { state.diag.lock().unwrap().clear(); for a in state.cfg().apps { let Some(wv) = app.get_webview(&webviews::label_for(&a.id)) else { continue }; let _ = wv.eval( r#"(function () { if (!window.__workAppSend) return; var st = window.__workAppState ? window.__workAppState() : {}; window.__workAppSend('diag', { title: String(document.title || '').slice(0, 32), active: String(st.active), seen: String(st.seen), // The three ways an app can say something arrived. notified: String(st.raised), badged: String(st.badged), sw: typeof (window.ServiceWorkerRegistration || {}).prototype }); })();"#, ); } Ok(()) } /// What every app reported, from the newest run only. #[tauri::command] pub fn app_reports(state: State<'_, AppState>) -> Vec<(String, String)> { let cfg = state.cfg(); let diag = state.diag.lock().unwrap(); cfg.apps .iter() .map(|a| { let report = diag .get(&a.id) .cloned() .unwrap_or_else(|| "no reply — page is asleep or gone".into()); (a.name.clone(), report) }) .collect() } // ------------------------------------------------------------ passwords /// Writes the offered login to the macOS Keychain. /// /// The Keychain, not `apps.json`: it is encrypted at rest, unlocked with the /// login session, and the one place on this machine that is actually built to /// hold a password. The value never touches the config file, the logs, or the /// shell. #[tauri::command] pub fn save_password(state: State<'_, AppState>) -> Result { let offer = state.pending_password.lock().unwrap().take(); let Some((host, account, password)) = offer else { return Err("nothing waiting to be saved".into()); }; #[cfg(target_os = "macos")] { let service = format!("Work — {host}"); security_framework::passwords::set_generic_password( &service, &account, password.as_bytes(), ) .map_err(|e| format!("the Keychain refused it: {e}"))?; } #[cfg(not(target_os = "macos"))] let _ = password; Ok(host) } /// Drops the offered login without saving it. #[tauri::command] pub fn discard_password(state: State<'_, AppState>) { *state.pending_password.lock().unwrap() = None; } // -------------------------------------------------- notification clicks /// Runs the page's own click handler for a notification it raised. /// /// This is what makes a click land on the message rather than merely on the /// app: Gmail's handler knows which thread it was about, and this app does not /// and should not. #[tauri::command] pub fn notification_click(app_id: String, notification_id: String, app: AppHandle) -> Result<(), String> { let wv = app .get_webview(&webviews::label_for(&app_id)) .ok_or_else(|| format!("{app_id} has no webview"))?; let escaped = notification_id.replace('\\', "\\\\").replace('\'', "\\'"); wv.eval(&format!( "window.__workAppNotifyClick && window.__workAppNotifyClick('{escaped}')" )) .map_err(|e| e.to_string()) } /// Brings the window forward when a notification is clicked. #[tauri::command] pub fn focus_window(app: AppHandle) { if let Some(w) = app.get_window("main") { let _ = w.unminimize(); let _ = w.show(); let _ = w.set_focus(); } } #[cfg(test)] mod tests { use super::*; #[test] fn zoom_steps_up_and_down_the_ladder() { assert_eq!(step_zoom(1.0, 1), 1.1); assert_eq!(step_zoom(1.0, -1), 0.9); assert_eq!(step_zoom(1.25, 1), 1.5); } #[test] fn zoom_stops_at_the_ends_rather_than_wrapping() { assert_eq!(step_zoom(3.0, 1), 3.0); assert_eq!(step_zoom(0.5, -1), 0.5); } #[test] fn an_off_ladder_value_snaps_to_its_nearest_rung() { // A hand-edited apps.json should still zoom somewhere sensible. assert_eq!(step_zoom(1.04, 1), 1.1); assert_eq!(step_zoom(1.04, -1), 0.9); } }