diff --git a/Cargo.toml b/Cargo.toml index 5e9f98b..84b012d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ windows = { version = "0.54.0", features = [ "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", + "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_UI_Controls", "Win32_UI_Controls_Dialogs", diff --git a/src/app/settings.rs b/src/app/settings.rs index 255a3e6..08dbe0e 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -25,6 +25,10 @@ pub const DEFAULT_ZOOM_LEVEL: i32 = 0; /// Scintilla's supported zoom range (points added to the base font size). pub const MIN_ZOOM_LEVEL: i32 = -10; pub const MAX_ZOOM_LEVEL: i32 = 20; +pub const DEFAULT_EDITOR_FONT_NAME: &str = "Consolas"; +pub const DEFAULT_EDITOR_FONT_SIZE: i32 = 11; +pub const MIN_EDITOR_FONT_SIZE: i32 = 6; +pub const MAX_EDITOR_FONT_SIZE: i32 = 72; #[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] #[serde(rename_all = "lowercase")] @@ -73,6 +77,13 @@ pub struct UiSettings { /// [`MIN_ZOOM_LEVEL`]..=[`MAX_ZOOM_LEVEL`]. #[serde(default)] pub zoom_level: i32, + /// `settings.json`: editor font family name (e.g. "Consolas"). + #[serde(default = "default_editor_font_name")] + pub editor_font_name: String, + /// `settings.json`: editor base font size in points, clamped to + /// [`MIN_EDITOR_FONT_SIZE`]..=[`MAX_EDITOR_FONT_SIZE`]. + #[serde(default = "default_editor_font_size")] + pub editor_font_size: i32, } impl Default for UiSettings { @@ -89,6 +100,8 @@ impl Default for UiSettings { large_file_disable_smart_highlight: DEFAULT_LARGE_FILE_DISABLE_SMART_HIGHLIGHT, recent_files: Vec::new(), zoom_level: DEFAULT_ZOOM_LEVEL, + editor_font_name: DEFAULT_EDITOR_FONT_NAME.to_string(), + editor_font_size: DEFAULT_EDITOR_FONT_SIZE, } } } @@ -121,6 +134,10 @@ struct UiSettingsWire { recent_files: Vec, #[serde(default)] zoom_level: i32, + #[serde(default = "default_editor_font_name")] + editor_font_name: String, + #[serde(default = "default_editor_font_size")] + editor_font_size: i32, } impl From for UiSettings { @@ -143,6 +160,8 @@ impl From for UiSettings { .unwrap_or(DEFAULT_LARGE_FILE_DISABLE_SMART_HIGHLIGHT), recent_files: value.recent_files, zoom_level: value.zoom_level, + editor_font_name: value.editor_font_name, + editor_font_size: value.editor_font_size, } } } @@ -174,6 +193,12 @@ impl UiSettings { .clamp(MIN_LARGE_FILE_THRESHOLD_MB, MAX_LARGE_FILE_THRESHOLD_MB); self.recent_files.truncate(MAX_RECENT_FILES); self.zoom_level = self.zoom_level.clamp(MIN_ZOOM_LEVEL, MAX_ZOOM_LEVEL); + if self.editor_font_name.trim().is_empty() { + self.editor_font_name = DEFAULT_EDITOR_FONT_NAME.to_string(); + } + self.editor_font_size = self + .editor_font_size + .clamp(MIN_EDITOR_FONT_SIZE, MAX_EDITOR_FONT_SIZE); self } } @@ -222,6 +247,14 @@ fn default_editor_dark() -> bool { DEFAULT_EDITOR_DARK } +fn default_editor_font_name() -> String { + DEFAULT_EDITOR_FONT_NAME.to_string() +} + +fn default_editor_font_size() -> i32 { + DEFAULT_EDITOR_FONT_SIZE +} + fn default_smart_highlight_match_case() -> bool { DEFAULT_SMART_HIGHLIGHT_MATCH_CASE } @@ -351,6 +384,8 @@ mod tests { large_file_disable_smart_highlight: true, recent_files: vec!["C:\\a.txt".to_string(), "C:\\b.md".to_string()], zoom_level: 3, + editor_font_name: "Consolas".to_string(), + editor_font_size: 11, }; let json = serde_json::to_string_pretty(&settings).unwrap(); assert!(json.contains("\"tab_placement\": \"right\"")); diff --git a/src/editor/scintilla.rs b/src/editor/scintilla.rs index 62f3613..508cfad 100644 --- a/src/editor/scintilla.rs +++ b/src/editor/scintilla.rs @@ -18,6 +18,7 @@ const SCI_SETCODEPAGE: u32 = 2037; const SCI_SETTEXT: u32 = 2181; const SCI_GETTEXT: u32 = 2182; const SCI_INSERTTEXT: u32 = 2003; +const SCI_REPLACESEL: u32 = 2170; const SCI_GETLENGTH: u32 = 2006; const SCI_GETCHARAT: u32 = 2007; const SCI_GETCURRENTPOS: u32 = 2008; @@ -607,8 +608,8 @@ pub fn create_window(parent: HWND, instance: HINSTANCE) -> Result { Ok(hwnd) } -pub fn apply_lexer(hwnd: HWND, lexer: LexerKind, dark: bool) { - apply_base_theme(hwnd, dark); +pub fn apply_lexer(hwnd: HWND, lexer: LexerKind, dark: bool, font_name: &str, font_size: i32) { + apply_base_theme(hwnd, dark, font_name, font_size); set_lexer_by_name(hwnd, lexer_name(lexer)); clear_keywords(hwnd); apply_fold_properties(hwnd); @@ -673,6 +674,16 @@ pub fn insert_text(hwnd: HWND, pos: usize, text: &str) { send_message(hwnd, SCI_INSERTTEXT, pos, text.as_ptr() as isize); } +/// Replaces the current selection (or inserts at the caret if the selection +/// is empty) with `text`, then leaves the caret positioned after it — the +/// same behavior as typing or pasting. +pub fn replace_selection(hwnd: HWND, text: &str) { + let Ok(text) = CString::new(text) else { + return; + }; + send_message(hwnd, SCI_REPLACESEL, 0, text.as_ptr() as isize); +} + /// The newline sequence matching the document's current EOL mode. pub fn eol_string(hwnd: HWND) -> &'static str { match get_eol_mode(hwnd) { @@ -968,7 +979,7 @@ fn lexer_name(lexer: LexerKind) -> &'static str { } } -fn apply_base_theme(hwnd: HWND, dark: bool) { +fn apply_base_theme(hwnd: HWND, dark: bool, font_name: &str, font_size: i32) { let (fore, back, sel_back, caret, caret_line) = if dark { ( COLOR_DARK_FORE, @@ -988,8 +999,8 @@ fn apply_base_theme(hwnd: HWND, dark: bool) { }; set_style_fore(hwnd, STYLE_DEFAULT, fore); set_style_back(hwnd, STYLE_DEFAULT, back); - set_style_size(hwnd, STYLE_DEFAULT, 11); - set_style_font(hwnd, STYLE_DEFAULT, "Consolas"); + set_style_size(hwnd, STYLE_DEFAULT, font_size.max(1) as usize); + set_style_font(hwnd, STYLE_DEFAULT, font_name); send_message(hwnd, SCI_STYLECLEARALL, 0, 0); send_message(hwnd, SCI_SETSELFORE, 1, fore as isize); send_message(hwnd, SCI_SETSELBACK, 1, sel_back as isize); diff --git a/src/platform/win32.rs b/src/platform/win32.rs index db63363..ee6b476 100644 --- a/src/platform/win32.rs +++ b/src/platform/win32.rs @@ -11,16 +11,19 @@ use windows::Win32::Foundation::{ }; use windows::Win32::Graphics::Gdi::{ BeginPaint, CreatePen, CreateSolidBrush, DT_CENTER, DT_END_ELLIPSIS, DT_HIDEPREFIX, - DT_NOPREFIX, DT_SINGLELINE, DT_VCENTER, DeleteObject, DrawTextW, EndPaint, FillRect, HBRUSH, - HDC, HGDIOBJ, InvalidateRect, LineTo, MONITOR_DEFAULTTONULL, MonitorFromRect, MoveToEx, - PAINTSTRUCT, PS_SOLID, ScreenToClient, SelectObject, SetBkMode, SetTextColor, TRANSPARENT, + DT_NOPREFIX, DT_SINGLELINE, DT_VCENTER, DeleteObject, DrawTextW, EndPaint, FillRect, GetDC, + GetDeviceCaps, HBRUSH, HDC, HGDIOBJ, InvalidateRect, LOGFONTW, LOGPIXELSY, LineTo, + MONITOR_DEFAULTTONULL, MonitorFromRect, MoveToEx, PAINTSTRUCT, PS_SOLID, ReleaseDC, + ScreenToClient, SelectObject, SetBkMode, SetTextColor, TRANSPARENT, }; use windows::Win32::System::Com::CoTaskMemFree; use windows::Win32::System::DataExchange::COPYDATASTRUCT; use windows::Win32::System::LibraryLoader::GetModuleHandleW; +use windows::Win32::System::SystemInformation::GetLocalTime; use windows::Win32::UI::Controls::Dialogs::{ - CommDlgExtendedError, GetOpenFileNameW, GetSaveFileNameW, OFN_EXPLORER, OFN_FILEMUSTEXIST, - OFN_OVERWRITEPROMPT, OFN_PATHMUSTEXIST, OPENFILENAMEW, + CF_EFFECTS, CF_FORCEFONTEXIST, CF_INITTOLOGFONTSTRUCT, CF_SCREENFONTS, CHOOSEFONTW, + ChooseFontW, CommDlgExtendedError, GetOpenFileNameW, GetSaveFileNameW, OFN_EXPLORER, + OFN_FILEMUSTEXIST, OFN_OVERWRITEPROMPT, OFN_PATHMUSTEXIST, OPENFILENAMEW, }; use windows::Win32::UI::Controls::{ CDDS_ITEMPOSTPAINT, CDDS_ITEMPREPAINT, CDDS_PREPAINT, CDRF_DODEFAULT, CDRF_NEWFONT, @@ -128,6 +131,7 @@ const IDM_EDIT_REPLACE: u16 = 323; const IDM_EDIT_REPLACE_ALL: u16 = 324; const IDM_EDIT_FIND_IN_FILES: u16 = 325; const IDM_EDIT_GOTO_LINE: u16 = 332; +const IDM_EDIT_INSERT_DATETIME: u16 = 333; const CMD_TRANSFORM_UPPERCASE: u16 = 326; const CMD_TRANSFORM_LOWERCASE: u16 = 327; const CMD_COPY_FULL_PATH: u16 = 328; @@ -147,6 +151,7 @@ const CMD_EDITOR_EXPAND_ALL: u16 = 353; const IDM_VIEW_ZOOM_IN: u16 = 354; const IDM_VIEW_ZOOM_OUT: u16 = 355; const IDM_VIEW_ZOOM_RESET: u16 = 356; +const IDM_VIEW_FONT: u16 = 357; // Language (syntax) override commands. CMD_LANG_AUTO clears the per-tab override // and falls back to extension detection; the rest force a specific lexer. const CMD_LANG_AUTO: u16 = 360; @@ -209,6 +214,7 @@ const VK_X: u16 = 0x58; const VK_Y: u16 = 0x59; const VK_Z: u16 = 0x5A; const VK_F3: u16 = 0x72; +const VK_F5: u16 = 0x74; const VK_N: u16 = 0x4E; const VK_S: u16 = 0x53; const VK_OEM_4: u16 = 0xDB; @@ -420,6 +426,8 @@ struct AppState { docs: Vec, active: usize, editor_dark: bool, + editor_font_name: String, + editor_font_size: i32, next_tab_runtime_id: i64, always_on_top: bool, window_placement: Option, @@ -839,6 +847,13 @@ fn create_menu() -> Result { CMD_TRIM_LEADING_TRAILING as usize, w!("Trim Leading + Trailing Whitespace"), )?; + AppendMenuW(edit_menu, MF_SEPARATOR, 0, PCWSTR::null())?; + AppendMenuW( + edit_menu, + MF_STRING, + IDM_EDIT_INSERT_DATETIME as usize, + w!("Insert Date/Time"), + )?; AppendMenuW(menu, MF_POPUP, edit_menu.0 as usize, w!("Edit"))?; let view_menu = CreatePopupMenu()?; @@ -882,6 +897,8 @@ fn create_menu() -> Result { w!("Always On Top"), )?; AppendMenuW(view_menu, MF_SEPARATOR, 0, PCWSTR::null())?; + AppendMenuW(view_menu, MF_STRING, IDM_VIEW_FONT as usize, w!("Font..."))?; + AppendMenuW(view_menu, MF_SEPARATOR, 0, PCWSTR::null())?; AppendMenuW( view_menu, MF_STRING, @@ -1116,6 +1133,11 @@ fn create_accelerators() -> Result { key: VK_G, cmd: IDM_EDIT_GOTO_LINE, }, + ACCEL { + fVirt: FVIRTKEY, + key: VK_F5, + cmd: IDM_EDIT_INSERT_DATETIME, + }, ACCEL { fVirt: FVIRTKEY | FCONTROL, key: VK_OEM_PLUS, @@ -1553,6 +1575,14 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: } LRESULT(0) } + IDM_EDIT_INSERT_DATETIME => { + if let Some(state) = get_state(hwnd) + && let Some(editor) = active_editor(state) + { + scintilla::replace_selection(editor, ¤t_date_time_stamp()); + } + LRESULT(0) + } IDM_EDIT_REDO => { if let Some(state) = get_state(hwnd) && let Some(editor) = active_editor(state) @@ -1819,6 +1849,20 @@ unsafe extern "system" fn wndproc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: } LRESULT(0) } + IDM_VIEW_FONT => { + if let Some(state) = get_state(hwnd) { + match show_font_dialog( + hwnd, + &state.editor_font_name, + state.editor_font_size, + ) { + Ok(Some((name, size))) => set_editor_font(state, name, size), + Ok(None) => {} + Err(err) => show_error("Rivet error", &err.to_string()), + } + } + LRESULT(0) + } CMD_TAB_NEXT => { if let Some(state) = get_state(hwnd) { select_adjacent_tab(hwnd, state, true); @@ -2396,6 +2440,8 @@ fn create_children(hwnd: HWND, instance: HINSTANCE) -> Result { // Captured before the struct literal moves `ui_settings` into the state. let editor_dark = ui_settings.editor_dark; + let editor_font_name = ui_settings.editor_font_name.clone(); + let editor_font_size = ui_settings.editor_font_size; let state = AppState { tab_host: TabStripHost { top_tabs, @@ -2417,6 +2463,8 @@ fn create_children(hwnd: HWND, instance: HINSTANCE) -> Result { docs: Vec::new(), active: 0, editor_dark, + editor_font_name, + editor_font_size, next_tab_runtime_id: 1, always_on_top: session::DEFAULT_ALWAYS_ON_TOP, window_placement: None, @@ -2664,7 +2712,12 @@ fn open_path_new_tab( scintilla::set_eol_mode(doc_tab.editor, eol); } - apply_syntax_for_doc(&doc_tab, state.editor_dark); + apply_syntax_for_doc( + &doc_tab, + state.editor_dark, + &state.editor_font_name, + state.editor_font_size, + ); let index = add_tab(state, &tab_title(&doc_tab), doc_tab)?; select_tab(hwnd, state, index); apply_large_file_mode_restrictions(hwnd, state, index); @@ -2810,7 +2863,12 @@ fn save_document_at( state.ui_settings.large_file_disable_word_wrap, ), ); - apply_syntax_for_doc(doc_tab, state.editor_dark); + apply_syntax_for_doc( + doc_tab, + state.editor_dark, + &state.editor_font_name, + state.editor_font_size, + ); scintilla::set_savepoint(doc_tab.editor); doc_tab.sticky_dirty = false; doc_tab.doc.is_dirty = false; @@ -2883,7 +2941,12 @@ fn reload_doc_from_path( state.ui_settings.large_file_threshold_mb, state.ui_settings.large_file_disable_word_wrap, )?; - apply_syntax_for_doc(doc_tab, state.editor_dark); + apply_syntax_for_doc( + doc_tab, + state.editor_dark, + &state.editor_font_name, + state.editor_font_size, + ); scintilla::goto_pos( doc_tab.editor, caret.min(scintilla::get_length(doc_tab.editor)), @@ -4056,6 +4119,15 @@ fn count_words(text: &str) -> usize { count } +/// Current local date/time as "YYYY/MM/DD HH:MM", for Insert Date/Time. +fn current_date_time_stamp() -> String { + let st = unsafe { GetLocalTime() }; + format!( + "{:04}-{:02}-{:02} {:02}:{:02}", + st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute + ) +} + /// Formats a count with comma separators, e.g. 1234567 -> "1,234,567". fn format_thousands(value: usize) -> String { let digits = value.to_string(); @@ -4239,9 +4311,9 @@ fn effective_lexer(doc_tab: &DocTab) -> scintilla::LexerKind { .unwrap_or_else(|| lexer_for_doc(&doc_tab.doc)) } -fn apply_syntax_for_doc(doc_tab: &DocTab, dark: bool) { +fn apply_syntax_for_doc(doc_tab: &DocTab, dark: bool, font_name: &str, font_size: i32) { let lexer = effective_lexer(doc_tab); - scintilla::apply_lexer(doc_tab.editor, lexer, dark); + scintilla::apply_lexer(doc_tab.editor, lexer, dark, font_name, font_size); apply_editor_theme_overlays(doc_tab.editor, dark); if matches!(lexer, scintilla::LexerKind::Markdown) && !doc_tab.doc.large_file_mode { apply_markdown_fold_levels(doc_tab.editor); @@ -4253,6 +4325,8 @@ fn apply_syntax_for_doc(doc_tab: &DocTab, dark: bool) { fn set_language_override(state: &mut AppState, over: Option) { let index = state.active; let dark = state.editor_dark; + let font_name = state.editor_font_name.clone(); + let font_size = state.editor_font_size; let Some(doc_tab) = state.docs.get_mut(index) else { return; }; @@ -4260,7 +4334,7 @@ fn set_language_override(state: &mut AppState, over: Option Re scintilla::set_eol_mode(editor, doc_tab.doc.eol); scintilla::set_wrap_enabled(editor, state.word_wrap_enabled); scintilla::set_savepoint(editor); - apply_syntax_for_doc(&doc_tab, state.editor_dark); + apply_syntax_for_doc( + &doc_tab, + state.editor_dark, + &state.editor_font_name, + state.editor_font_size, + ); let index = add_tab(state, &tab_title(&doc_tab), doc_tab)?; select_tab(hwnd, state, index); @@ -4616,7 +4695,12 @@ fn duplicate_active_tab(hwnd: HWND, state: &mut AppState) -> Result<()> { smart_highlight_truncated: false, lexer_override: None, }; - apply_syntax_for_doc(&doc_tab, state.editor_dark); + apply_syntax_for_doc( + &doc_tab, + state.editor_dark, + &state.editor_font_name, + state.editor_font_size, + ); restore_strike_ranges(doc_tab.editor, &strike_ranges); let index = add_tab(state, &tab_title(&doc_tab), doc_tab)?; @@ -5656,7 +5740,12 @@ fn restore_session_entry( if doc_tab.doc.path.is_none() { update_next_untitled_index_from_name(state, &doc_tab.doc.display_name); } - apply_syntax_for_doc(&doc_tab, state.editor_dark); + apply_syntax_for_doc( + &doc_tab, + state.editor_dark, + &state.editor_font_name, + state.editor_font_size, + ); restore_strike_ranges(editor, &entry.strike_ranges); let index = add_tab(state, &tab_title(&doc_tab), doc_tab)?; if entry.cursor_pos >= 0 { @@ -5831,6 +5920,45 @@ fn eol_mode_label(mode: i32) -> &'static str { } } +/// Shows the native font picker, pre-selected to `current_name`/`current_size`. +/// Returns `None` if the user cancels. +fn show_font_dialog( + hwnd: HWND, + current_name: &str, + current_size: i32, +) -> Result> { + let mut log_font = LOGFONTW::default(); + let wide_name: Vec = current_name + .encode_utf16() + .take(log_font.lfFaceName.len() - 1) + .collect(); + log_font.lfFaceName[..wide_name.len()].copy_from_slice(&wide_name); + + let screen_dc = unsafe { GetDC(HWND(0)) }; + let dpi = unsafe { GetDeviceCaps(screen_dc, LOGPIXELSY) }; + unsafe { + ReleaseDC(HWND(0), screen_dc); + } + log_font.lfHeight = -((current_size * dpi + 36) / 72); + + let mut choose_font = CHOOSEFONTW { + lStructSize: std::mem::size_of::() as u32, + hwndOwner: hwnd, + lpLogFont: &mut log_font, + Flags: CF_SCREENFONTS | CF_INITTOLOGFONTSTRUCT | CF_EFFECTS | CF_FORCEFONTEXIST, + ..Default::default() + }; + + let result = unsafe { ChooseFontW(&mut choose_font) }; + if !result.as_bool() { + return Ok(None); + } + + let name = wide_to_string(&log_font.lfFaceName)?; + let size = (choose_font.iPointSize / 10).max(1); + Ok(Some((name, size))) +} + fn open_file_dialog(hwnd: HWND) -> Result> { let mut buffer = vec![0u16; 1024]; let filter = w!("All Files\0*.*\0\0"); @@ -6444,6 +6572,8 @@ fn persist_ui_settings(state: &AppState) { settings.tab_placement = state.tab_host.placement; settings.vertical_tab_width_px = state.tab_host.vertical_width_px; settings.editor_dark = state.editor_dark; + settings.editor_font_name = state.editor_font_name.clone(); + settings.editor_font_size = state.editor_font_size; if let Err(err) = settings::save_settings(&settings) { logging::log_error(&format!("settings_save_failed err={err}")); } @@ -6753,7 +6883,12 @@ fn set_editor_dark_mode(hwnd: HWND, state: &mut AppState, enabled: bool) { state.editor_dark = enabled; update_editor_dark_menu(hwnd, enabled); for doc_tab in &state.docs { - apply_syntax_for_doc(doc_tab, enabled); + apply_syntax_for_doc( + doc_tab, + enabled, + &state.editor_font_name, + state.editor_font_size, + ); } if let Err(err) = update_tab_host_theme(state, enabled) { logging::log_error(&format!("tab_host_theme_update_failed err={err}")); @@ -6775,6 +6910,23 @@ fn set_editor_dark_mode(hwnd: HWND, state: &mut AppState, enabled: bool) { persist_ui_settings(state); } +fn set_editor_font(state: &mut AppState, name: String, size: i32) { + state.editor_font_name = name; + state.editor_font_size = size.clamp( + settings::MIN_EDITOR_FONT_SIZE, + settings::MAX_EDITOR_FONT_SIZE, + ); + for doc_tab in &state.docs { + apply_syntax_for_doc( + doc_tab, + state.editor_dark, + &state.editor_font_name, + state.editor_font_size, + ); + } + persist_ui_settings(state); +} + fn set_tab_layout(hwnd: HWND, state: &mut AppState, layout: TabPlacement) { state.tab_host.placement = layout; update_tab_layout_menu(hwnd, layout);