#![deny(unsafe_op_in_unsafe_fn)]
use core::cell::OnceCell;
use std::cell::RefCell;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use notify_debouncer_mini::{new_debouncer, notify};
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, ProtocolObject};
use objc2::{define_class, msg_send, sel, DefinedClass, MainThreadMarker, MainThreadOnly};
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate, NSBackingStoreType,
NSMenu, NSMenuItem, NSModalResponseOK, NSOpenPanel, NSWindow, NSWindowStyleMask,
};
use objc2_foundation::{
ns_string, NSNotification, NSObject, NSObjectProtocol, NSPoint, NSRect, NSRunLoopCommonModes,
NSSize, NSString,
};
use objc2_web_kit::WKWebView;
use comrak::{markdown_to_html, Options};
const GITHUB_CSS: &str = include_str!("../assets/github-markdown.css");
const MERMAID_JS: &str = include_str!("../assets/mermaid.min.js");
static FILE_PATH: OnceLock<Mutex<PathBuf>> = OnceLock::new();
static NEEDS_RELOAD: AtomicBool = AtomicBool::new(false);
fn get_file_path() -> Option<PathBuf> {
FILE_PATH.get().map(|m| m.lock().unwrap().clone())
}
fn set_file_path(path: PathBuf) {
match FILE_PATH.get() {
Some(mutex) => {
*mutex.lock().unwrap() = path;
}
None => {
FILE_PATH.set(Mutex::new(path)).ok();
}
}
}
fn comrak_options() -> Options<'static> {
let mut options = Options::default();
options.extension.strikethrough = true;
options.extension.table = true;
options.extension.autolink = true;
options.extension.tasklist = true;
options.extension.footnotes = true;
options.extension.description_lists = true;
options.extension.multiline_block_quotes = true;
options.render.r#unsafe = true;
options
}
fn render_markdown(markdown: &str) -> String {
let options = comrak_options();
let html_body = markdown_to_html(markdown, &options);
format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
{GITHUB_CSS}
body {{
margin: 0;
background-color: #ffffff;
}}
@media (prefers-color-scheme: dark) {{
body {{
background-color: #0d1117;
}}
}}
.markdown-body {{
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}}
@media (max-width: 767px) {{
.markdown-body {{
padding: 15px;
}}
}}
/* Find bar */
#mdiew-find-bar {{
display: none;
position: fixed;
top: 12px;
right: 12px;
z-index: 10000;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: rgba(246,246,246,0.92);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0,0,0,0.12);
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.12);
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 13px;
}}
@media (prefers-color-scheme: dark) {{
#mdiew-find-bar {{
background: rgba(50,50,50,0.92);
border-color: rgba(255,255,255,0.1);
}}
}}
#mdiew-find-input {{
width: 200px;
padding: 4px 8px;
border: 1px solid rgba(0,0,0,0.15);
border-radius: 5px;
outline: none;
font-size: 13px;
background: white;
color: #333;
}}
@media (prefers-color-scheme: dark) {{
#mdiew-find-input {{
background: rgba(30,30,30,0.9);
border-color: rgba(255,255,255,0.15);
color: #eee;
}}
}}
#mdiew-find-input:focus {{
border-color: #6C5CE7;
box-shadow: 0 0 0 2px rgba(108,92,231,0.3);
}}
.mdiew-find-btn {{
padding: 3px 8px;
border: 1px solid rgba(0,0,0,0.12);
border-radius: 5px;
background: rgba(255,255,255,0.8);
color: #333;
font-size: 12px;
cursor: pointer;
line-height: 1.4;
}}
@media (prefers-color-scheme: dark) {{
.mdiew-find-btn {{
background: rgba(60,60,60,0.8);
border-color: rgba(255,255,255,0.1);
color: #ddd;
}}
}}
.mdiew-find-btn:hover {{
background: rgba(108,92,231,0.15);
}}
</style>
<script>
{MERMAID_JS}
</script>
</head>
<body>
<article class="markdown-body">
{html_body}
</article>
<script>
(function() {{
// Convert comrak's mermaid code blocks into mermaid-renderable divs.
document.querySelectorAll('pre > code.language-mermaid').forEach(function(codeEl) {{
var pre = codeEl.parentElement;
var div = document.createElement('div');
div.className = 'mermaid';
div.textContent = codeEl.textContent;
pre.parentElement.replaceChild(div, pre);
}});
// Detect dark mode and set mermaid theme accordingly.
var isDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
mermaid.initialize({{
startOnLoad: true,
theme: isDark ? 'dark' : 'default'
}});
}})();
// Persist scroll position across live reloads.
var savedY = sessionStorage.getItem('mdiew_scrollY');
if (savedY) {{
requestAnimationFrame(function() {{
window.scrollTo(0, parseInt(savedY));
}});
}}
window.addEventListener('scroll', function() {{
sessionStorage.setItem('mdiew_scrollY', window.scrollY.toString());
}});
</script>
<!-- Find bar -->
<div id="mdiew-find-bar">
<input id="mdiew-find-input" type="text" placeholder="Find..." autocomplete="off" spellcheck="false">
<button class="mdiew-find-btn" id="mdiew-find-prev" title="Previous (⇧⏎)">‹</button>
<button class="mdiew-find-btn" id="mdiew-find-next" title="Next (⏎)">›</button>
<button class="mdiew-find-btn" id="mdiew-find-close" title="Close (Esc)">×</button>
</div>
<script>
(function() {{
var bar = document.getElementById('mdiew-find-bar');
var input = document.getElementById('mdiew-find-input');
var nextBtn = document.getElementById('mdiew-find-next');
var prevBtn = document.getElementById('mdiew-find-prev');
var closeBtn = document.getElementById('mdiew-find-close');
function doFind(backwards) {{
var text = input.value;
if (!text) return;
// window.find(string, caseSensitive, backwards, wrapAround)
window.find(text, false, backwards, true);
}}
function openFindBar() {{
bar.style.display = 'flex';
input.focus();
input.select();
}}
function closeFindBar() {{
bar.style.display = 'none';
input.value = '';
// Clear selection
window.getSelection().removeAllRanges();
}}
// Expose for native menu
window.mdiewOpenFind = openFindBar;
input.addEventListener('keydown', function(e) {{
if (e.key === 'Enter') {{
e.preventDefault();
doFind(e.shiftKey);
}} else if (e.key === 'Escape') {{
closeFindBar();
}}
}});
nextBtn.addEventListener('click', function() {{ doFind(false); }});
prevBtn.addEventListener('click', function() {{ doFind(true); }});
closeBtn.addEventListener('click', closeFindBar);
// ⌘F from keyboard (in case menu doesn't catch it)
document.addEventListener('keydown', function(e) {{
if ((e.metaKey || e.ctrlKey) && e.key === 'f') {{
e.preventDefault();
openFindBar();
}}
// ⌘G / ⌘⇧G for next/prev
if ((e.metaKey || e.ctrlKey) && e.key === 'g') {{
e.preventDefault();
doFind(e.shiftKey);
}}
if (e.key === 'Escape' && bar.style.display === 'flex') {{
closeFindBar();
}}
}});
}})();
</script>
</body>
</html>"#
)
}
fn load_and_render() -> Option<String> {
match get_file_path() {
Some(path) => {
let markdown = match std::fs::read_to_string(&path) {
Ok(s) if s.is_empty() => return None, Ok(s) => s,
Err(_) => return None, };
Some(render_markdown(&markdown))
}
None => Some(render_markdown("*Open a file with* **File → Open** *(⌘O)*")),
}
}
struct AppDelegateIvars {
window: OnceCell<Retained<NSWindow>>,
web_view: OnceCell<Retained<WKWebView>>,
debouncer: RefCell<Option<notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>>>,
}
define_class!(
#[unsafe(super(NSObject))]
#[thread_kind = MainThreadOnly]
#[ivars = AppDelegateIvars]
struct AppDelegate;
unsafe impl NSObjectProtocol for AppDelegate {}
unsafe impl NSApplicationDelegate for AppDelegate {
#[unsafe(method(applicationDidFinishLaunching:))]
#[allow(non_snake_case)]
unsafe fn applicationDidFinishLaunching(&self, _notification: &NSNotification) {
let mtm = self.mtm();
let window = {
let content_rect = NSRect::new(NSPoint::new(0., 0.), NSSize::new(1200., 900.));
let style = NSWindowStyleMask::Closable
| NSWindowStyleMask::Resizable
| NSWindowStyleMask::Titled
| NSWindowStyleMask::Miniaturizable;
unsafe {
NSWindow::initWithContentRect_styleMask_backing_defer(
NSWindow::alloc(mtm),
content_rect,
style,
NSBackingStoreType::Buffered,
false,
)
}
};
let web_view =
unsafe { WKWebView::initWithFrame(WKWebView::alloc(mtm), NSRect::ZERO) };
let html = load_and_render()
.unwrap_or_else(|| render_markdown("*Open a file with* **File → Open** *(⌘O)*"));
let html_ns = NSString::from_str(&html);
unsafe { web_view.loadHTMLString_baseURL(&html_ns, None) };
window.setContentView(Some(&web_view));
window.center();
let title = get_file_path()
.as_deref()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())
.unwrap_or("mdiew")
.to_string();
window.setTitle(&NSString::from_str(&title));
window.makeKeyAndOrderFront(None);
build_menu_bar(mtm);
if let Some(path) = get_file_path() {
let debouncer = start_file_watcher(&path);
*self.ivars().debouncer.borrow_mut() = Some(debouncer);
}
self.ivars()
.window
.set(window)
.expect("window ivar should not already be set");
self.ivars()
.web_view
.set(web_view)
.expect("web_view ivar should not already be set");
start_reload_timer(self);
}
#[unsafe(method(applicationShouldTerminateAfterLastWindowClosed:))]
#[allow(non_snake_case)]
unsafe fn applicationShouldTerminateAfterLastWindowClosed(
&self,
_sender: &NSApplication,
) -> bool {
true
}
#[unsafe(method(application:openFile:))]
#[allow(non_snake_case)]
unsafe fn application_openFile(
&self,
_sender: &NSApplication,
filename: &NSString,
) -> bool {
let path = PathBuf::from(filename.to_string());
if path.exists() {
self.open_file(path);
true
} else {
false
}
}
}
impl AppDelegate {
#[unsafe(method(checkReload:))]
#[allow(non_snake_case)]
fn checkReload(&self, _timer: *mut AnyObject) {
if !NEEDS_RELOAD.swap(false, Ordering::Relaxed) {
return;
}
let Some(web_view) = self.ivars().web_view.get() else {
return;
};
let Some(html) = load_and_render() else {
NEEDS_RELOAD.store(true, Ordering::Relaxed);
return;
};
let html_ns = NSString::from_str(&html);
unsafe { web_view.loadHTMLString_baseURL(&html_ns, None) };
}
#[unsafe(method(openDocument:))]
#[allow(non_snake_case)]
fn openDocument(&self, _sender: *mut AnyObject) {
let mtm = self.mtm();
let panel = NSOpenPanel::openPanel(mtm);
panel.setAllowsMultipleSelection(false);
let response = panel.runModal();
if response == NSModalResponseOK {
if let Some(url) = panel.URL() {
if let Some(path) = url.path() {
self.open_file(PathBuf::from(path.to_string()));
}
}
}
}
#[unsafe(method(zoomIn:))]
#[allow(non_snake_case)]
fn zoomIn(&self, _sender: *mut AnyObject) {
if let Some(web_view) = self.ivars().web_view.get() {
let js = NSString::from_str(
"document.body.style.zoom = (parseFloat(document.body.style.zoom || 1) + 0.1).toString()"
);
unsafe { web_view.evaluateJavaScript_completionHandler(&js, None) };
}
}
#[unsafe(method(zoomOut:))]
#[allow(non_snake_case)]
fn zoomOut(&self, _sender: *mut AnyObject) {
if let Some(web_view) = self.ivars().web_view.get() {
let js = NSString::from_str(
"document.body.style.zoom = Math.max(0.5, (parseFloat(document.body.style.zoom || 1) - 0.1)).toString()"
);
unsafe { web_view.evaluateJavaScript_completionHandler(&js, None) };
}
}
#[unsafe(method(resetZoom:))]
#[allow(non_snake_case)]
fn resetZoom(&self, _sender: *mut AnyObject) {
if let Some(web_view) = self.ivars().web_view.get() {
let js = NSString::from_str("document.body.style.zoom = '1'");
unsafe { web_view.evaluateJavaScript_completionHandler(&js, None) };
}
}
#[unsafe(method(performFind:))]
#[allow(non_snake_case)]
fn performFind(&self, _sender: *mut AnyObject) {
if let Some(web_view) = self.ivars().web_view.get() {
let js = NSString::from_str("window.mdiewOpenFind()");
unsafe { web_view.evaluateJavaScript_completionHandler(&js, None) };
}
}
}
);
impl AppDelegate {
fn new(mtm: MainThreadMarker) -> Retained<Self> {
let this = Self::alloc(mtm).set_ivars(AppDelegateIvars {
window: OnceCell::new(),
web_view: OnceCell::new(),
debouncer: RefCell::new(None),
});
unsafe { msg_send![super(this), init] }
}
fn open_file(&self, path: PathBuf) {
let path = std::fs::canonicalize(&path).unwrap_or(path);
if let Some(window) = self.ivars().window.get() {
let title = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("mdiew");
window.setTitle(&NSString::from_str(title));
}
let debouncer = start_file_watcher(&path);
set_file_path(path);
*self.ivars().debouncer.borrow_mut() = Some(debouncer);
if let Some(web_view) = self.ivars().web_view.get() {
if let Some(html) = load_and_render() {
let html_ns = NSString::from_str(&html);
unsafe { web_view.loadHTMLString_baseURL(&html_ns, None) };
}
}
}
}
fn build_menu_bar(mtm: MainThreadMarker) {
let menu_bar = NSMenu::initWithTitle(NSMenu::alloc(mtm), ns_string!(""));
let app_menu_item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
ns_string!(""),
None,
ns_string!(""),
)
};
let app_menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), ns_string!("mdiew"));
unsafe {
app_menu.addItemWithTitle_action_keyEquivalent(
ns_string!("About mdiew"),
Some(sel!(orderFrontStandardAboutPanel:)),
ns_string!(""),
);
}
app_menu.addItem(&NSMenuItem::separatorItem(mtm));
unsafe {
app_menu.addItemWithTitle_action_keyEquivalent(
ns_string!("Quit mdiew"),
Some(sel!(terminate:)),
ns_string!("q"),
);
}
app_menu_item.setSubmenu(Some(&app_menu));
menu_bar.addItem(&app_menu_item);
let file_menu_item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
ns_string!("File"),
None,
ns_string!(""),
)
};
let file_menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), ns_string!("File"));
unsafe {
file_menu.addItemWithTitle_action_keyEquivalent(
ns_string!("Open..."),
Some(sel!(openDocument:)),
ns_string!("o"),
);
}
file_menu.addItem(&NSMenuItem::separatorItem(mtm));
unsafe {
file_menu.addItemWithTitle_action_keyEquivalent(
ns_string!("Close Window"),
Some(sel!(performClose:)),
ns_string!("w"),
);
}
file_menu_item.setSubmenu(Some(&file_menu));
menu_bar.addItem(&file_menu_item);
let edit_menu_item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
ns_string!("Edit"),
None,
ns_string!(""),
)
};
let edit_menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), ns_string!("Edit"));
unsafe {
edit_menu.addItemWithTitle_action_keyEquivalent(
ns_string!("Copy"),
Some(sel!(copy:)),
ns_string!("c"),
);
edit_menu.addItemWithTitle_action_keyEquivalent(
ns_string!("Select All"),
Some(sel!(selectAll:)),
ns_string!("a"),
);
}
edit_menu.addItem(&NSMenuItem::separatorItem(mtm));
unsafe {
edit_menu.addItemWithTitle_action_keyEquivalent(
ns_string!("Find\u{2026}"),
Some(sel!(performFind:)),
ns_string!("f"),
);
}
edit_menu_item.setSubmenu(Some(&edit_menu));
menu_bar.addItem(&edit_menu_item);
let view_menu_item = unsafe {
NSMenuItem::initWithTitle_action_keyEquivalent(
NSMenuItem::alloc(mtm),
ns_string!("View"),
None,
ns_string!(""),
)
};
let view_menu = NSMenu::initWithTitle(NSMenu::alloc(mtm), ns_string!("View"));
unsafe {
view_menu.addItemWithTitle_action_keyEquivalent(
ns_string!("Zoom In"),
Some(sel!(zoomIn:)),
ns_string!("+"),
);
view_menu.addItemWithTitle_action_keyEquivalent(
ns_string!("Zoom Out"),
Some(sel!(zoomOut:)),
ns_string!("-"),
);
view_menu.addItemWithTitle_action_keyEquivalent(
ns_string!("Actual Size"),
Some(sel!(resetZoom:)),
ns_string!("0"),
);
}
view_menu_item.setSubmenu(Some(&view_menu));
menu_bar.addItem(&view_menu_item);
let app = NSApplication::sharedApplication(mtm);
app.setMainMenu(Some(&menu_bar));
}
fn start_file_watcher(path: &PathBuf) -> notify_debouncer_mini::Debouncer<notify::RecommendedWatcher> {
let file_name = path
.file_name()
.expect("watched path must have a filename")
.to_os_string();
let parent = path
.parent()
.expect("watched path must have a parent directory")
.to_path_buf();
let mut debouncer = new_debouncer(Duration::from_millis(200), move |result: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| match result {
Ok(events) => {
let relevant = events.iter().any(|e| e.path.file_name() == Some(&file_name));
if relevant {
NEEDS_RELOAD.store(true, Ordering::Relaxed);
}
}
Err(err) => {
eprintln!("File watch error: {err}");
}
})
.expect("Failed to create file watcher");
debouncer
.watcher()
.watch(&parent, notify::RecursiveMode::NonRecursive)
.unwrap_or_else(|e| eprintln!("Failed to watch directory: {e}"));
debouncer
}
fn start_reload_timer(delegate: &AppDelegate) {
use objc2_foundation::{NSRunLoop, NSTimer};
let timer = unsafe {
NSTimer::timerWithTimeInterval_target_selector_userInfo_repeats(
0.2,
delegate,
sel!(checkReload:),
None,
true,
)
};
let run_loop = NSRunLoop::currentRunLoop();
unsafe { run_loop.addTimer_forMode(&timer, NSRunLoopCommonModes) };
}
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() >= 2 {
let path = std::fs::canonicalize(&args[1]).unwrap_or_else(|_| PathBuf::from(&args[1]));
set_file_path(path);
}
let mtm = MainThreadMarker::new().unwrap();
let app = NSApplication::sharedApplication(mtm);
app.setActivationPolicy(NSApplicationActivationPolicy::Regular);
let delegate = AppDelegate::new(mtm);
let object = ProtocolObject::from_ref(&*delegate);
app.setDelegate(Some(object));
app.run();
}