forked from jess/Acord
1
0
Fork 0

- Added Settings menu into windows and linux

- Switched from 3 Menu implementations / removed  Muda on Windows.
 	[Iced Menu Bar for both Linux and Windows now.]
This commit is contained in:
jess 2026-04-29 17:35:38 -07:00
parent 33ab38d5a9
commit 3d357209e6
8 changed files with 455 additions and 224 deletions

View File

@ -60,7 +60,7 @@ impl App {
} }
} }
fn sync_settings(&self) { fn sync_settings(&mut self) {
if self.handle.is_null() { return; } if self.handle.is_null() { return; }
let theme = match self.config.theme_mode() { let theme = match self.config.theme_mode() {
"dark" => "kicad", "dark" => "kicad",
@ -74,6 +74,14 @@ impl App {
viewport_set_line_indicator(self.handle, ind.as_ptr()); viewport_set_line_indicator(self.handle, ind.as_ptr());
viewport_set_gutter_rainbow(self.handle, self.config.gutter_rainbow()); viewport_set_gutter_rainbow(self.handle, self.config.gutter_rainbow());
viewport_set_auto_pair_flags(self.handle, self.config.auto_pair_flags()); viewport_set_auto_pair_flags(self.handle, self.config.auto_pair_flags());
let view = acord_viewport::editor::SettingsView {
theme_mode: self.config.theme_mode().to_string(),
line_indicator: self.config.line_indicator().to_string(),
gutter_rainbow: self.config.gutter_rainbow(),
auto_save_dir: self.config.notes_dir().to_string_lossy().into_owned(),
};
unsafe { (*self.handle).state.settings_view = view; }
} }
fn dispatch_menu(&mut self, action: MenuAction, event_loop: &ActiveEventLoop) { fn dispatch_menu(&mut self, action: MenuAction, event_loop: &ActiveEventLoop) {
@ -170,9 +178,29 @@ impl App {
ShellAction::Save => self.save_file(), ShellAction::Save => self.save_file(),
ShellAction::SaveAs => self.save_file_as(), ShellAction::SaveAs => self.save_file_as(),
ShellAction::Quit => event_loop.exit(), ShellAction::Quit => event_loop.exit(),
ShellAction::Settings => self.dispatch_menu(MenuAction::Settings, event_loop), ShellAction::Settings => {}
ShellAction::ExportCrate => self.dispatch_menu(MenuAction::ExportCrate, event_loop), ShellAction::ExportCrate => self.dispatch_menu(MenuAction::ExportCrate, event_loop),
ShellAction::ToggleBrowser => self.toggle_browser(event_loop), ShellAction::ToggleBrowser => self.toggle_browser(event_loop),
ShellAction::SetThemeMode(v) => {
self.config.set("themeMode", &v);
self.sync_settings();
}
ShellAction::SetLineIndicator(v) => {
self.config.set("lineIndicatorMode", &v);
self.sync_settings();
}
ShellAction::SetGutterRainbow(b) => {
self.config.set("gutterRainbow", if b { "true" } else { "false" });
self.sync_settings();
}
ShellAction::PickAutoSaveDir => {
let dialog = rfd::FileDialog::new()
.set_directory(self.config.notes_dir());
if let Some(path) = dialog.pick_folder() {
self.config.set("autoSaveDirectory", &path.to_string_lossy());
self.sync_settings();
}
}
} }
} }
@ -294,10 +322,15 @@ impl App {
} }
fn save_file(&mut self) { fn save_file(&mut self) {
match self.current_file.clone() { if let Some(path) = self.current_file.clone() {
Some(path) => self.write_to(&path), self.write_to(&path);
None => self.save_file_as(), return;
} }
let notes_dir = self.config.notes_dir();
let _ = std::fs::create_dir_all(&notes_dir);
let path = notes_dir.join(format!("{}.md", self.derive_default_filename()));
self.write_to(&path);
self.current_file = Some(path);
} }
fn save_file_as(&mut self) { fn save_file_as(&mut self) {
@ -307,7 +340,7 @@ impl App {
.add_filter("Markdown", &["md"]) .add_filter("Markdown", &["md"])
.add_filter("All Files", &["*"]) .add_filter("All Files", &["*"])
.set_directory(&notes_dir) .set_directory(&notes_dir)
.set_file_name("note.md"); .set_file_name(format!("{}.md", self.derive_default_filename()));
if let Some(path) = dialog.save_file() { if let Some(path) = dialog.save_file() {
self.write_to(&path); self.write_to(&path);
self.current_file = Some(path); self.current_file = Some(path);
@ -326,6 +359,31 @@ impl App {
} }
} }
fn derive_default_filename(&self) -> String {
let text_ptr = viewport_get_text(self.handle);
let text = if text_ptr.is_null() {
String::new()
} else {
let s = unsafe { std::ffi::CStr::from_ptr(text_ptr) }
.to_string_lossy()
.into_owned();
viewport_free_string(text_ptr);
s
};
let title = text.lines().next().unwrap_or("").trim_start();
let title = title.trim_start_matches('#').trim();
let cleaned: String = title
.chars()
.filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
.collect();
let cleaned = cleaned.trim();
if cleaned.is_empty() {
"Untitled".to_string()
} else {
cleaned.chars().take(60).collect()
}
}
fn new_note(&mut self) { fn new_note(&mut self) {
viewport_send_command(self.handle, 12); viewport_send_command(self.handle, 12);
let stub = CString::new("# ").unwrap(); let stub = CString::new("# ").unwrap();

View File

@ -63,18 +63,7 @@ impl Config {
} }
} }
/// XDG-friendly config dir with `~/.acord` fallback for parity with the
/// Windows shell. `$XDG_CONFIG_HOME/acord` if set, else `~/.config/acord`,
/// else `~/.acord`.
fn config_dir() -> PathBuf { fn config_dir() -> PathBuf {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return PathBuf::from(xdg).join("acord");
}
}
if let Some(cfg) = dirs::config_dir() {
return cfg.join("acord");
}
dirs::home_dir() dirs::home_dir()
.unwrap_or_else(|| PathBuf::from(".")) .unwrap_or_else(|| PathBuf::from("."))
.join(".acord") .join(".acord")

View File

@ -186,7 +186,7 @@ pub enum MenuCategory {
View, View,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShellAction { pub enum ShellAction {
NewNote, NewNote,
Open, Open,
@ -196,9 +196,13 @@ pub enum ShellAction {
Settings, Settings,
ExportCrate, ExportCrate,
ToggleBrowser, ToggleBrowser,
SetThemeMode(String),
SetLineIndicator(String),
SetGutterRainbow(bool),
PickAutoSaveDir,
} }
#[cfg(target_os = "linux")] #[cfg(any(target_os = "linux", target_os = "windows"))]
const MENU_CATS: [(MenuCategory, &'static str); 4] = [ const MENU_CATS: [(MenuCategory, &'static str); 4] = [
(MenuCategory::File, "File"), (MenuCategory::File, "File"),
(MenuCategory::Edit, "Edit"), (MenuCategory::Edit, "Edit"),
@ -206,7 +210,7 @@ const MENU_CATS: [(MenuCategory, &'static str); 4] = [
(MenuCategory::View, "View"), (MenuCategory::View, "View"),
]; ];
#[cfg(target_os = "linux")] #[cfg(any(target_os = "linux", target_os = "windows"))]
fn cat_btn_width(label: &str, char_w: f32, pad_x: f32) -> f32 { fn cat_btn_width(label: &str, char_w: f32, pad_x: f32) -> f32 {
label.chars().count() as f32 * char_w + pad_x * 2.0 label.chars().count() as f32 * char_w + pad_x * 2.0
} }
@ -430,6 +434,27 @@ pub struct EditorState {
pub menu_open: Option<MenuCategory>, pub menu_open: Option<MenuCategory>,
pub pending_shell_action: Option<ShellAction>, pub pending_shell_action: Option<ShellAction>,
pub settings_open: bool,
pub settings_view: SettingsView,
}
#[derive(Debug, Clone)]
pub struct SettingsView {
pub theme_mode: String,
pub line_indicator: String,
pub gutter_rainbow: bool,
pub auto_save_dir: String,
}
impl Default for SettingsView {
fn default() -> Self {
Self {
theme_mode: "auto".to_string(),
line_indicator: "on".to_string(),
gutter_rainbow: true,
auto_save_dir: String::new(),
}
}
} }
/// per-eval table name to id bookkeeping /// per-eval table name to id bookkeeping
@ -525,6 +550,8 @@ impl EditorState {
prev_cursor_line: 0, prev_cursor_line: 0,
menu_open: None, menu_open: None,
pending_shell_action: None, pending_shell_action: None,
settings_open: false,
settings_view: SettingsView::default(),
} }
} }
@ -3091,8 +3118,15 @@ impl EditorState {
self.menu_open = None; self.menu_open = None;
} }
Message::Shell(action) => { Message::Shell(action) => {
self.pending_shell_action = Some(action);
self.menu_open = None; self.menu_open = None;
match action {
ShellAction::Settings => {
self.settings_open = !self.settings_open;
}
other => {
self.pending_shell_action = Some(other);
}
}
} }
Message::CopyLiteral(text) => { Message::CopyLiteral(text) => {
self.pending_clipboard = Some(text); self.pending_clipboard = Some(text);
@ -3332,7 +3366,7 @@ impl EditorState {
let mut col_items: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> = Vec::new(); let mut col_items: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
#[cfg(target_os = "linux")] #[cfg(any(target_os = "linux", target_os = "windows"))]
col_items.push(self.menu_strip()); col_items.push(self.menu_strip());
col_items.push(main_content); col_items.push(main_content);
@ -3348,7 +3382,12 @@ impl EditorState {
.height(Length::Fill) .height(Length::Fill)
.into(); .into();
#[cfg(target_os = "linux")] #[cfg(any(target_os = "linux", target_os = "windows"))]
if self.settings_open {
return iced_widget::stack![body, self.settings_panel()].into();
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
if let Some(cat) = self.menu_open { if let Some(cat) = self.menu_open {
return iced_widget::stack![body, self.menu_dropdown(cat)].into(); return iced_widget::stack![body, self.menu_dropdown(cat)].into();
} }
@ -3364,9 +3403,9 @@ impl EditorState {
&& self.block_at(0).map(|b| b.as_any().is::<TextBlock>()).unwrap_or(false) && self.block_at(0).map(|b| b.as_any().is::<TextBlock>()).unwrap_or(false)
&& !has_computed_layers; && !has_computed_layers;
#[cfg(target_os = "linux")] #[cfg(any(target_os = "linux", target_os = "windows"))]
let title_bar_h = 0.0_f32; let title_bar_h = 0.0_f32;
#[cfg(not(target_os = "linux"))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
let title_bar_h = 38.0_f32; let title_bar_h = 38.0_f32;
let mut block_elements: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> = Vec::new(); let mut block_elements: Vec<Element<'_, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
@ -3994,7 +4033,7 @@ impl EditorState {
.into() .into()
} }
#[cfg(target_os = "linux")] #[cfg(any(target_os = "linux", target_os = "windows"))]
fn menu_strip(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { fn menu_strip(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let p = palette::current(); let p = palette::current();
let f = self.font_size; let f = self.font_size;
@ -4039,7 +4078,7 @@ impl EditorState {
} }
/// returns the dropdown panel for the open category, anchored under its strip button /// returns the dropdown panel for the open category, anchored under its strip button
#[cfg(target_os = "linux")] #[cfg(any(target_os = "linux", target_os = "windows"))]
fn menu_dropdown(&self, cat: MenuCategory) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { fn menu_dropdown(&self, cat: MenuCategory) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let p = palette::current(); let p = palette::current();
let f = self.font_size; let f = self.font_size;
@ -4169,6 +4208,182 @@ impl EditorState {
.into() .into()
} }
#[cfg(any(target_os = "linux", target_os = "windows"))]
fn settings_panel(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let p = palette::current();
let f = self.font_size;
let item_pad_x = f * 0.95;
let item_pad_y = f * 0.32;
let panel_radius = f * 0.30;
let label_size = f * 0.92;
let title_size = f * 1.05;
let row_gap = f * 0.55;
let panel_width = f * 28.0;
let title = iced_widget::text("Settings")
.size(title_size)
.font(syntax::EDITOR_FONT)
.color(p.text);
let theme_row = self.settings_segment_row(
"Theme",
label_size,
&[
("Auto", "auto"),
("Light", "light"),
("Dark", "dark"),
],
&self.settings_view.theme_mode,
|v| Message::Shell(ShellAction::SetThemeMode(v.to_string())),
);
let line_row = self.settings_segment_row(
"Line indicator",
label_size,
&[
("Off", "off"),
("Line", "line"),
("On", "on"),
],
&self.settings_view.line_indicator,
|v| Message::Shell(ShellAction::SetLineIndicator(v.to_string())),
);
let rainbow_row = self.settings_segment_row(
"Gutter rainbow",
label_size,
&[
("Off", "false"),
("On", "true"),
],
if self.settings_view.gutter_rainbow { "true" } else { "false" },
|v| Message::Shell(ShellAction::SetGutterRainbow(v == "true")),
);
let dir_label = iced_widget::text("Auto-save folder")
.size(label_size)
.font(syntax::EDITOR_FONT)
.color(p.text)
.width(Length::Fill);
let dir_value = iced_widget::text(self.settings_view.auto_save_dir.clone())
.size(label_size)
.font(syntax::EDITOR_FONT)
.color(p.subtext0)
.width(Length::Fill);
let dir_btn = iced_widget::button(
iced_widget::text("Choose…")
.size(label_size)
.font(syntax::EDITOR_FONT)
)
.padding(Padding { top: item_pad_y * 0.6, right: item_pad_x * 0.7, bottom: item_pad_y * 0.6, left: item_pad_x * 0.7 })
.on_press(Message::Shell(ShellAction::PickAutoSaveDir))
.style(context_menu_item_style);
let dir_row: Element<'_, Message, Theme, iced_wgpu::Renderer> = iced_widget::column![
dir_label,
iced_widget::row![dir_value, dir_btn].spacing(f * 0.5),
]
.spacing(f * 0.2)
.into();
let close_btn = iced_widget::button(
iced_widget::text("Close")
.size(label_size)
.font(syntax::EDITOR_FONT)
)
.padding(Padding { top: item_pad_y * 0.6, right: item_pad_x, bottom: item_pad_y * 0.6, left: item_pad_x })
.on_press(Message::Shell(ShellAction::Settings))
.style(context_menu_item_style);
let panel = iced_widget::container(
iced_widget::column![
title,
theme_row,
line_row,
rainbow_row,
dir_row,
iced_widget::row![
iced_widget::Space::new().width(Length::Fill).height(Length::Shrink),
close_btn,
],
]
.spacing(row_gap)
.width(Length::Fixed(panel_width))
)
.padding(Padding { top: f, right: f, bottom: f, left: f })
.style(move |_t: &Theme| iced_widget::container::Style {
background: Some(Background::Color(p.surface0)),
border: Border {
color: p.surface1,
width: 1.0,
radius: panel_radius.into(),
},
text_color: Some(p.text),
shadow: Shadow::default(),
snap: false,
});
iced_widget::container(panel)
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.style(move |_t: &Theme| iced_widget::container::Style {
background: Some(Background::Color(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.4 })),
border: Border::default(),
text_color: None,
shadow: Shadow::default(),
snap: false,
})
.into()
}
#[cfg(any(target_os = "linux", target_os = "windows"))]
fn settings_segment_row<'a>(
&'a self,
label: &str,
label_size: f32,
options: &[(&str, &'a str)],
current: &str,
msg_for: impl Fn(&'a str) -> Message,
) -> Element<'a, Message, Theme, iced_wgpu::Renderer> {
let p = palette::current();
let f = self.font_size;
let mut buttons: Vec<Element<'a, Message, Theme, iced_wgpu::Renderer>> = Vec::new();
for (display, value) in options {
let active = *value == current;
let display = display.to_string();
let value = *value;
buttons.push(
iced_widget::button(
iced_widget::text(display)
.size(label_size)
.font(syntax::EDITOR_FONT)
)
.padding(Padding { top: f * 0.18, right: f * 0.55, bottom: f * 0.18, left: f * 0.55 })
.style(move |_t: &Theme, _s| iced_widget::button::Style {
background: if active { Some(Background::Color(p.surface2)) } else { Some(Background::Color(p.surface1)) },
text_color: if active { p.text } else { p.subtext0 },
border: Border { color: p.surface2, width: 1.0, radius: (f * 0.18).into() },
shadow: Shadow::default(),
snap: false,
})
.on_press(msg_for(value))
.into()
);
}
let label_w = iced_widget::text(label.to_string())
.size(label_size)
.font(syntax::EDITOR_FONT)
.color(p.text)
.width(Length::Fill);
iced_widget::row![
label_w,
iced_widget::row(buttons).spacing(f * 0.25),
]
.spacing(f)
.into()
}
fn find_bar(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { fn find_bar(&self) -> Element<'_, Message, Theme, iced_wgpu::Renderer> {
let p = palette::current(); let p = palette::current();

View File

@ -11,7 +11,6 @@ path = "src/main.rs"
acord-core = { path = "../core" } acord-core = { path = "../core" }
acord-viewport = { path = "../viewport" } acord-viewport = { path = "../viewport" }
winit = "0.30" winit = "0.30"
muda = "0.16"
arboard = "3" arboard = "3"
rfd = "0.15" rfd = "0.15"
raw-window-handle = "0.6" raw-window-handle = "0.6"

View File

@ -22,13 +22,13 @@ use acord_viewport::{
use acord_viewport::browser::{self, BrowserHandle}; use acord_viewport::browser::{self, BrowserHandle};
use crate::config::Config; use crate::config::Config;
use crate::menu::{AppMenu, MenuAction}; use crate::shortcuts::{match_shortcut, MenuAction};
use acord_viewport::editor::ShellAction;
pub struct App { pub struct App {
window: Option<Window>, window: Option<Window>,
handle: *mut ViewportHandle, handle: *mut ViewportHandle,
config: Config, config: Config,
_menu: Option<AppMenu>,
cursor_pos: PhysicalPosition<f64>, cursor_pos: PhysicalPosition<f64>,
scale: f32, scale: f32,
modifiers: ModifiersState, modifiers: ModifiersState,
@ -48,7 +48,6 @@ impl App {
window: None, window: None,
handle: std::ptr::null_mut(), handle: std::ptr::null_mut(),
config: Config::load(), config: Config::load(),
_menu: None,
cursor_pos: PhysicalPosition::new(0.0, 0.0), cursor_pos: PhysicalPosition::new(0.0, 0.0),
scale: 1.0, scale: 1.0,
modifiers: ModifiersState::empty(), modifiers: ModifiersState::empty(),
@ -62,12 +61,12 @@ impl App {
} }
} }
fn sync_settings(&self) { fn sync_settings(&mut self) {
if self.handle.is_null() { return; } if self.handle.is_null() { return; }
let theme = match self.config.theme_mode() { let theme = match self.config.theme_mode() {
"dark" => "kicad", "dark" => "kicad",
"light" => "latte", "light" => "latte",
_ => "kicad", // Windows: default dark. No NSAppearance auto-detect. _ => "kicad",
}; };
let c_theme = CString::new(theme).unwrap(); let c_theme = CString::new(theme).unwrap();
viewport_set_theme(self.handle, c_theme.as_ptr()); viewport_set_theme(self.handle, c_theme.as_ptr());
@ -76,6 +75,14 @@ impl App {
viewport_set_line_indicator(self.handle, ind.as_ptr()); viewport_set_line_indicator(self.handle, ind.as_ptr());
viewport_set_gutter_rainbow(self.handle, self.config.gutter_rainbow()); viewport_set_gutter_rainbow(self.handle, self.config.gutter_rainbow());
viewport_set_auto_pair_flags(self.handle, self.config.auto_pair_flags()); viewport_set_auto_pair_flags(self.handle, self.config.auto_pair_flags());
let view = acord_viewport::editor::SettingsView {
theme_mode: self.config.theme_mode().to_string(),
line_indicator: self.config.line_indicator().to_string(),
gutter_rainbow: self.config.gutter_rainbow(),
auto_save_dir: self.config.notes_dir().to_string_lossy().into_owned(),
};
unsafe { (*self.handle).state.settings_view = view; }
} }
fn dispatch_menu(&mut self, action: MenuAction, event_loop: &ActiveEventLoop) { fn dispatch_menu(&mut self, action: MenuAction, event_loop: &ActiveEventLoop) {
@ -97,29 +104,50 @@ impl App {
MenuAction::Save => self.save_file(), MenuAction::Save => self.save_file(),
MenuAction::SaveAs => self.save_file_as(), MenuAction::SaveAs => self.save_file_as(),
MenuAction::NewNote => self.new_note(), MenuAction::NewNote => self.new_note(),
MenuAction::Settings => { MenuAction::Settings => unsafe {
// Open config file in the default editor. (*self.handle).state.settings_open = !(*self.handle).state.settings_open;
let cfg = dirs::home_dir() },
.unwrap_or_default() MenuAction::ExportCrate => {}
.join(".acord")
.join("config.json");
let _ = std::process::Command::new("notepad").arg(&cfg).spawn();
}
MenuAction::Undo => { /* TODO */ },
MenuAction::Redo => { /* TODO */ },
MenuAction::ExportCrate => { /* TODO */ },
MenuAction::ToggleAutoPair(bit) => {
let new_flags = self.config.auto_pair_flags() ^ bit;
self.config.set_auto_pair_flags(new_flags);
viewport_set_auto_pair_flags(self.handle, new_flags);
if let Some(menu) = &self._menu {
menu.set_auto_pair_check(bit, (new_flags & bit) != 0);
}
}
MenuAction::ToggleBrowser => self.toggle_browser(event_loop), MenuAction::ToggleBrowser => self.toggle_browser(event_loop),
} }
} }
fn drain_shell_actions(&mut self, event_loop: &ActiveEventLoop) {
if self.handle.is_null() { return; }
let action = unsafe { (*self.handle).state.take_pending_shell_action() };
let Some(action) = action else { return };
match action {
ShellAction::NewNote => self.new_note(),
ShellAction::Open => self.open_file(),
ShellAction::Save => self.save_file(),
ShellAction::SaveAs => self.save_file_as(),
ShellAction::Quit => event_loop.exit(),
ShellAction::Settings => {}
ShellAction::ExportCrate => {}
ShellAction::ToggleBrowser => self.toggle_browser(event_loop),
ShellAction::SetThemeMode(v) => {
self.config.set("themeMode", &v);
self.sync_settings();
}
ShellAction::SetLineIndicator(v) => {
self.config.set("lineIndicatorMode", &v);
self.sync_settings();
}
ShellAction::SetGutterRainbow(b) => {
self.config.set("gutterRainbow", if b { "true" } else { "false" });
self.sync_settings();
}
ShellAction::PickAutoSaveDir => {
let dialog = rfd::FileDialog::new()
.set_directory(self.config.notes_dir());
if let Some(path) = dialog.pick_folder() {
self.config.set("autoSaveDirectory", &path.to_string_lossy());
self.sync_settings();
}
}
}
}
fn toggle_browser(&mut self, event_loop: &ActiveEventLoop) { fn toggle_browser(&mut self, event_loop: &ActiveEventLoop) {
if self.browser_window.is_some() { if self.browser_window.is_some() {
self.close_browser(); self.close_browser();
@ -217,17 +245,25 @@ impl App {
} }
fn save_file(&mut self) { fn save_file(&mut self) {
match self.current_file.clone() { if let Some(path) = self.current_file.clone() {
Some(path) => self.write_to(&path), self.write_to(&path);
None => self.save_file_as(), return;
} }
let notes_dir = self.config.notes_dir();
let _ = std::fs::create_dir_all(&notes_dir);
let path = notes_dir.join(format!("{}.md", self.derive_default_filename()));
self.write_to(&path);
self.current_file = Some(path);
} }
fn save_file_as(&mut self) { fn save_file_as(&mut self) {
let notes_dir = self.config.notes_dir();
let _ = std::fs::create_dir_all(&notes_dir);
let dialog = rfd::FileDialog::new() let dialog = rfd::FileDialog::new()
.add_filter("Markdown", &["md"]) .add_filter("Markdown", &["md"])
.add_filter("All Files", &["*"]) .add_filter("All Files", &["*"])
.set_file_name("note.md"); .set_directory(&notes_dir)
.set_file_name(format!("{}.md", self.derive_default_filename()));
if let Some(path) = dialog.save_file() { if let Some(path) = dialog.save_file() {
self.write_to(&path); self.write_to(&path);
self.current_file = Some(path); self.current_file = Some(path);
@ -246,6 +282,31 @@ impl App {
} }
} }
fn derive_default_filename(&self) -> String {
let text_ptr = viewport_get_text(self.handle);
let text = if text_ptr.is_null() {
String::new()
} else {
let s = unsafe { std::ffi::CStr::from_ptr(text_ptr) }
.to_string_lossy()
.into_owned();
viewport_free_string(text_ptr);
s
};
let title = text.lines().next().unwrap_or("").trim_start();
let title = title.trim_start_matches('#').trim();
let cleaned: String = title
.chars()
.filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|'))
.collect();
let cleaned = cleaned.trim();
if cleaned.is_empty() {
"Untitled".to_string()
} else {
cleaned.chars().take(60).collect()
}
}
fn new_note(&mut self) { fn new_note(&mut self) {
viewport_send_command(self.handle, 12); viewport_send_command(self.handle, 12);
let stub = CString::new("# ").unwrap(); let stub = CString::new("# ").unwrap();
@ -416,19 +477,9 @@ impl ApplicationHandler for App {
self.handle = viewport_create(hwnd, w, h, self.scale); self.handle = viewport_create(hwnd, w, h, self.scale);
self.sync_settings(); self.sync_settings();
viewport_send_command(self.handle, 12);
let app_menu = AppMenu::new(self.config.auto_pair_flags()); let stub = CString::new("# ").unwrap();
#[cfg(target_os = "windows")] viewport_set_text(self.handle, stub.as_ptr());
{
if let raw_window_handle::RawWindowHandle::Win32(h) = raw {
let theme = match self.config.theme_mode() {
"light" => muda::MenuTheme::Light,
_ => muda::MenuTheme::Dark,
};
unsafe { app_menu.menu.init_for_hwnd_with_theme(h.hwnd.get(), theme).ok(); }
}
}
self._menu = Some(app_menu);
self.window = Some(window); self.window = Some(window);
} }
@ -500,6 +551,14 @@ impl ApplicationHandler for App {
WindowEvent::KeyboardInput { event, .. } => { WindowEvent::KeyboardInput { event, .. } => {
let pressed = event.state == ElementState::Pressed; let pressed = event.state == ElementState::Pressed;
if pressed {
if let Some(action) = match_shortcut(self.modifiers, &event.logical_key) {
self.dispatch_menu(action, event_loop);
return;
}
}
let text_str = event.text.as_ref().map(|s| s.to_string()); let text_str = event.text.as_ref().map(|s| s.to_string());
let text_c = text_str.as_deref() let text_c = text_str.as_deref()
.and_then(|s| CString::new(s).ok()); .and_then(|s| CString::new(s).ok());
@ -534,13 +593,11 @@ impl ApplicationHandler for App {
} }
fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
while let Some(action) = AppMenu::poll() {
self.dispatch_menu(action, _event_loop);
}
if self.last_autosave_attempt.elapsed() >= Duration::from_millis(500) { if self.last_autosave_attempt.elapsed() >= Duration::from_millis(500) {
self.last_autosave_attempt = Instant::now(); self.last_autosave_attempt = Instant::now();
self.try_autosave(); self.try_autosave();
} }
self.drain_shell_actions(_event_loop);
self.drain_browser_open(); self.drain_browser_open();
if let Some(w) = &self.window { if let Some(w) = &self.window {
if !self.handle.is_null() { if !self.handle.is_null() {

View File

@ -3,7 +3,7 @@
mod app; mod app;
mod config; mod config;
mod menu; mod shortcuts;
fn main() { fn main() {
let event_loop = winit::event_loop::EventLoop::new().expect("event loop"); let event_loop = winit::event_loop::EventLoop::new().expect("event loop");

View File

@ -1,153 +0,0 @@
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu, accelerator::Accelerator};
use muda::accelerator::{Code, Modifiers};
pub const AP_PAREN: u32 = 1;
pub const AP_BRACKET: u32 = 2;
pub const AP_BRACE: u32 = 4;
pub const AP_SINGLE: u32 = 8;
pub const AP_DOUBLE: u32 = 16;
pub const AP_BACKTICK: u32 = 32;
pub struct AppMenu {
#[allow(dead_code)]
pub menu: Menu,
auto_pair_items: Vec<(u32, CheckMenuItem)>,
}
pub enum MenuAction {
NewNote,
Open,
Save,
SaveAs,
Quit,
Undo,
Redo,
Bold,
Italic,
InsertTable,
Evaluate,
LiveMode,
EditorMode,
ViewMode,
ZoomIn,
ZoomOut,
ZoomReset,
Find,
Settings,
ExportCrate,
ToggleAutoPair(u32),
ToggleBrowser,
}
impl AppMenu {
pub fn new(auto_pair_flags: u32) -> Self {
let menu = Menu::new();
let file = Submenu::new("File", true);
file.append(&MenuItem::with_id("new", "New Note", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyN)))).ok();
file.append(&MenuItem::with_id("open", "Open...", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyO)))).ok();
file.append(&MenuItem::with_id("browse", "Documents...", true, Some(Accelerator::new(Some(Modifiers::ALT), Code::KeyB)))).ok();
file.append(&PredefinedMenuItem::separator()).ok();
file.append(&MenuItem::with_id("save", "Save", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyS)))).ok();
file.append(&MenuItem::with_id("save_as", "Save As...", true, Some(Accelerator::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyS)))).ok();
file.append(&PredefinedMenuItem::separator()).ok();
file.append(&MenuItem::with_id("export_crate", "Export as Rust Library", true, Some(Accelerator::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyE)))).ok();
file.append(&PredefinedMenuItem::separator()).ok();
file.append(&MenuItem::with_id("quit", "Quit", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyQ)))).ok();
let edit = Submenu::new("Edit", true);
edit.append(&MenuItem::with_id("undo", "Undo", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyZ)))).ok();
edit.append(&MenuItem::with_id("redo", "Redo", true, Some(Accelerator::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::KeyZ)))).ok();
edit.append(&PredefinedMenuItem::separator()).ok();
edit.append(&PredefinedMenuItem::cut(None)).ok();
edit.append(&PredefinedMenuItem::copy(None)).ok();
edit.append(&PredefinedMenuItem::paste(None)).ok();
edit.append(&PredefinedMenuItem::select_all(None)).ok();
edit.append(&PredefinedMenuItem::separator()).ok();
edit.append(&MenuItem::with_id("find", "Find...", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyF)))).ok();
edit.append(&PredefinedMenuItem::separator()).ok();
edit.append(&MenuItem::with_id("settings", "Settings...", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Comma)))).ok();
edit.append(&PredefinedMenuItem::separator()).ok();
edit.append(&MenuItem::with_id("bold", "Bold", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyB)))).ok();
edit.append(&MenuItem::with_id("italic", "Italic", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyI)))).ok();
edit.append(&MenuItem::with_id("table", "Insert Table", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyT)))).ok();
edit.append(&PredefinedMenuItem::separator()).ok();
let auto_pair_sub = Submenu::new("Auto Pair", true);
let pair_specs: [(u32, &str, &str); 6] = [
(AP_PAREN, "ap_paren", "Parens ( )"),
(AP_BRACKET, "ap_bracket", "Brackets [ ]"),
(AP_BRACE, "ap_brace", "Braces { }"),
(AP_SINGLE, "ap_single", "Single quotes ' '"),
(AP_DOUBLE, "ap_double", "Double quotes \" \""),
(AP_BACKTICK, "ap_backtick", "Backticks ` `"),
];
let mut auto_pair_items: Vec<(u32, CheckMenuItem)> = Vec::with_capacity(6);
for (bit, id, label) in pair_specs {
let item = CheckMenuItem::with_id(id, label, true, (auto_pair_flags & bit) != 0, None);
auto_pair_sub.append(&item).ok();
auto_pair_items.push((bit, item));
}
edit.append(&auto_pair_sub).ok();
let render = Submenu::new("Render", true);
render.append(&MenuItem::with_id("live", "Live", true, None)).ok();
render.append(&MenuItem::with_id("editor", "Editor", true, None)).ok();
render.append(&MenuItem::with_id("view", "View", true, None)).ok();
render.append(&PredefinedMenuItem::separator()).ok();
render.append(&MenuItem::with_id("eval", "Evaluate", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::KeyE)))).ok();
let view = Submenu::new("View", true);
view.append(&MenuItem::with_id("zoom_in", "Zoom In", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Equal)))).ok();
view.append(&MenuItem::with_id("zoom_out", "Zoom Out", true, Some(Accelerator::new(Some(Modifiers::CONTROL), Code::Minus)))).ok();
view.append(&MenuItem::with_id("zoom_reset", "Reset Zoom", true, Some(Accelerator::new(Some(Modifiers::CONTROL | Modifiers::SHIFT), Code::Digit0)))).ok();
menu.append(&file).ok();
menu.append(&edit).ok();
menu.append(&render).ok();
menu.append(&view).ok();
Self { menu, auto_pair_items }
}
pub fn set_auto_pair_check(&self, bit: u32, checked: bool) {
for (b, item) in &self.auto_pair_items {
if *b == bit { item.set_checked(checked); }
}
}
pub fn poll() -> Option<MenuAction> {
MenuEvent::receiver().try_recv().ok().and_then(|e| {
match e.id().0.as_str() {
"new" => Some(MenuAction::NewNote),
"open" => Some(MenuAction::Open),
"browse" => Some(MenuAction::ToggleBrowser),
"save" => Some(MenuAction::Save),
"save_as" => Some(MenuAction::SaveAs),
"quit" => Some(MenuAction::Quit),
"undo" => Some(MenuAction::Undo),
"redo" => Some(MenuAction::Redo),
"bold" => Some(MenuAction::Bold),
"italic" => Some(MenuAction::Italic),
"table" => Some(MenuAction::InsertTable),
"eval" => Some(MenuAction::Evaluate),
"live" => Some(MenuAction::LiveMode),
"editor" => Some(MenuAction::EditorMode),
"view" => Some(MenuAction::ViewMode),
"zoom_in" => Some(MenuAction::ZoomIn),
"zoom_out" => Some(MenuAction::ZoomOut),
"zoom_reset" => Some(MenuAction::ZoomReset),
"find" => Some(MenuAction::Find),
"settings" => Some(MenuAction::Settings),
"export_crate" => Some(MenuAction::ExportCrate),
"ap_paren" => Some(MenuAction::ToggleAutoPair(AP_PAREN)),
"ap_bracket" => Some(MenuAction::ToggleAutoPair(AP_BRACKET)),
"ap_brace" => Some(MenuAction::ToggleAutoPair(AP_BRACE)),
"ap_single" => Some(MenuAction::ToggleAutoPair(AP_SINGLE)),
"ap_double" => Some(MenuAction::ToggleAutoPair(AP_DOUBLE)),
"ap_backtick" => Some(MenuAction::ToggleAutoPair(AP_BACKTICK)),
_ => None,
}
})
}
}

