extern crate image;
use crate::Config;
use crate::config::MenuBarVisibility;
use crate::errors::BrowserError;
use crate::external_open::{
SiteOrigin, apply_decision, decide_without_frame_info, mark_gui_active, open_external,
parse_ipc_open_request,
};
use crate::server::{Server, ServerConfig};
use muda::{
AboutMetadata, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu,
accelerator::{Accelerator, Code, Modifiers},
};
use parking_lot::RwLock;
use std::path::PathBuf;
use std::sync::Arc;
use tao::{
event::{ElementState, Event, StartCause, WindowEvent},
event_loop::{ControlFlow, EventLoopBuilder, EventLoopProxy},
keyboard::ModifiersState,
window::{Icon, WindowBuilder},
};
#[cfg(target_os = "linux")]
use tao::keyboard::Key;
#[cfg(not(target_os = "linux"))]
use tao::keyboard::KeyCode;
use tokio::task::JoinHandle;
#[cfg(target_os = "linux")]
use wry::WebViewBuilderExtUnix;
use wry::{NewWindowResponse, WebViewBuilder};
const FIND_OPEN_SCRIPT: &str = "(()=>{let n=0;const go=()=>{\
const e=document.querySelector('mbr-find-bar');\
if(e&&typeof e.open==='function'){e.open();return;}\
if(++n<40)setTimeout(go,25);\
};go();})()";
const FIND_NEXT_SCRIPT: &str = "(()=>{\
const e=document.querySelector('mbr-find-bar');\
if(e&&typeof e.findNext==='function')e.findNext();\
})()";
const FIND_PREV_SCRIPT: &str = "(()=>{\
const e=document.querySelector('mbr-find-bar');\
if(e&&typeof e.findPrevious==='function')e.findPrevious();\
})()";
enum UserEvent {
MenuEvent(MenuEvent),
PathSelected(PathBuf),
ServerReady {
handle: JoinHandle<()>,
url: String,
},
ServerFailed {
path: PathBuf,
},
}
pub struct BrowserContext {
pub url: String,
pub server_handle: JoinHandle<()>,
pub config: Config,
pub tokio_runtime: tokio::runtime::Handle,
}
pub enum InitialLaunch {
Known(Box<BrowserContext>),
#[cfg(target_os = "macos")]
Deferred {
tokio_runtime: tokio::runtime::Handle,
},
}
fn about_metadata() -> AboutMetadata {
AboutMetadata {
name: Some("mbr".to_string()),
version: Some(env!("CARGO_PKG_VERSION").to_string()),
short_version: Some(env!("CARGO_PKG_VERSION").to_string()),
authors: Some(vec!["zmre".to_string()]),
comments: Some("A markdown viewer and browser".to_string()),
copyright: Some("Copyright © 2025".to_string()),
license: Some("MIT".to_string()),
website: Some("https://github.com/zmre/mbr".to_string()),
website_label: Some("GitHub".to_string()),
..Default::default()
}
}
fn log_menu_result(what: &str, result: Result<(), muda::Error>) {
if let Err(e) = result {
tracing::error!("Failed to append {what}: {e}");
}
}
struct HistoryMenuItems {
back: MenuItem,
forward: MenuItem,
}
struct FindMenuItems {
open: MenuItem,
next: MenuItem,
prev: MenuItem,
}
struct MenuHandles {
menu_bar: Menu,
open_item: MenuItem,
reload_item: MenuItem,
print_item: MenuItem,
history_items: HistoryMenuItems,
find_items: FindMenuItems,
window_menu: Submenu,
}
fn build_menu_bar() -> MenuHandles {
let menu_bar = Menu::new();
#[cfg(target_os = "macos")]
let app_menu = {
let app_menu = Submenu::new("mbr", true);
log_menu_result(
"app menu items",
app_menu.append_items(&[
&PredefinedMenuItem::about(None, Some(about_metadata())),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::services(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::hide(None),
&PredefinedMenuItem::hide_others(None),
&PredefinedMenuItem::show_all(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
]),
);
app_menu
};
let file_menu = Submenu::new("&File", true);
#[cfg(target_os = "macos")]
let command_modifier = Modifiers::META;
#[cfg(not(target_os = "macos"))]
let command_modifier = Modifiers::CONTROL;
let open_item = MenuItem::with_id(
"open",
"&Open...",
true,
Some(Accelerator::new(command_modifier, Code::KeyO)),
);
let reload_item = MenuItem::with_id(
"reload",
"&Reload",
true,
Some(Accelerator::new(command_modifier, Code::KeyR)),
);
#[cfg(target_os = "macos")]
let print_modifier = command_modifier;
#[cfg(not(target_os = "macos"))]
let print_modifier = Modifiers::CONTROL | Modifiers::SHIFT;
let print_item = MenuItem::with_id(
"print",
"&Print…",
true,
Some(Accelerator::new(print_modifier, Code::KeyP)),
);
#[cfg(target_os = "macos")]
log_menu_result(
"file menu items",
file_menu.append_items(&[
&open_item,
&PredefinedMenuItem::separator(),
&reload_item,
&PredefinedMenuItem::separator(),
&print_item,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(Some("Close Window")),
]),
);
#[cfg(not(target_os = "macos"))]
log_menu_result(
"file menu items",
file_menu.append_items(&[
&open_item,
&PredefinedMenuItem::separator(),
&reload_item,
&PredefinedMenuItem::separator(),
&print_item,
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::close_window(Some("Close Window")),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::quit(None),
]),
);
#[cfg(target_os = "macos")]
let find_accelerator = Accelerator::new(Modifiers::META, Code::KeyF);
#[cfg(not(target_os = "macos"))]
let find_accelerator = Accelerator::new(Modifiers::CONTROL, Code::KeyF);
#[cfg(target_os = "macos")]
let find_next_accelerator = Accelerator::new(Modifiers::META, Code::KeyG);
#[cfg(not(target_os = "macos"))]
let find_next_accelerator = Accelerator::new(Modifiers::empty(), Code::F3);
#[cfg(target_os = "macos")]
let find_prev_accelerator = Accelerator::new(Modifiers::META | Modifiers::SHIFT, Code::KeyG);
#[cfg(not(target_os = "macos"))]
let find_prev_accelerator = Accelerator::new(Modifiers::SHIFT, Code::F3);
let find_item = MenuItem::with_id("find", "&Find…", true, Some(find_accelerator));
let find_next_item =
MenuItem::with_id("find_next", "Find &Next", true, Some(find_next_accelerator));
let find_prev_item = MenuItem::with_id(
"find_prev",
"Find &Previous",
true,
Some(find_prev_accelerator),
);
let edit_menu = Submenu::new("&Edit", true);
log_menu_result(
"edit menu items",
edit_menu.append_items(&[
&PredefinedMenuItem::undo(None),
&PredefinedMenuItem::redo(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::cut(None),
&PredefinedMenuItem::copy(None),
&PredefinedMenuItem::paste(None),
&PredefinedMenuItem::select_all(None),
&PredefinedMenuItem::separator(),
&find_item,
&find_next_item,
&find_prev_item,
]),
);
let view_menu = Submenu::new("&View", true);
#[cfg(target_os = "macos")]
let devtools_modifiers = Modifiers::META | Modifiers::ALT;
#[cfg(not(target_os = "macos"))]
let devtools_modifiers = Modifiers::CONTROL | Modifiers::SHIFT;
let devtools_item = MenuItem::with_id(
"devtools",
"Toggle Developer Tools",
true,
Some(Accelerator::new(devtools_modifiers, Code::KeyI)),
);
log_menu_result(
"view menu items",
view_menu.append_items(&[
&PredefinedMenuItem::fullscreen(None),
&PredefinedMenuItem::separator(),
&devtools_item,
]),
);
let history_menu = Submenu::new("&History", true);
#[cfg(target_os = "macos")]
let (back_accelerator, forward_accelerator) = (
Accelerator::new(Modifiers::META, Code::BracketLeft),
Accelerator::new(Modifiers::META, Code::BracketRight),
);
#[cfg(not(target_os = "macos"))]
let (back_accelerator, forward_accelerator) = (
Accelerator::new(Modifiers::ALT, Code::ArrowLeft),
Accelerator::new(Modifiers::ALT, Code::ArrowRight),
);
let back_item = MenuItem::with_id("back", "&Back", true, Some(back_accelerator));
let forward_item = MenuItem::with_id("forward", "&Forward", true, Some(forward_accelerator));
log_menu_result(
"history menu items",
history_menu.append_items(&[&back_item, &forward_item]),
);
let history_items = HistoryMenuItems {
back: back_item,
forward: forward_item,
};
let window_menu = Submenu::new("&Window", true);
log_menu_result(
"window menu items",
window_menu.append_items(&[
&PredefinedMenuItem::minimize(None),
&PredefinedMenuItem::maximize(None),
&PredefinedMenuItem::separator(),
&PredefinedMenuItem::bring_all_to_front(None),
]),
);
#[cfg(not(target_os = "macos"))]
let help_menu = {
let help_menu = Submenu::new("&Help", true);
log_menu_result(
"help menu items",
help_menu.append_items(&[&PredefinedMenuItem::about(None, Some(about_metadata()))]),
);
help_menu
};
#[cfg(target_os = "macos")]
log_menu_result(
"menus to menu bar",
menu_bar.append_items(&[
&app_menu,
&file_menu,
&edit_menu,
&view_menu,
&history_menu,
&window_menu,
]),
);
#[cfg(not(target_os = "macos"))]
log_menu_result(
"menus to menu bar",
menu_bar.append_items(&[
&file_menu,
&edit_menu,
&view_menu,
&history_menu,
&window_menu,
&help_menu,
]),
);
#[cfg(target_os = "macos")]
window_menu.set_as_windows_menu_for_nsapp();
let find_items = FindMenuItems {
open: find_item,
next: find_next_item,
prev: find_prev_item,
};
MenuHandles {
menu_bar,
open_item,
reload_item,
print_item,
history_items,
find_items,
window_menu,
}
}
fn open_with_system_handler(url: &str) {
tracing::debug!("Opening {url} with the system default handler");
if let Err(e) = open_external(url) {
tracing::warn!("Could not open {url} externally: {e}");
}
}
fn spawn_open_picker(proxy: EventLoopProxy<UserEvent>) {
std::thread::spawn(move || {
let markdown_extensions = Config::default().markdown_extensions;
if let Some(path) = crate::open_picker::pick_file_or_folder(
"Open a Markdown Folder or File",
&markdown_extensions,
) {
let _ = proxy.send_event(UserEvent::PathSelected(path));
}
});
}
async fn start_server_for(path: PathBuf) -> Result<(JoinHandle<()>, String), BrowserError> {
let absolute_path = path.canonicalize().map_err(|e| {
tracing::error!("Failed to canonicalize path: {e}");
BrowserError::ServerStartFailed
})?;
let config = Config::read(&absolute_path).map_err(|e| {
tracing::error!("Failed to read config: {e}");
BrowserError::ServerStartFailed
})?;
let is_directory = absolute_path.is_dir();
let relative_path = pathdiff::diff_paths(&absolute_path, &config.root_dir).unwrap_or_default();
let (ready_tx, ready_rx) = tokio::sync::oneshot::channel::<u16>();
let config_copy = config.clone();
let handle = tokio::spawn(async move {
let server_config = ServerConfig::from(&config_copy).with_gui_mode(true);
match Server::init(server_config) {
Ok(mut s) => {
if let Err(e) = s.start_with_port_retry(Some(ready_tx), 10).await {
tracing::error!("Server error: {e}");
}
}
Err(e) => {
tracing::error!("Server init failed: {e}");
drop(ready_tx);
}
}
});
let port = ready_rx.await.map_err(|_| {
handle.abort();
BrowserError::ServerStartFailed
})?;
let url_path = crate::launch_url::resolve_launch_url_path(
&relative_path,
is_directory,
&config.markdown_extensions,
);
let base_url = url::Url::parse(&format!("http://{}:{}/", config.host, port)).map_err(|e| {
tracing::error!("Failed to build server URL: {e}");
BrowserError::ServerStartFailed
})?;
let url = base_url.join(&url_path).map_err(|e| {
tracing::error!("Failed to build launch URL: {e}");
BrowserError::ServerStartFailed
})?;
Ok((handle, url.to_string()))
}
const MENU_BAR_AUTO_VISIBLE: bool = !cfg!(target_os = "linux");
fn menu_bar_starts_visible(setting: MenuBarVisibility, auto: bool) -> bool {
match setting {
MenuBarVisibility::Auto => auto,
MenuBarVisibility::Always => true,
MenuBarVisibility::Never => false,
}
}
#[cfg(target_os = "linux")]
fn menu_bar_toggle_allowed(setting: MenuBarVisibility) -> bool {
!matches!(setting, MenuBarVisibility::Never)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Shortcut {
Open,
Reload,
Print,
Back,
Forward,
FindOpen,
FindNext,
FindPrev,
#[cfg(target_os = "linux")]
Quit,
}
struct ShortcutIds {
open: muda::MenuId,
reload: muda::MenuId,
print: muda::MenuId,
back: muda::MenuId,
forward: muda::MenuId,
find: muda::MenuId,
find_next: muda::MenuId,
find_prev: muda::MenuId,
}
fn shortcut_for_menu_id(id: &muda::MenuId, ids: &ShortcutIds) -> Option<Shortcut> {
let table = [
(&ids.open, Shortcut::Open),
(&ids.reload, Shortcut::Reload),
(&ids.print, Shortcut::Print),
(&ids.back, Shortcut::Back),
(&ids.forward, Shortcut::Forward),
(&ids.find, Shortcut::FindOpen),
(&ids.find_next, Shortcut::FindNext),
(&ids.find_prev, Shortcut::FindPrev),
];
table
.into_iter()
.find(|(candidate, _)| *candidate == id)
.map(|(_, shortcut)| shortcut)
}
fn perform_shortcut(
shortcut: Shortcut,
webview: &wry::WebView,
current_url: &str,
proxy: &EventLoopProxy<UserEvent>,
#[cfg_attr(not(target_os = "linux"), allow(unused_variables))] control_flow: &mut ControlFlow,
) {
match shortcut {
Shortcut::Open => {
tracing::debug!("Open requested");
spawn_open_picker(proxy.clone());
}
Shortcut::Reload => {
tracing::debug!("Reload requested");
let _ = webview.load_url(current_url);
}
Shortcut::Print => {
tracing::debug!("Print requested");
if let Err(e) = webview.print() {
tracing::error!("Print failed: {e}");
}
}
Shortcut::Back => {
tracing::debug!("History back requested");
let _ = webview.evaluate_script("history.back()");
}
Shortcut::Forward => {
tracing::debug!("History forward requested");
let _ = webview.evaluate_script("history.forward()");
}
Shortcut::FindOpen => {
tracing::debug!("Find requested");
let _ = webview.evaluate_script(FIND_OPEN_SCRIPT);
}
Shortcut::FindNext => {
tracing::debug!("Find next requested");
let _ = webview.evaluate_script(FIND_NEXT_SCRIPT);
}
Shortcut::FindPrev => {
tracing::debug!("Find previous requested");
let _ = webview.evaluate_script(FIND_PREV_SCRIPT);
}
#[cfg(target_os = "linux")]
Shortcut::Quit => {
tracing::debug!("Quit requested");
*control_flow = ControlFlow::Exit;
}
}
}
#[cfg(target_os = "linux")]
fn linux_shortcut_for(key: &Key<'_>, modifiers: ModifiersState) -> Option<Shortcut> {
let ctrl = modifiers == ModifiersState::CONTROL;
let ctrl_shift = modifiers == ModifiersState::CONTROL | ModifiersState::SHIFT;
let alt = modifiers == ModifiersState::ALT;
let shift = modifiers == ModifiersState::SHIFT;
let bare = modifiers.is_empty();
match key {
Key::Character(c) if ctrl => match c.to_ascii_lowercase().as_str() {
"o" => Some(Shortcut::Open),
"r" => Some(Shortcut::Reload),
"f" => Some(Shortcut::FindOpen),
"w" | "q" => Some(Shortcut::Quit),
_ => None,
},
Key::Character(c) if ctrl_shift && c.eq_ignore_ascii_case("p") => Some(Shortcut::Print),
Key::F3 if bare => Some(Shortcut::FindNext),
Key::F3 if shift => Some(Shortcut::FindPrev),
Key::ArrowLeft if alt => Some(Shortcut::Back),
Key::ArrowRight if alt => Some(Shortcut::Forward),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn release_gtk_menu_bar_accel() {
use gtk::prelude::*;
let Some(settings) = gtk::Settings::default() else {
return;
};
if settings.find_property("gtk-menu-bar-accel").is_some() {
settings.set_property("gtk-menu-bar-accel", None::<String>);
}
}
#[cfg(target_os = "linux")]
fn set_gtk_menu_bar_visible(menu_bar: &Menu, window: &tao::window::Window, visible: bool) {
use gtk::prelude::WidgetExt;
use tao::platform::unix::WindowExtUnix;
let gtk_window = window.gtk_window();
if let Some(bar) = menu_bar.clone().gtk_menubar_for_gtk_window(gtk_window) {
bar.set_no_show_all(!visible);
}
let result = if visible {
menu_bar.show_for_gtk_window(gtk_window)
} else {
menu_bar.hide_for_gtk_window(gtk_window)
};
if let Err(e) = result {
tracing::warn!("Failed to set menu bar visibility: {e}");
}
}
pub fn launch_browser(launch: InitialLaunch) -> Result<(), BrowserError> {
mark_gui_active();
let event_loop = EventLoopBuilder::<UserEvent>::with_user_event().build();
let proxy = event_loop.create_proxy();
MenuEvent::set_event_handler(Some(move |event| {
let _ = proxy.send_event(UserEvent::MenuEvent(event));
}));
let MenuHandles {
menu_bar,
open_item,
reload_item,
print_item,
history_items,
find_items,
window_menu: _window_menu,
} = build_menu_bar();
#[cfg(target_os = "macos")]
menu_bar.init_for_nsapp();
let (
menu_bar_setting,
initial_url,
initial_server_handle,
tokio_runtime,
mut awaiting_open_decision,
) = match launch {
InitialLaunch::Known(ctx) => (
ctx.config.gui_menu_bar,
ctx.url,
ctx.server_handle,
ctx.tokio_runtime,
false,
),
#[cfg(target_os = "macos")]
InitialLaunch::Deferred { tokio_runtime } => {
let placeholder_handle = tokio_runtime.spawn(async {});
(
Config::default().gui_menu_bar,
"about:blank".to_string(),
placeholder_handle,
tokio_runtime,
true,
)
}
};
let mut menu_bar_visible = menu_bar_starts_visible(menu_bar_setting, MENU_BAR_AUTO_VISIBLE);
let icon = load_icon()?;
let window = WindowBuilder::new()
.with_title("mbr")
.with_window_icon(Some(icon))
.build(&event_loop)
.map_err(BrowserError::WindowCreationFailed)?;
#[cfg(not(target_os = "linux"))]
let _ = (menu_bar_setting, &mut menu_bar_visible);
#[cfg(target_os = "windows")]
unsafe {
use tao::platform::windows::WindowExtWindows;
if let Err(e) = menu_bar.init_for_hwnd(window.hwnd()) {
tracing::warn!("Failed to attach menu bar to window: {e}");
}
}
#[cfg(target_os = "linux")]
{
use tao::platform::unix::WindowExtUnix;
if let Err(e) = menu_bar.init_for_gtk_window(window.gtk_window(), window.default_vbox()) {
tracing::warn!("Failed to attach menu bar to window: {e}");
}
release_gtk_menu_bar_accel();
set_gtk_menu_bar_visible(&menu_bar, &window, menu_bar_visible);
}
let site_origin = Arc::new(RwLock::new(SiteOrigin::new(&initial_url)));
let new_window_origin = Arc::clone(&site_origin);
let ipc_origin = Arc::clone(&site_origin);
let builder = WebViewBuilder::new()
.with_devtools(true)
.with_url(&initial_url)
.with_navigation_handler(|url| {
apply_decision(
decide_without_frame_info(&url),
&url,
open_with_system_handler,
)
})
.with_new_window_req_handler(move |url, _features| {
let origin = new_window_origin.read().clone();
if apply_decision(origin.decide(&url), &url, open_with_system_handler) {
NewWindowResponse::Allow
} else {
NewWindowResponse::Deny
}
})
.with_ipc_handler(move |request| {
let origin = ipc_origin.read().clone();
match parse_ipc_open_request(&origin, request.body()) {
Some(url) => open_with_system_handler(url),
None => tracing::debug!("Ignoring unrecognized IPC message from the page"),
}
});
#[cfg(not(target_os = "linux"))]
let webview = builder
.build(&window)
.map_err(BrowserError::WebViewCreationFailed)?;
#[cfg(target_os = "linux")]
let webview = {
use tao::platform::unix::WindowExtUnix;
match window.default_vbox() {
Some(vbox) => builder.build_gtk(vbox),
None => builder.build_gtk(window.gtk_window()),
}
.map_err(BrowserError::WebViewCreationFailed)?
};
let shortcut_ids = ShortcutIds {
open: open_item.id().clone(),
reload: reload_item.id().clone(),
print: print_item.id().clone(),
back: history_items.back.id().clone(),
forward: history_items.forward.id().clone(),
find: find_items.open.id().clone(),
find_next: find_items.next.id().clone(),
find_prev: find_items.prev.id().clone(),
};
let mut modifiers = ModifiersState::empty();
let mut server_handle = initial_server_handle;
let mut current_url = initial_url;
let event_proxy = event_loop.create_proxy();
event_loop.run(move |event, _target, control_flow| {
*control_flow = ControlFlow::Wait;
match event {
Event::UserEvent(UserEvent::MenuEvent(menu_event)) => {
if let Some(shortcut) = shortcut_for_menu_id(&menu_event.id, &shortcut_ids) {
perform_shortcut(shortcut, &webview, ¤t_url, &event_proxy, control_flow);
}
}
Event::UserEvent(UserEvent::PathSelected(new_path)) => {
tracing::info!("Opening: {}", new_path.display());
let proxy = event_proxy.clone();
tokio_runtime.spawn(async move {
let event = match start_server_for(new_path.clone()).await {
Ok((handle, url)) => UserEvent::ServerReady { handle, url },
Err(e) => {
tracing::error!("Failed to open {}: {e}", new_path.display());
UserEvent::ServerFailed { path: new_path }
}
};
let _ = proxy.send_event(event);
});
}
Event::UserEvent(UserEvent::ServerReady { handle, url }) => {
server_handle.abort();
server_handle = handle;
current_url = url;
*site_origin.write() = SiteOrigin::new(¤t_url);
tracing::info!("Server restarted at {}", current_url);
let _ = webview.load_url(¤t_url);
}
Event::UserEvent(UserEvent::ServerFailed { path }) => {
std::thread::spawn(move || {
rfd::MessageDialog::new()
.set_level(rfd::MessageLevel::Error)
.set_title("Failed to Open")
.set_description(format!(
"Could not open: {}\n\nThe current folder will remain active.",
path.display()
))
.set_buttons(rfd::MessageButtons::Ok)
.show();
});
}
Event::Opened { urls } => match crate::macos_open::first_local_path(&urls) {
Some(path) => {
awaiting_open_decision = false;
tracing::info!("Finder asked to open: {}", path.display());
let _ = event_proxy.send_event(UserEvent::PathSelected(path));
}
None if awaiting_open_decision => {
awaiting_open_decision = false;
tracing::warn!(
"Received Event::Opened with no local file among {urls:?}; showing the picker instead"
);
spawn_open_picker(event_proxy.clone());
}
None => {
tracing::debug!("Ignoring Event::Opened with no local file: {urls:?}");
}
},
Event::NewEvents(StartCause::Init) => {
if awaiting_open_decision {
awaiting_open_decision = false;
tracing::debug!(
"No Event::Opened arrived by the end of launch; showing the open picker"
);
spawn_open_picker(event_proxy.clone());
}
}
Event::WindowEvent {
event: WindowEvent::CloseRequested,
..
} => {
tracing::debug!("The close button was pressed; stopping");
*control_flow = ControlFlow::Exit
}
Event::WindowEvent {
event: WindowEvent::ModifiersChanged(new_modifiers),
..
} => {
modifiers = new_modifiers;
}
#[cfg(target_os = "linux")]
Event::WindowEvent {
event:
WindowEvent::KeyboardInput {
event: key_event, ..
},
..
} if key_event.state == ElementState::Released
&& key_event.logical_key == Key::F10
&& modifiers.is_empty()
&& menu_bar_toggle_allowed(menu_bar_setting) =>
{
menu_bar_visible = !menu_bar_visible;
tracing::debug!("Menu bar toggled to visible={menu_bar_visible} via F10");
set_gtk_menu_bar_visible(&menu_bar, &window, menu_bar_visible);
}
#[cfg(target_os = "linux")]
Event::WindowEvent {
event:
WindowEvent::KeyboardInput {
event: key_event, ..
},
..
} if key_event.state == ElementState::Released
&& !menu_bar_visible
&& linux_shortcut_for(&key_event.logical_key, modifiers).is_some() =>
{
if let Some(shortcut) = linux_shortcut_for(&key_event.logical_key, modifiers) {
perform_shortcut(shortcut, &webview, ¤t_url, &event_proxy, control_flow);
}
}
#[cfg(not(target_os = "linux"))]
Event::WindowEvent {
event:
WindowEvent::KeyboardInput {
event: key_event, ..
},
..
} if key_event.state == ElementState::Released && modifiers.alt_key() => {
match key_event.physical_key {
KeyCode::ArrowLeft => {
perform_shortcut(
Shortcut::Back,
&webview,
¤t_url,
&event_proxy,
control_flow,
);
}
KeyCode::ArrowRight => {
perform_shortcut(
Shortcut::Forward,
&webview,
¤t_url,
&event_proxy,
control_flow,
);
}
_ => {}
}
}
_ => (),
}
});
}
fn load_icon() -> Result<Icon, BrowserError> {
let (icon_rgba, icon_width, icon_height) = {
let image_bytes = include_bytes!("../mbr-icon.png");
let image = image::load_from_memory(image_bytes)
.map_err(|e| BrowserError::IconLoadFailed(e.to_string()))?
.into_rgba8();
let (width, height) = image.dimensions();
let rgba = image.into_raw();
(rgba, width, height)
};
Icon::from_rgba(icon_rgba, icon_width, icon_height).map_err(BrowserError::IconCreationFailed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_follows_the_platform_convention() {
assert!(!menu_bar_starts_visible(MenuBarVisibility::Auto, false));
assert!(menu_bar_starts_visible(MenuBarVisibility::Auto, true));
}
#[test]
fn always_and_never_ignore_the_platform() {
for auto in [false, true] {
assert!(menu_bar_starts_visible(MenuBarVisibility::Always, auto));
assert!(!menu_bar_starts_visible(MenuBarVisibility::Never, auto));
}
}
#[test]
fn linux_is_the_only_platform_that_hides_by_default() {
assert_eq!(MENU_BAR_AUTO_VISIBLE, !cfg!(target_os = "linux"));
}
#[cfg(target_os = "linux")]
#[test]
fn only_never_refuses_the_f10_toggle() {
assert!(menu_bar_toggle_allowed(MenuBarVisibility::Auto));
assert!(menu_bar_toggle_allowed(MenuBarVisibility::Always));
assert!(!menu_bar_toggle_allowed(MenuBarVisibility::Never));
}
#[tokio::test]
async fn start_server_for_does_not_block_its_own_runtime() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("index.md"), "# hi\n").expect("write");
let (handle, url) = super::start_server_for(dir.path().to_path_buf())
.await
.expect("server should come up for a plain markdown folder");
assert!(
url.starts_with("http://"),
"expected an http url, got {url}"
);
let port: u16 = url
.rsplit(':')
.next()
.and_then(|p| p.trim_end_matches('/').parse().ok())
.unwrap_or_else(|| panic!("no port in {url}"));
assert!(port > 0);
handle.abort();
}
#[tokio::test]
async fn start_server_for_reports_a_missing_folder() {
let dir = tempfile::tempdir().expect("tempdir");
let missing = dir.path().join("no-such-folder");
assert!(super::start_server_for(missing).await.is_err());
}
#[cfg(target_os = "linux")]
mod shortcuts {
use super::super::*;
fn look(key: Key<'_>, modifiers: ModifiersState) -> Option<Shortcut> {
linux_shortcut_for(&key, modifiers)
}
const CTRL: ModifiersState = ModifiersState::CONTROL;
const ALT: ModifiersState = ModifiersState::ALT;
const SHIFT: ModifiersState = ModifiersState::SHIFT;
const NONE: ModifiersState = ModifiersState::empty();
#[test]
fn every_menu_shortcut_has_a_keyboard_route() {
assert_eq!(look(Key::Character("o"), CTRL), Some(Shortcut::Open));
assert_eq!(look(Key::Character("r"), CTRL), Some(Shortcut::Reload));
assert_eq!(
look(Key::Character("P"), CTRL | SHIFT),
Some(Shortcut::Print)
);
assert_eq!(look(Key::Character("f"), CTRL), Some(Shortcut::FindOpen));
assert_eq!(look(Key::F3, NONE), Some(Shortcut::FindNext));
assert_eq!(look(Key::F3, SHIFT), Some(Shortcut::FindPrev));
assert_eq!(look(Key::ArrowLeft, ALT), Some(Shortcut::Back));
assert_eq!(look(Key::ArrowRight, ALT), Some(Shortcut::Forward));
}
#[test]
fn close_and_quit_both_quit() {
assert_eq!(look(Key::Character("w"), CTRL), Some(Shortcut::Quit));
assert_eq!(look(Key::Character("q"), CTRL), Some(Shortcut::Quit));
}
#[test]
fn character_matching_ignores_case() {
assert_eq!(look(Key::Character("O"), CTRL), Some(Shortcut::Open));
}
#[test]
fn plain_ctrl_p_is_left_to_the_page() {
assert_eq!(look(Key::Character("p"), CTRL), None);
assert_eq!(
look(Key::Character("p"), CTRL | SHIFT),
Some(Shortcut::Print)
);
}
#[test]
fn extra_modifiers_do_not_match() {
assert_eq!(look(Key::Character("o"), CTRL | SHIFT), None);
assert_eq!(look(Key::ArrowLeft, ALT | CTRL), None);
assert_eq!(look(Key::F3, ALT), None);
}
#[test]
fn bare_letters_are_left_to_the_page() {
assert_eq!(look(Key::Character("o"), NONE), None);
assert_eq!(look(Key::Character("z"), CTRL), None);
assert_eq!(look(Key::ArrowLeft, NONE), None);
}
#[test]
fn f10_is_not_in_the_gated_table() {
assert_eq!(look(Key::F10, NONE), None);
}
}
#[cfg(target_os = "linux")]
#[test]
fn starting_state_and_toggle_permission_agree_for_never() {
let setting = MenuBarVisibility::Never;
assert!(!menu_bar_starts_visible(setting, true));
assert!(!menu_bar_toggle_allowed(setting));
}
}