mod app_icon;
mod app_url;
mod autosave;
pub mod fltk_draw_context;
mod history;
mod link_handler;
mod menu;
mod note_picker;
mod position_memory;
mod recency;
pub mod responsive_scrollbar;
mod search_bar;
mod statusbar;
mod window_state;
use autosave::AutoSaveState;
use clap::Parser;
use fltk::{prelude::*, *};
use history::History;
use piki_core::{DocumentStore, IndexPlugin, PluginRegistry, TodoPlugin};
use piki_gui::live_share::LiveShare;
use piki_gui::note_ui::NoteUI;
use piki_gui::on_air_bar::OnAirBar;
use piki_gui::section_link;
use piki_gui::ui_adapters::StructuredRichUI;
use position_memory::{NotePosition, PositionMemory};
use recency::RecentNotes;
use search_bar::SearchBar;
use statusbar::StatusBar;
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::Instant;
use window_state::WindowGeometry;
#[cfg(target_os = "macos")]
const CONTENT_TOP: i32 = 0;
#[cfg(not(target_os = "macos"))]
const CONTENT_TOP: i32 = 25;
type ShareHook = Box<dyn Fn(&str, &str)>;
thread_local! {
static SHARE_HOOK: RefCell<Option<ShareHook>> = const { RefCell::new(None) };
}
fn notify_share_view(note: &str, markdown: &str) {
SHARE_HOOK.with(|hook| {
if let Some(cb) = hook.borrow().as_ref() {
cb(note, markdown);
}
});
}
const WINDOW_STATE_SAVE_TIMEOUT_SECS: f64 = 3.0;
const AUTOSAVE_INTERVAL_SECS: f64 = 10.0;
const SAVE_STATUS_UPDATE_INTERVAL_SECS: f64 = 30.0;
#[derive(Parser, Debug)]
#[command(name = "piki-gui")]
#[command(about = "Piki - a simple personal wiki", long_about = None)]
struct Args {
#[arg(short = 'd', long = "directory", value_name = "DIRECTORY")]
directory: Option<PathBuf>,
#[arg(short, long, default_value = "frontpage")]
note: String,
}
struct AppState {
store: DocumentStore,
plugin_registry: PluginRegistry,
current_note: String,
history: History,
recent_notes: RecentNotes,
recent_notes_path: Option<PathBuf>,
note_positions: PositionMemory,
}
impl AppState {
fn new(
store: DocumentStore,
plugin_registry: PluginRegistry,
initial_note: String,
recent_notes_path: Option<PathBuf>,
) -> Self {
let recent_notes = recent_notes_path
.as_deref()
.map(RecentNotes::load)
.unwrap_or_default();
AppState {
store,
plugin_registry,
current_note: initial_note,
history: History::new(),
recent_notes,
recent_notes_path,
note_positions: PositionMemory::new(),
}
}
fn mark_note_opened(&mut self, note: &str) {
self.recent_notes.mark_opened(note);
if let Some(path) = &self.recent_notes_path
&& let Err(e) = self.recent_notes.save(path)
{
eprintln!("Failed to save recent notes: {e}");
}
}
fn rename_note(&mut self, old: &str, new: &str) {
if self.current_note == old {
self.current_note = new.to_string();
}
self.history.rename_note(old, new);
self.recent_notes.rename(old, new);
self.note_positions.rename(old, new);
if let Some(path) = &self.recent_notes_path
&& let Err(e) = self.recent_notes.save(path)
{
eprintln!("Failed to save recent notes: {e}");
}
}
fn forget_note(&mut self, note: &str) {
self.history.remove_note(note);
self.recent_notes.remove(note);
self.note_positions.remove(note);
if let Some(path) = &self.recent_notes_path
&& let Err(e) = self.recent_notes.save(path)
{
eprintln!("Failed to save recent notes: {e}");
}
}
fn load_note(&mut self, note_name: &str) -> Result<String, String> {
if let Some(plugin_name) = note_name.strip_prefix('!') {
self.current_note = note_name.to_string();
return self.plugin_registry.generate(plugin_name, &self.store);
}
match self.store.load(note_name) {
Ok(doc) => {
self.current_note = note_name.to_string();
Ok(doc.content)
}
Err(e) => Err(e),
}
}
}
fn save_current_note(
app_state: &Rc<RefCell<AppState>>,
autosave_state: &Rc<RefCell<AutoSaveState>>,
active_editor: &Rc<RefCell<Rc<RefCell<dyn NoteUI>>>>,
statusbar: &Rc<RefCell<StatusBar>>,
) {
if let (Ok(ed_ptr), Ok(mut as_state), Ok(app_st)) = (
active_editor.try_borrow(),
autosave_state.try_borrow_mut(),
app_state.try_borrow(),
) {
let ed_ref = (*ed_ptr).borrow();
match as_state.trigger_save(&*ed_ref, &app_st.store) {
Ok(()) => {
if let Ok(mut sb) = statusbar.try_borrow_mut() {
sb.set_status(&as_state.get_status_text());
}
}
Err(e) => {
if let Ok(mut sb) = statusbar.try_borrow_mut() {
sb.set_status(&format!("Error: {}", e));
}
}
}
}
}
fn rename_current_note(
new_name: &str,
app_state: &Rc<RefCell<AppState>>,
autosave_state: &Rc<RefCell<AutoSaveState>>,
active_editor: &Rc<RefCell<Rc<RefCell<dyn NoteUI>>>>,
statusbar: &Rc<RefCell<StatusBar>>,
) -> Result<(), String> {
let new_name = new_name.trim();
if new_name.is_empty() {
return Err("Please enter a name.".to_string());
}
let old_name = app_state.borrow().current_note.clone();
if new_name == old_name {
return Ok(());
}
if old_name.starts_with('!') {
return Err("This note cannot be renamed.".to_string());
}
save_current_note(app_state, autosave_state, active_editor, statusbar);
let (old_path, new_path) = {
let st = app_state.borrow();
(st.store.path_for(&old_name), st.store.path_for(new_name))
};
if new_path.exists() {
return Err(format!("A note named '{new_name}' already exists."));
}
if old_path.exists() {
if let Some(parent) = new_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create folder for '{new_name}': {e}"))?;
}
std::fs::rename(&old_path, &new_path).map_err(|e| format!("Failed to rename note: {e}"))?;
}
app_state.borrow_mut().rename_note(&old_name, new_name);
if let Ok(mut as_state) = autosave_state.try_borrow_mut() {
as_state.current_note = new_name.to_string();
}
statusbar
.borrow_mut()
.set_note(&format!("Note: {new_name}"));
let content = active_editor.borrow().borrow().get_content();
notify_share_view(new_name, &content);
Ok(())
}
fn delete_current_note(
app_state: &Rc<RefCell<AppState>>,
autosave_state: &Rc<RefCell<AutoSaveState>>,
active_editor: &Rc<RefCell<Rc<RefCell<dyn NoteUI>>>>,
statusbar: &Rc<RefCell<StatusBar>>,
) -> Result<(), String> {
let note = app_state.borrow().current_note.clone();
if note.starts_with('!') {
return Err("This note cannot be deleted.".to_string());
}
{
let st = app_state.borrow();
st.store.delete(¬e)?;
}
if let Ok(mut as_state) = autosave_state.try_borrow_mut() {
let content = active_editor.borrow().borrow().get_content();
as_state.reset_for_note(¬e, &content);
}
load_note_helper(
"frontpage",
app_state,
autosave_state,
active_editor,
statusbar,
None,
None,
);
app_state.borrow_mut().forget_note(¬e);
statusbar
.borrow_mut()
.set_status(&format!("Deleted note '{note}'."));
Ok(())
}
fn load_note_helper(
note_name: &str,
app_state: &Rc<RefCell<AppState>>,
autosave_state: &Rc<RefCell<AutoSaveState>>,
active_editor: &Rc<RefCell<Rc<RefCell<dyn NoteUI>>>>,
statusbar: &Rc<RefCell<StatusBar>>,
restore_position: Option<NotePosition>,
fragment: Option<&str>,
) {
save_current_note(app_state, autosave_state, active_editor, statusbar);
let record_history = restore_position.is_none();
{
let leaving_position = {
let active = active_editor.borrow();
let ed = active.borrow();
NotePosition {
scroll: ed.scroll_pos(),
cursor: ed.cursor_pos(),
}
};
let mut state = app_state.borrow_mut();
if record_history {
state.history.update_position(leaving_position.clone());
}
let leaving_note = state.current_note.clone();
state
.note_positions
.remember(&leaving_note, leaving_position);
}
let is_plugin = note_name.starts_with('!');
let content_result = app_state.borrow_mut().load_note(note_name);
match content_result {
Ok(content) => {
let modified_time = if !is_plugin {
app_state
.borrow()
.store
.load(note_name)
.ok()
.and_then(|doc| doc.modified_time)
} else {
None
};
{
let active = active_editor.borrow();
let mut editor_mut = active.borrow_mut();
editor_mut.set_content_from_markdown(&content);
editor_mut.set_readonly(is_plugin);
}
let did_anchor = fragment
.filter(|f| !f.is_empty())
.map(|frag| {
let active = active_editor.borrow();
let mut ed = active.borrow_mut();
ed.as_any_mut()
.downcast_mut::<StructuredRichUI>()
.map(|structured| structured.scroll_to_anchor(frag))
.unwrap_or(false)
})
.unwrap_or(false);
let final_position = if did_anchor {
let active = active_editor.borrow();
let ed = active.borrow();
NotePosition {
scroll: ed.scroll_pos(),
cursor: ed.cursor_pos(),
}
} else {
let target = restore_position
.or_else(|| app_state.borrow().note_positions.get(note_name))
.unwrap_or_default();
let active = active_editor.borrow();
let mut ed = active.borrow_mut();
if let Some(cursor) = target.cursor.clone() {
ed.set_cursor_pos(cursor);
}
ed.set_scroll_pos(target.scroll);
target
};
if record_history {
app_state
.borrow_mut()
.history
.push(note_name.to_string(), final_position);
}
if !is_plugin {
app_state.borrow_mut().mark_note_opened(note_name);
}
if let Ok(mut as_state) = autosave_state.try_borrow_mut() {
as_state.reset_for_note(note_name, &content);
if let Some(mtime) = modified_time {
as_state.last_save_time = Some(mtime);
}
}
let note_text = if let Some(plugin_name) = note_name.strip_prefix('!') {
format!("Plugin: {}", plugin_name)
} else if content.is_empty() {
format!("Note: {} (new)", note_name)
} else {
format!("Note: {}", note_name)
};
statusbar.borrow_mut().set_note(¬e_text);
if let Ok(as_state) = autosave_state.try_borrow() {
statusbar
.borrow_mut()
.set_status(&as_state.get_status_text());
} else {
statusbar.borrow_mut().set_status("");
}
notify_share_view(note_name, &content);
app::redraw();
}
Err(e) => {
statusbar.borrow_mut().set_note(&format!("Error: {}", e));
statusbar.borrow_mut().set_status("");
app::redraw();
}
}
}
fn navigate_back(
app_state: &Rc<RefCell<AppState>>,
autosave_state: &Rc<RefCell<AutoSaveState>>,
active_editor: &Rc<RefCell<Rc<RefCell<dyn NoteUI>>>>,
statusbar: &Rc<RefCell<StatusBar>>,
) {
let position = {
let active = active_editor.borrow();
let ed = active.borrow();
NotePosition {
scroll: ed.scroll_pos(),
cursor: ed.cursor_pos(),
}
};
app_state.borrow_mut().history.update_position(position);
let target = {
let mut state = app_state.borrow_mut();
state
.history
.go_back()
.map(|entry| (entry.note_name.clone(), entry.position.clone()))
};
if let Some((note_name, position)) = target {
load_note_helper(
¬e_name,
app_state,
autosave_state,
active_editor,
statusbar,
Some(position),
None,
);
}
}
fn navigate_forward(
app_state: &Rc<RefCell<AppState>>,
autosave_state: &Rc<RefCell<AutoSaveState>>,
active_editor: &Rc<RefCell<Rc<RefCell<dyn NoteUI>>>>,
statusbar: &Rc<RefCell<StatusBar>>,
) {
let position = {
let active = active_editor.borrow();
let ed = active.borrow();
NotePosition {
scroll: ed.scroll_pos(),
cursor: ed.cursor_pos(),
}
};
app_state.borrow_mut().history.update_position(position);
let target = {
let mut state = app_state.borrow_mut();
state
.history
.go_forward()
.map(|entry| (entry.note_name.clone(), entry.position.clone()))
};
if let Some((note_name, position)) = target {
load_note_helper(
¬e_name,
app_state,
autosave_state,
active_editor,
statusbar,
Some(position),
None,
);
}
}
fn relayout_content(
win_w: i32,
win_h: i32,
on_air: &Rc<RefCell<OnAirBar>>,
search_bar: &Rc<RefCell<SearchBar>>,
active_editor: &Rc<RefCell<Rc<RefCell<dyn NoteUI>>>>,
statusbar: &Rc<RefCell<StatusBar>>,
) {
let on_air_h = {
let bar = on_air.borrow();
if bar.visible() { bar.height() } else { 0 }
};
let search_h = if search_bar.borrow().visible() {
search_bar::BAR_HEIGHT
} else {
0
};
let statusbar_h = {
let sb = statusbar.borrow();
if sb.visible() { sb.height() } else { 0 }
};
if on_air_h > 0 {
on_air.borrow_mut().resize(0, CONTENT_TOP, win_w);
}
let search_top = CONTENT_TOP + on_air_h;
if search_h > 0 {
search_bar.borrow_mut().resize(0, search_top, win_w);
}
let editor_top = search_top + search_h;
let editor_h = (win_h - editor_top - statusbar_h).max(0);
if let Ok(ed_ptr) = active_editor.try_borrow()
&& let Ok(mut ed) = ed_ptr.try_borrow_mut()
&& let Some(structured) = ed.as_any_mut().downcast_mut::<StructuredRichUI>()
{
structured.resize(0, editor_top, win_w, editor_h);
}
}
#[allow(clippy::too_many_arguments)]
fn start_sharing(
app_state: &Rc<RefCell<AppState>>,
active_editor: &Rc<RefCell<Rc<RefCell<dyn NoteUI>>>>,
live_share: &Rc<RefCell<Option<LiveShare>>>,
on_air: &Rc<RefCell<OnAirBar>>,
search_bar: &Rc<RefCell<SearchBar>>,
statusbar: &Rc<RefCell<StatusBar>>,
wind_ref: &Rc<RefCell<window::Window>>,
) {
if live_share.borrow().is_some() {
return;
}
let (dir, note) = {
let st = app_state.borrow();
(st.store.base_path().to_path_buf(), st.current_note.clone())
};
let markdown = active_editor.borrow().borrow().get_content();
match LiveShare::start(dir, note.clone(), markdown) {
Ok(session) => {
let url = session.url_for(¬e);
{
let mut bar = on_air.borrow_mut();
bar.set_url(&url);
bar.show();
}
*live_share.borrow_mut() = Some(session);
let (w, h) = {
let win = wind_ref.borrow();
(win.width(), win.height())
};
relayout_content(w, h, on_air, search_bar, active_editor, statusbar);
statusbar
.borrow_mut()
.set_status(&format!("Sharing live at {url}"));
app::redraw();
let _ = webbrowser::open(&url);
}
Err(e) => {
statusbar
.borrow_mut()
.set_status(&format!("Could not start sharing: {e}"));
}
}
}
fn stop_sharing(
live_share: &Rc<RefCell<Option<LiveShare>>>,
on_air: &Rc<RefCell<OnAirBar>>,
search_bar: &Rc<RefCell<SearchBar>>,
active_editor: &Rc<RefCell<Rc<RefCell<dyn NoteUI>>>>,
statusbar: &Rc<RefCell<StatusBar>>,
wind_ref: &Rc<RefCell<window::Window>>,
) {
let session = live_share.borrow_mut().take();
if session.is_none() {
return;
}
drop(session);
on_air.borrow_mut().hide();
let (w, h) = {
let win = wind_ref.borrow();
(win.width(), win.height())
};
relayout_content(w, h, on_air, search_bar, active_editor, statusbar);
statusbar.borrow_mut().set_status("Live sharing stopped.");
app::redraw();
}
fn get_directory(dir_opt: Option<PathBuf>) -> PathBuf {
dir_opt.unwrap_or_else(|| {
std::env::var("HOME")
.ok()
.map(|home| PathBuf::from(home).join(".piki"))
.unwrap_or_else(|| PathBuf::from(".piki"))
})
}
fn main() {
let args = Args::parse();
let directory = get_directory(args.directory);
if !directory.exists()
&& let Err(e) = std::fs::create_dir_all(&directory)
{
eprintln!(
"Error: Failed to create directory '{}': {}",
directory.display(),
e
);
std::process::exit(1);
}
if !directory.is_dir() {
eprintln!("Error: '{}' is not a directory", directory.display());
std::process::exit(1);
}
let app = app::App::default();
app_icon::set_macos_dock_icon();
let window_state_path = window_state::state_file_path().map(Rc::new);
let mut wind = window::Window::default()
.with_size(400, 650) .with_label("Piki");
if let Some(path) = window_state_path.as_ref()
&& let Some(saved_state) = window_state::load_state(path.as_path())
&& saved_state.width > 0
&& saved_state.height > 0
{
wind.resize(
saved_state.x,
saved_state.y,
saved_state.width,
saved_state.height,
);
}
app_icon::set_window_icon(&mut wind);
wind.begin();
let store = DocumentStore::new(directory.clone());
let mut plugin_registry = PluginRegistry::new();
plugin_registry.register("index", Box::new(IndexPlugin));
plugin_registry.register("todo", Box::new(TodoPlugin));
let recent_notes_path = window_state::recent_notes_file(&directory);
let app_state = Rc::new(RefCell::new(AppState::new(
store,
plugin_registry,
args.note.clone(),
recent_notes_path,
)));
let autosave_state = Rc::new(RefCell::new(AutoSaveState::new()));
let live_share: Rc<RefCell<Option<LiveShare>>> = Rc::new(RefCell::new(None));
#[cfg(target_os = "macos")]
let editor_padding = 0;
#[cfg(not(target_os = "macos"))]
let editor_padding = 0;
let statusbar_size = 25;
#[cfg(target_os = "macos")]
let (editor_y, editor_height) = (editor_padding, wind.h() - statusbar_size - editor_padding);
#[cfg(not(target_os = "macos"))]
let (editor_y, editor_height) = (
25 + editor_padding,
wind.h() - statusbar_size - editor_padding - 25,
);
let editor_x = editor_padding;
let editor_w = wind.w() - 2 * editor_padding;
let editor_h = editor_height;
let rich_editor: Rc<RefCell<dyn NoteUI>> = Rc::new(RefCell::new(StructuredRichUI::new(
editor_x, editor_y, editor_w, editor_h, true,
)));
let active_editor: Rc<RefCell<Rc<RefCell<dyn NoteUI>>>> = Rc::new(RefCell::new(rich_editor));
let statusbar = Rc::new(RefCell::new(StatusBar::new(
0,
wind.h() - statusbar_size,
wind.w(),
statusbar_size,
)));
let wind_ref = Rc::new(RefCell::new(wind.clone()));
let saved_fullscreen = window_state_path
.as_ref()
.and_then(|path| window_state::load_state(path.as_path()))
.map(|state| state.fullscreen)
.unwrap_or(false);
let window_geometry = Rc::new(RefCell::new(WindowGeometry {
x: wind.x(),
y: wind.y(),
width: wind.width(),
height: wind.height(),
fullscreen: saved_fullscreen,
}));
let search_bar = Rc::new(RefCell::new(SearchBar::new(editor_x, editor_y, editor_w)));
let on_air = Rc::new(RefCell::new(OnAirBar::new(editor_x, editor_y, editor_w)));
{
let live_share = live_share.clone();
let on_air_for_stop = on_air.clone();
let search_bar = search_bar.clone();
let active_editor = active_editor.clone();
let statusbar = statusbar.clone();
let wind_ref = wind_ref.clone();
on_air.borrow_mut().on_stop(move || {
stop_sharing(
&live_share,
&on_air_for_stop,
&search_bar,
&active_editor,
&statusbar,
&wind_ref,
);
});
}
{
let on_air_for_link = on_air.clone();
let statusbar = statusbar.clone();
on_air.borrow_mut().on_link_click(move || {
let url = on_air_for_link.borrow().url();
if !url.is_empty()
&& let Err(e) = webbrowser::open(&url)
{
statusbar
.borrow_mut()
.set_status(&format!("Failed to open link: {e}"));
}
});
}
{
let live_share = live_share.clone();
let on_air = on_air.clone();
SHARE_HOOK.with(|hook| {
*hook.borrow_mut() = Some(Box::new(move |note: &str, markdown: &str| {
if let Some(session) = live_share.borrow().as_ref() {
session.set_current(note, markdown);
if let Ok(mut bar) = on_air.try_borrow_mut() {
bar.set_url(&session.url_for(note));
}
}
}));
});
}
#[cfg(target_os = "macos")]
menu::setup_menu(
app_state.clone(),
autosave_state.clone(),
active_editor.clone(),
statusbar.clone(),
wind_ref.clone(),
window_geometry.clone(),
search_bar.clone(),
live_share.clone(),
on_air.clone(),
);
#[cfg(not(target_os = "macos"))]
let _menu_bar = menu::setup_menu(
app_state.clone(),
autosave_state.clone(),
active_editor.clone(),
statusbar.clone(),
wind_ref.clone(),
window_geometry.clone(),
search_bar.clone(),
live_share.clone(),
on_air.clone(),
);
active_editor
.borrow()
.borrow_mut()
.set_bg_color(enums::Color::from_rgb(255, 255, 245));
{
let search_bar_for_search = search_bar.clone();
let editor_for_search = active_editor.clone();
search_bar.borrow().on_search(move |term| {
if let Ok(ed_ptr) = editor_for_search.try_borrow()
&& let Ok(mut ed) = ed_ptr.try_borrow_mut()
&& let Some(structured) = ed.as_any_mut().downcast_mut::<StructuredRichUI>()
{
let count = structured.search(&term);
if let Ok(mut sb) = search_bar_for_search.try_borrow_mut() {
sb.set_match_count(if count > 0 { Some(0) } else { None }, count);
}
if count > 0 {
structured.scroll_to_current_match();
}
app::redraw();
}
});
}
{
let search_bar_for_next = search_bar.clone();
let editor_for_next = active_editor.clone();
search_bar.borrow().on_next(move || {
if let Ok(ed_ptr) = editor_for_next.try_borrow()
&& let Ok(mut ed) = ed_ptr.try_borrow_mut()
&& let Some(structured) = ed.as_any_mut().downcast_mut::<StructuredRichUI>()
&& structured.next_match()
{
let total = structured.search_matches().len();
let current = structured.search_current_index();
if let Ok(mut sb) = search_bar_for_next.try_borrow_mut() {
sb.set_match_count(current, total);
}
structured.scroll_to_current_match();
app::redraw();
}
});
}
{
let search_bar_for_prev = search_bar.clone();
let editor_for_prev = active_editor.clone();
search_bar.borrow().on_prev(move || {
if let Ok(ed_ptr) = editor_for_prev.try_borrow()
&& let Ok(mut ed) = ed_ptr.try_borrow_mut()
&& let Some(structured) = ed.as_any_mut().downcast_mut::<StructuredRichUI>()
&& structured.prev_match()
{
let total = structured.search_matches().len();
let current = structured.search_current_index();
if let Ok(mut sb) = search_bar_for_prev.try_borrow_mut() {
sb.set_match_count(current, total);
}
structured.scroll_to_current_match();
app::redraw();
}
});
}
{
let search_bar_for_close = search_bar.clone();
let editor_for_close = active_editor.clone();
search_bar.borrow().on_close(move || {
if let Ok(ed_ptr) = editor_for_close.try_borrow()
&& let Ok(mut ed) = ed_ptr.try_borrow_mut()
&& let Some(structured) = ed.as_any_mut().downcast_mut::<StructuredRichUI>()
{
let bar_h = search_bar::BAR_HEIGHT;
let x = structured.x();
let y = structured.y();
let w = structured.width();
let h = structured.height();
structured.resize(x, y - bar_h, w, h + bar_h);
structured.clear_search();
}
if let Ok(mut sb) = search_bar_for_close.try_borrow_mut() {
sb.hide();
}
if let Ok(ed_ptr) = editor_for_close.try_borrow()
&& let Ok(mut ed) = ed_ptr.try_borrow_mut()
{
ed.take_focus();
}
app::redraw();
});
}
wind.end();
let pending_save_handle = Rc::new(RefCell::new(None::<app::TimeoutHandle>));
{
let geometry = window_geometry.clone();
let pending = pending_save_handle.clone();
let state_path_for_handler = window_state_path.clone();
let search_bar_for_resize = search_bar.clone();
let on_air_for_resize = on_air.clone();
let active_editor_for_resize = active_editor.clone();
let statusbar_for_resize = statusbar.clone();
let app_state_for_close = app_state.clone();
let autosave_for_close = autosave_state.clone();
let live_share_for_close = live_share.clone();
wind.handle(move |win, event| match event {
enums::Event::Move | enums::Event::Resize => {
if geometry.borrow().fullscreen {
return false;
}
if (win.x() == geometry.borrow().x)
&& (win.y() == geometry.borrow().y)
&& (win.width() == geometry.borrow().width)
&& (win.height() == geometry.borrow().height)
{
return false;
}
let is_fullscreen = geometry.borrow().fullscreen;
if !is_fullscreen {
relayout_content(
win.width(),
win.height(),
&on_air_for_resize,
&search_bar_for_resize,
&active_editor_for_resize,
&statusbar_for_resize,
);
}
{
let mut geom = geometry.borrow_mut();
geom.x = win.x();
geom.y = win.y();
geom.width = win.width();
geom.height = win.height();
}
if let Some(handle) = {
let mut slot = pending.borrow_mut();
slot.take()
} {
app::remove_timeout3(handle);
}
if let Some(path) = state_path_for_handler.as_ref() {
let geometry_for_timer = geometry.clone();
let pending_for_timer = pending.clone();
let path_for_timer = path.clone();
let new_handle = app::add_timeout3(WINDOW_STATE_SAVE_TIMEOUT_SECS, move |_| {
let snapshot = geometry_for_timer.borrow().clone();
if let Err(err) =
window_state::save_state(path_for_timer.as_path(), &snapshot)
{
eprintln!("Failed to save window state: {err}");
}
pending_for_timer.borrow_mut().take();
});
pending.borrow_mut().replace(new_handle);
}
false
}
enums::Event::Close => {
save_current_note(
&app_state_for_close,
&autosave_for_close,
&active_editor_for_resize,
&statusbar_for_resize,
);
let session = live_share_for_close.borrow_mut().take();
drop(session);
if let Some(handle) = {
let mut slot = pending.borrow_mut();
slot.take()
} {
app::remove_timeout3(handle);
}
if let Some(path) = state_path_for_handler.as_ref() {
let snapshot = geometry.borrow().clone();
if let Err(err) = window_state::save_state(path.as_path(), &snapshot) {
eprintln!("Failed to save window state on close: {err}");
}
}
false
}
_ => false,
});
}
active_editor.borrow().borrow().set_resizable(&mut wind);
wind.show();
if saved_fullscreen {
let win_center_x = wind.x() + wind.width() / 2;
let win_center_y = wind.y() + wind.height() / 2;
let screen_num = app::screen_num(win_center_x, win_center_y);
wind.fullscreen(true);
let (_, _, screen_w, screen_h) = app::screen_xywh(screen_num);
let font_size = 14; let char_width = (font_size as f32 * 0.55) as i32;
let target_text_width = char_width * 90; let scrollbar_width = 15;
let available_width = screen_w - scrollbar_width;
let padding = ((available_width - target_text_width) / 2).max(25);
if let Ok(active_ptr) = active_editor.try_borrow()
&& let Ok(mut editor) = active_ptr.try_borrow_mut()
&& let Some(structured) = editor.as_any_mut().downcast_mut::<StructuredRichUI>()
{
structured.set_horizontal_padding(padding);
let y = structured.y();
structured.resize(0, y, screen_w, screen_h - y);
}
statusbar.borrow_mut().hide();
}
{
let app_state = app_state.clone();
let autosave_state = autosave_state.clone();
let active_editor = active_editor.clone();
let statusbar_for_click = statusbar.clone();
let wind_for_click = wind.clone();
statusbar.borrow_mut().on_note_click(move |_| {
note_picker::show_note_picker(
app_state.clone(),
autosave_state.clone(),
active_editor.clone(),
statusbar_for_click.clone(),
&wind_for_click,
);
});
}
load_note_helper(
&args.note,
&app_state,
&autosave_state,
&active_editor,
&statusbar,
None,
None,
);
wire_editor_callbacks(
&active_editor,
&autosave_state,
&app_state,
&statusbar,
&live_share,
);
{
let autosave_ref = autosave_state.clone();
let statusbar_ref = statusbar.clone();
app::add_timeout3(SAVE_STATUS_UPDATE_INTERVAL_SECS, move |handle| {
if let (Ok(as_state), Ok(mut sb)) =
(autosave_ref.try_borrow(), statusbar_ref.try_borrow_mut())
&& !as_state.is_saving
&& as_state.last_save_time.is_some()
{
sb.set_status(&as_state.get_status_text());
app::redraw();
}
app::repeat_timeout3(SAVE_STATUS_UPDATE_INTERVAL_SECS, handle);
});
}
{
let start = Instant::now();
let editor_ref = active_editor.clone();
let on_air_ref = on_air.clone();
app::add_timeout3(0.1, move |handle| {
let ms = start.elapsed().as_millis() as u64;
if let Ok(ed_ptr) = editor_ref.try_borrow()
&& let Ok(mut ed) = (*ed_ptr).try_borrow_mut()
{
ed.tick(ms);
}
if let Ok(mut bar) = on_air_ref.try_borrow_mut() {
bar.tick(ms);
}
app::repeat_timeout3(0.1, handle);
});
}
app_icon::set_macos_app_name("Piki");
app_icon::set_macos_about();
{
let app_state = app_state.clone();
let autosave_state = autosave_state.clone();
let active_editor = active_editor.clone();
let statusbar = statusbar.clone();
app_url::set_open_url_handler(move |url: String| {
let target = section_link::normalize_link_target(&url);
let (note, fragment) = section_link::split_target(&target);
let note = note.to_string();
let fragment = fragment.map(str::to_string);
let app_state = app_state.clone();
let autosave_state = autosave_state.clone();
let active_editor = active_editor.clone();
let statusbar = statusbar.clone();
app::awake_callback(move || {
load_note_helper(
¬e,
&app_state,
&autosave_state,
&active_editor,
&statusbar,
None,
fragment.as_deref(),
);
});
});
app_url::register();
}
app.run().unwrap();
}
fn wire_editor_callbacks(
active_editor: &Rc<RefCell<Rc<RefCell<dyn NoteUI>>>>,
autosave_state: &Rc<RefCell<AutoSaveState>>,
app_state: &Rc<RefCell<AppState>>,
statusbar: &Rc<RefCell<StatusBar>>,
live_share: &Rc<RefCell<Option<LiveShare>>>,
) {
let editor_for_callback = active_editor.clone();
let autosave_for_callback = autosave_state.clone();
let app_state_for_callback = app_state.clone();
let statusbar_for_callback = statusbar.clone();
let live_share_for_change = live_share.clone();
let current_for_change = active_editor.borrow().clone();
current_for_change.borrow_mut().on_change(Box::new(move || {
let editor_clone = editor_for_callback.clone();
app::awake_callback(move || {
if let Ok(ed_ptr) = editor_clone.try_borrow() {
let mut ed_ref = (*ed_ptr).borrow_mut();
ed_ref.restyle();
}
});
if live_share_for_change.borrow().is_some() {
let live = live_share_for_change.clone();
let editor = editor_for_callback.clone();
let app_state = app_state_for_callback.clone();
app::awake_callback(move || {
if let (Ok(ed_ptr), Ok(app_st)) = (editor.try_borrow(), app_state.try_borrow())
&& let Ok(inner) = ed_ptr.try_borrow()
{
let markdown = inner.get_content();
let note = app_st.current_note.clone();
if let Some(session) = live.borrow().as_ref() {
session.set_current(¬e, &markdown);
}
}
});
}
if let Ok(mut as_state) = autosave_for_callback.try_borrow_mut() {
as_state.mark_changed();
}
let editor_clone = editor_for_callback.clone();
let autosave_clone = autosave_for_callback.clone();
let app_state_clone = app_state_for_callback.clone();
let statusbar_clone = statusbar_for_callback.clone();
app::add_timeout3(AUTOSAVE_INTERVAL_SECS, move |_| {
let should_save = autosave_clone
.try_borrow()
.map(|s| s.pending_save)
.unwrap_or(false);
if should_save {
if let Ok(mut sb) = statusbar_clone.try_borrow_mut() {
sb.set_status("Saving …");
app::redraw();
}
if let (Ok(ed_ptr), Ok(mut as_state), Ok(app_st)) = (
editor_clone.try_borrow(),
autosave_clone.try_borrow_mut(),
app_state_clone.try_borrow(),
) {
let ed_ref = (*ed_ptr).borrow();
match as_state.trigger_save(&*ed_ref, &app_st.store) {
Ok(()) => {
if let Ok(mut sb) = statusbar_clone.try_borrow_mut() {
sb.set_status(&as_state.get_status_text());
app::redraw();
}
}
Err(e) => {
if let Ok(mut sb) = statusbar_clone.try_borrow_mut() {
sb.set_status(&format!("Error: {}", e));
app::redraw();
}
}
}
}
}
});
}));
let app_state_links = app_state.clone();
let autosave_links = autosave_state.clone();
let statusbar_links = statusbar.clone();
let current_for_links = active_editor.borrow().clone();
{
let mut cur = current_for_links.borrow_mut();
let active_clone = active_editor.clone();
cur.on_link_click(Box::new(move |link_dest: String| {
let normalized = section_link::normalize_link_target(&link_dest);
if link_handler::is_external_link(&normalized) {
let statusbar = statusbar_links.clone();
app::awake_callback(move || {
if let Err(e) = webbrowser::open(&normalized) {
statusbar
.borrow_mut()
.set_status(&format!("Failed to open link: {}", e));
app::redraw();
}
});
return;
}
let (note, fragment) = section_link::split_target(&normalized);
let note = note.to_string();
let fragment = fragment.map(str::to_string);
let app_state = app_state_links.clone();
let autosave_state = autosave_links.clone();
let editor_ref = active_clone.clone();
let statusbar = statusbar_links.clone();
app::awake_callback(move || {
load_note_helper(
¬e,
&app_state,
&autosave_state,
&editor_ref,
&statusbar,
None,
fragment.as_deref(),
);
});
}));
}
let current_for_hover = active_editor.borrow().clone();
{
let mut cur = current_for_hover.borrow_mut();
let statusbar_clone = statusbar.clone();
let base_label: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
cur.on_link_hover(Box::new(move |target: Option<String>| {
let statusbar_for_cb = statusbar_clone.clone();
let base_label_for_cb = base_label.clone();
let tgt = target.clone();
app::awake_callback(move || {
match &tgt {
Some(dest) => {
let dest = dest.clone();
if base_label_for_cb.borrow().is_none() {
let current = statusbar_for_cb.borrow().note_status_widget().label();
*base_label_for_cb.borrow_mut() = Some(current);
}
statusbar_for_cb.borrow_mut().set_note(&dest);
}
None => {
if let Some(orig) = base_label_for_cb.borrow_mut().take() {
statusbar_for_cb.borrow_mut().set_note(&orig);
}
}
}
app::redraw();
});
}));
}
}