66
windows/src/shortcuts.rs Normal file
View File

@ -0,0 +1,66 @@
use winit::keyboard::{Key, ModifiersState, SmolStr};
#[derive(Clone, Copy)]
#[allow(dead_code)]
pub enum MenuAction {
NewNote,
Open,
Save,
SaveAs,
Quit,
Bold,
Italic,
InsertTable,
Evaluate,
LiveMode,
EditorMode,
ViewMode,
ZoomIn,
ZoomOut,
ZoomReset,
Find,
Settings,
ExportCrate,
ToggleBrowser,
}
pub fn match_shortcut(modifiers: ModifiersState, key: &Key) -> Option<MenuAction> {
if modifiers.alt_key() && !modifiers.control_key() && !modifiers.super_key() {
if let Key::Character(s) = key {
if ascii_lower(s) == 'b' {
return Some(MenuAction::ToggleBrowser);
}
}
}
if !modifiers.control_key() {
return None;
}
let shift = modifiers.shift_key();
match key {
Key::Character(s) => match (shift, ascii_lower(s)) {
(false, 'n') => Some(MenuAction::NewNote),
(false, 'o') => Some(MenuAction::Open),
(false, 's') => Some(MenuAction::Save),
(true, 's') => Some(MenuAction::SaveAs),
(false, 'q') => Some(MenuAction::Quit),
(false, 'b') => Some(MenuAction::Bold),
(false, 'i') => Some(MenuAction::Italic),
(false, 't') => Some(MenuAction::InsertTable),
(false, 'f') => Some(MenuAction::Find),
(false, 'e') => Some(MenuAction::Evaluate),
(true, 'e') => Some(MenuAction::ExportCrate),
(false, ',') => Some(MenuAction::Settings),
(false, '=') | (false, '+') => Some(MenuAction::ZoomIn),
(false, '-') => Some(MenuAction::ZoomOut),
(true, '0') => Some(MenuAction::ZoomReset),
_ => None,
},
_ => None,
}
}
fn ascii_lower(s: &SmolStr) -> char {
s.chars().next().map(|c| c.to_ascii_lowercase()).unwrap_or('\0')
}