Add session restore and command palette

This commit is contained in:
MihneaTeodorStoica 2025-11-08 16:34:25 +02:00
parent 535c1d2d44
commit 3d025ca8de
10 changed files with 859 additions and 8 deletions

1
Cargo.lock generated
View file

@ -186,6 +186,7 @@ version = "1.2.1"
dependencies = [
"criterion",
"libc",
"serde",
"serde_json",
"toml-span",
"windows-sys",

View file

@ -35,6 +35,8 @@ codegen-units = 16 # Make compiling criterion faster (16 is the default
lto = "thin" # Similarly, speed up linking by a ton
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
@ -63,5 +65,4 @@ features = [
[dev-dependencies]
criterion = { version = "0.7", features = ["html_reports"] }
serde_json = { version = "1.0" }
zstd = { version = "0.13", default-features = false }

View file

@ -8,6 +8,7 @@ __default__ = [
"ja",
"ko",
"pt_br",
"pl",
"ru",
"zh_hans",
"zh_hant",
@ -53,6 +54,7 @@ it = "OK"
ja = "OK"
ko = "확인"
nl = "OK"
pl = "OK"
pt_br = "OK"
pt_pt = "OK"
ro = "OK"
@ -286,6 +288,10 @@ vi = "Mở tệp…"
zh_hans = "打开文件…"
zh_hant = "開啟檔案…"
[FileOpenRecent]
en = "Open Recent…"
pl = "Otwórz ostatnie…"
[FileSave]
en = "Save"
bn = "সংরক্ষণ"
@ -1772,27 +1778,64 @@ zh_hans = "文件已存在。要覆盖它吗?"
zh_hant = "檔案已存在。要覆蓋它嗎?"
[PreferencesDialogTitle]
en = "Preferences"
pl = "Preferencje"
[PreferencesAutoClose]
en = "Auto close brackets"
pl = "Automatycznie domykaj nawiasy"
[PreferencesLineHighlight]
en = "Highlight current line"
pl = "Podświetlaj bieżący wiersz"
[PreferencesColorscheme]
en = "Color scheme"
pl = "Motyw kolorów"
[PreferencesShowLineNumbers]
en = "Show line numbers"
pl = "Pokaż numery wierszy"
[PreferencesWordWrap]
en = "Wrap long lines"
pl = "Zawijaj długie wiersze"
[PreferencesIndentWithTabs]
en = "Use tabs for indentation"
pl = "Używaj tabulatorów do wcięć"
[PreferencesTabWidth]
en = "Tab width"
pl = "Szerokość tabulatora"
[PreferencesSchemeSystem]
en = "System"
pl = "Systemowy"
[PreferencesSchemeMidnight]
en = "Midnight"
pl = "Północ"
[PreferencesSchemeDaylight]
en = "Daylight"
pl = "Światło dzienne"
[PreferencesSchemeNord]
en = "Nord"
pl = "Nord"
[PreferencesSchemeHighContrast]
en = "High Contrast"
pl = "Wysoki kontrast"
[RecentFilesDialogTitle]
en = "Recent Files"
pl = "Ostatnie pliki"
[CommandPaletteTitle]
en = "Command Palette"
pl = "Paleta poleceń"
[CommandPaletteNoResults]
en = "No commands found"
pl = "Brak wyników"

View file

@ -0,0 +1,442 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use edit::arena::scratch_arena;
use edit::arena_format;
use edit::framebuffer::IndexedColor;
use edit::fuzzy::score_fuzzy;
use edit::helpers::*;
use edit::input::{kbmod, vk};
use edit::tui::*;
use crate::localization::*;
use crate::state::*;
const MAX_COMMAND_RESULTS: usize = 12;
#[derive(Clone, Copy)]
enum StaticCommand {
NewFile,
OpenFile,
OpenRecentList,
SaveFile,
SaveFileAs,
CloseFile,
ExitApp,
Find,
Replace,
GoToFile,
GoToLine,
Preferences,
About,
ToggleWordWrap,
}
#[derive(Clone, Copy)]
enum CommandAction {
Static(StaticCommand),
RecentFile(usize),
}
struct StaticCommandDefinition {
label: LocId,
shortcut: Option<&'static str>,
keywords: &'static [&'static str],
action: StaticCommand,
requires_document: bool,
requires_search: bool,
requires_recent: bool,
}
struct CommandEntry<'a> {
label: &'a str,
shortcut: Option<&'static str>,
keywords: &'static [&'static str],
action: CommandAction,
enabled: bool,
}
const STATIC_COMMANDS: &[StaticCommandDefinition] = &[
StaticCommandDefinition {
label: LocId::FileNew,
shortcut: Some("Ctrl+N"),
keywords: &["new"],
action: StaticCommand::NewFile,
requires_document: false,
requires_search: false,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::FileOpen,
shortcut: Some("Ctrl+O"),
keywords: &["open", "file"],
action: StaticCommand::OpenFile,
requires_document: false,
requires_search: false,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::FileOpenRecent,
shortcut: None,
keywords: &["recent"],
action: StaticCommand::OpenRecentList,
requires_document: false,
requires_search: false,
requires_recent: true,
},
StaticCommandDefinition {
label: LocId::FileSave,
shortcut: Some("Ctrl+S"),
keywords: &["save"],
action: StaticCommand::SaveFile,
requires_document: true,
requires_search: false,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::FileSaveAs,
shortcut: Some("Ctrl+Shift+S"),
keywords: &["save as"],
action: StaticCommand::SaveFileAs,
requires_document: true,
requires_search: false,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::FileClose,
shortcut: Some("Ctrl+W"),
keywords: &["close"],
action: StaticCommand::CloseFile,
requires_document: true,
requires_search: false,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::FileExit,
shortcut: Some("Ctrl+Q"),
keywords: &["quit", "exit"],
action: StaticCommand::ExitApp,
requires_document: false,
requires_search: false,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::EditFind,
shortcut: Some("Ctrl+F"),
keywords: &["find", "search"],
action: StaticCommand::Find,
requires_document: true,
requires_search: true,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::EditReplace,
shortcut: Some("Ctrl+R"),
keywords: &["replace"],
action: StaticCommand::Replace,
requires_document: true,
requires_search: true,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::ViewGoToFile,
shortcut: Some("Ctrl+P"),
keywords: &["switch file"],
action: StaticCommand::GoToFile,
requires_document: true,
requires_search: false,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::FileGoto,
shortcut: Some("Ctrl+G"),
keywords: &["goto line"],
action: StaticCommand::GoToLine,
requires_document: true,
requires_search: false,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::EditPreferences,
shortcut: None,
keywords: &["prefs", "settings"],
action: StaticCommand::Preferences,
requires_document: false,
requires_search: false,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::HelpAbout,
shortcut: None,
keywords: &["about"],
action: StaticCommand::About,
requires_document: false,
requires_search: false,
requires_recent: false,
},
StaticCommandDefinition {
label: LocId::ViewWordWrap,
shortcut: Some("Alt+Z"),
keywords: &["wrap"],
action: StaticCommand::ToggleWordWrap,
requires_document: true,
requires_search: false,
requires_recent: false,
},
];
pub fn draw_command_palette(ctx: &mut Context, state: &mut State) {
ctx.modal_begin("command-palette", loc(LocId::CommandPaletteTitle));
ctx.attr_focus_well();
ctx.attr_padding(Rect::three(1, 2, 1));
let mut close = false;
let mut activate: Option<CommandAction> = None;
if ctx.contains_focus() && ctx.consume_shortcut(vk::ESCAPE) {
close = true;
}
ctx.block_begin("filter");
ctx.attr_padding(Rect::three(0, 0, 1));
ctx.attr_intrinsic_size(Size { width: COORD_TYPE_SAFE_MAX, height: 1 });
if ctx.editline("command-filter", &mut state.command_palette_filter) {
state.command_palette_selection = 0;
}
ctx.block_end();
let entries = build_command_entries(state);
let filtered = filter_commands(entries, state.command_palette_filter.trim());
let mut selection = state.command_palette_selection;
let visible_len = filtered.len().min(MAX_COMMAND_RESULTS);
if visible_len == 0 {
selection = 0;
} else {
selection = selection.min(visible_len.saturating_sub(1));
}
if visible_len != 0 {
if ctx.consume_shortcut(vk::UP) || ctx.consume_shortcut(kbmod::CTRL | vk::P) {
if selection == 0 {
selection = visible_len - 1;
} else {
selection -= 1;
}
} else if ctx.consume_shortcut(vk::DOWN) || ctx.consume_shortcut(kbmod::CTRL | vk::N) {
selection = (selection + 1) % visible_len.max(1);
}
}
ctx.block_begin("results");
ctx.attr_padding(Rect::three(0, 0, 1));
if visible_len == 0 {
ctx.attr_foreground_rgba(ctx.indexed_alpha(IndexedColor::BrightBlack, 3, 4));
ctx.label("no-results", loc(LocId::CommandPaletteNoResults));
ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::Foreground));
} else {
for (idx, entry) in filtered.iter().take(visible_len).enumerate() {
ctx.next_block_id_mixin(idx as u64);
if idx == state.command_palette_selection {
ctx.attr_background_rgba(ctx.indexed_alpha(IndexedColor::Blue, 3, 4));
ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::BrightWhite));
} else {
ctx.attr_background_rgba(ctx.indexed(IndexedColor::Background));
ctx.attr_foreground_rgba(ctx.indexed(IndexedColor::Foreground));
}
if !entry.enabled {
ctx.attr_foreground_rgba(ctx.indexed_alpha(IndexedColor::BrightBlack, 3, 4));
}
ctx.attr_overflow(Overflow::TruncateTail);
let mut label_owned = None;
let label_text: &str = if let Some(shortcut) = entry.shortcut {
label_owned = Some(arena_format!(ctx.arena(), "{} {}", entry.label, shortcut));
label_owned.as_deref().unwrap()
} else {
entry.label
};
if ctx.button("command-entry", label_text, ButtonStyle::default()) && entry.enabled {
selection = idx;
activate = Some(entry.action);
}
}
}
ctx.block_end();
if visible_len != 0
&& (ctx.consume_shortcut(vk::RETURN) || ctx.consume_shortcut(kbmod::CTRL | vk::RETURN))
{
if let Some(entry) = filtered.get(selection) {
if entry.enabled {
activate = Some(entry.action);
}
}
}
drop(filtered);
state.command_palette_selection = selection;
if ctx.modal_end() {
close = true;
}
if let Some(action) = activate {
execute_command(ctx, state, action);
close = true;
}
if close {
state.wants_command_palette = false;
state.command_palette_filter.clear();
state.command_palette_selection = 0;
}
}
fn build_command_entries<'a>(state: &'a State) -> Vec<CommandEntry<'a>> {
let mut entries = Vec::new();
for def in STATIC_COMMANDS {
let has_doc = state.documents.active().is_some();
let search_available = !matches!(state.wants_search.kind, StateSearchKind::Disabled);
let recent_available = !state.recent_files.is_empty();
let enabled = (!def.requires_document || has_doc)
&& (!def.requires_search || search_available)
&& (!def.requires_recent || recent_available);
entries.push(CommandEntry {
label: loc(def.label),
shortcut: def.shortcut,
keywords: def.keywords,
action: CommandAction::Static(def.action),
enabled,
});
}
for (idx, entry) in state.recent_files.iter().enumerate() {
entries.push(CommandEntry {
label: entry.as_str(),
shortcut: None,
keywords: &["recent"],
action: CommandAction::RecentFile(idx),
enabled: true,
});
}
entries
}
fn filter_commands<'a>(entries: Vec<CommandEntry<'a>>, needle: &str) -> Vec<CommandEntry<'a>> {
if needle.is_empty() {
return entries;
}
let scratch = scratch_arena(None);
let mut matches = Vec::new();
for entry in entries {
let mut best = score_fuzzy(&scratch, entry.label, needle, true).0;
if best == 0 {
for keyword in entry.keywords {
let (score, _) = score_fuzzy(&scratch, keyword, needle, true);
best = best.max(score);
}
}
if best > 0 {
matches.push((best, entry));
}
}
matches.sort_by(|a, b| b.0.cmp(&a.0));
matches.into_iter().map(|(_, entry)| entry).collect()
}
fn execute_command(ctx: &mut Context, state: &mut State, action: CommandAction) {
match action {
CommandAction::Static(cmd) => match cmd {
StaticCommand::NewFile => {
draw_add_untitled_document(ctx, state);
ctx.needs_rerender();
}
StaticCommand::OpenFile => {
state.wants_file_picker = StateFilePicker::Open;
ctx.needs_rerender();
}
StaticCommand::OpenRecentList => {
state.wants_recent_files = true;
ctx.needs_rerender();
}
StaticCommand::SaveFile => {
state.wants_save = true;
ctx.needs_rerender();
}
StaticCommand::SaveFileAs => {
state.wants_file_picker = StateFilePicker::SaveAs;
ctx.needs_rerender();
}
StaticCommand::CloseFile => {
state.wants_close = true;
ctx.needs_rerender();
}
StaticCommand::ExitApp => {
state.wants_exit = true;
ctx.needs_rerender();
}
StaticCommand::Find => {
if state.wants_search.kind != StateSearchKind::Disabled {
state.wants_search.kind = StateSearchKind::Search;
state.wants_search.focus = true;
ctx.needs_rerender();
}
}
StaticCommand::Replace => {
if state.wants_search.kind != StateSearchKind::Disabled {
state.wants_search.kind = StateSearchKind::Replace;
state.wants_search.focus = true;
ctx.needs_rerender();
}
}
StaticCommand::GoToFile => {
state.wants_go_to_file = true;
ctx.needs_rerender();
}
StaticCommand::GoToLine => {
state.wants_goto = true;
ctx.needs_rerender();
}
StaticCommand::Preferences => {
state.wants_preferences = true;
ctx.needs_rerender();
}
StaticCommand::About => {
state.wants_about = true;
ctx.needs_rerender();
}
StaticCommand::ToggleWordWrap => {
if let Some(doc) = state.documents.active_mut() {
let mut tb = doc.buffer.borrow_mut();
let wrap_enabled = tb.is_word_wrap_enabled();
tb.set_word_wrap(!wrap_enabled);
tb.make_cursor_visible();
ctx.needs_rerender();
}
}
},
CommandAction::RecentFile(idx) => {
if let Some(entry) = state.recent_files.get(idx) {
let path = entry.as_path().to_path_buf();
let prefs = state.preferences.clone();
match state.documents.add_file_path(&path) {
Ok(doc) => {
prefs.apply_to_document(doc);
state.mark_file_recent_path(&path);
ctx.needs_rerender();
}
Err(err) => error_log_add(ctx, state, err),
}
}
}
}
}

View file

@ -195,10 +195,13 @@ pub fn search_execute(ctx: &mut Context, state: &mut State, action: SearchAction
}
pub fn draw_handle_save(ctx: &mut Context, state: &mut State) {
let mut recent_path = None;
if let Some(doc) = state.documents.active_mut() {
if doc.path.is_some() {
if let Err(err) = doc.save(None) {
error_log_add(ctx, state, err);
} else {
recent_path = doc.path.clone();
}
} else {
// No path? Show the file picker.
@ -208,6 +211,10 @@ pub fn draw_handle_save(ctx: &mut Context, state: &mut State) {
}
}
if let Some(path) = recent_path {
state.mark_file_recent_path(path);
}
state.wants_save = false;
}

View file

@ -247,12 +247,13 @@ pub fn draw_file_picker(ctx: &mut Context, state: &mut State) {
prefs.apply_to_document(doc);
})
} else if let Some(doc) = state.documents.active_mut() {
doc.save(Some(path))
doc.save(Some(path.clone()))
} else {
Ok(())
};
match res {
Ok(..) => {
state.mark_file_recent_path(&path);
ctx.needs_rerender();
done = true;
}

View file

@ -91,6 +91,11 @@ fn draw_menu_file(ctx: &mut Context, state: &mut State) {
if ctx.menubar_menu_button(loc(LocId::FileOpen), 'O', kbmod::CTRL | vk::O) {
state.wants_file_picker = StateFilePicker::Open;
}
if !state.recent_files.is_empty()
&& ctx.menubar_menu_button(loc(LocId::FileOpenRecent), 'R', vk::NULL)
{
state.wants_recent_files = true;
}
if state.documents.active().is_some() {
if ctx.menubar_menu_button(loc(LocId::FileSave), 'S', kbmod::CTRL | vk::S) {
state.wants_save = true;

View file

@ -4,11 +4,13 @@
#![feature(allocator_api, linked_list_cursors, string_from_utf8_lossy_owned)]
mod documents;
mod draw_command_palette;
mod draw_editor;
mod draw_filepicker;
mod draw_menubar;
mod draw_statusbar;
mod localization;
mod session;
mod state;
use std::borrow::Cow;
@ -18,6 +20,7 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use std::{env, process};
use draw_command_palette::*;
use draw_editor::*;
use draw_filepicker::*;
use draw_menubar::*;
@ -70,6 +73,8 @@ fn run() -> apperr::Result<()> {
return Ok(());
}
state.initialize_session();
// This will reopen stdin if it's redirected (which may fail) and switch
// the terminal to raw mode which prevents the user from pressing Ctrl+C.
// `handle_args` may want to print a help message (must not fail),
@ -205,6 +210,8 @@ fn run() -> apperr::Result<()> {
}
}
state.save_session();
Ok(())
}
@ -251,9 +258,15 @@ fn handle_args(state: &mut State) -> apperr::Result<bool> {
let prefs = state.preferences.clone();
let doc = state.documents.add_file_path(p)?;
prefs.apply_to_document(doc);
state.mark_file_recent_path(p);
}
if !paths.is_empty() {
state.skip_session_restore = true;
}
if let Some(mut file) = sys::open_stdin_if_redirected() {
state.skip_session_restore = true;
let prefs = state.preferences.clone();
let doc = state.documents.add_untitled()?;
prefs.apply_to_document(doc);
@ -326,6 +339,12 @@ fn draw(ctx: &mut Context, state: &mut State) {
if state.wants_preferences {
draw_dialog_preferences(ctx, state);
}
if state.wants_recent_files {
draw_recent_files_dialog(ctx, state);
}
if state.wants_command_palette {
draw_command_palette(ctx, state);
}
if ctx.clipboard_ref().wants_host_sync() {
draw_handle_clipboard_change(ctx, state);
}
@ -333,10 +352,18 @@ fn draw(ctx: &mut Context, state: &mut State) {
draw_error_log(ctx, state);
}
if state.wants_command_palette {
return;
}
if let Some(key) = ctx.keyboard_input() {
// Shortcuts that are not handled as part of the textarea, etc.
if key == kbmod::CTRL | vk::N {
if key == kbmod::CTRL_SHIFT | vk::P {
state.wants_command_palette = true;
state.command_palette_filter.clear();
state.command_palette_selection = 0;
} else if key == kbmod::CTRL | vk::N {
draw_add_untitled_document(ctx, state);
} else if key == kbmod::CTRL | vk::O {
state.wants_file_picker = StateFilePicker::Open;

51
src/bin/edit/session.rs Normal file
View file

@ -0,0 +1,51 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
use std::{fs, io};
use serde::{Deserialize, Serialize};
use crate::state;
const SESSION_FILE_NAME: &str = "session.json";
pub(crate) const SESSION_VERSION: u32 = 1;
#[derive(Default, Serialize, Deserialize)]
pub struct SessionFile {
pub version: u32,
#[serde(default)]
pub open_documents: Vec<SessionDocument>,
#[serde(default)]
pub recent_files: Vec<String>,
}
#[derive(Clone, Serialize, Deserialize)]
pub struct SessionDocument {
pub path: String,
pub line: i64,
pub column: i64,
}
pub fn load() -> Option<SessionFile> {
let mut path = state::config_dir()?;
path.push(SESSION_FILE_NAME);
let text = fs::read_to_string(path).ok()?;
let session: SessionFile = serde_json::from_str(&text).ok()?;
if session.version != SESSION_VERSION {
return None;
}
Some(session)
}
pub fn save(data: &SessionFile) -> io::Result<()> {
let mut dir = match state::config_dir() {
Some(dir) => dir,
None => return Ok(()),
};
fs::create_dir_all(&dir)?;
dir.push(SESSION_FILE_NAME);
let text = serde_json::to_string_pretty(data)?;
fs::write(dir, text)
}

View file

@ -11,10 +11,11 @@ use edit::helpers::*;
use edit::input::vk;
use edit::oklab::StraightRgba;
use edit::tui::*;
use edit::{apperr, arena_format, buffer, icu, sys};
use edit::{apperr, arena_format, buffer, icu, path, sys};
use crate::documents::{Document, DocumentManager};
use crate::localization::*;
use crate::session;
#[repr(transparent)]
pub struct FormatApperr(apperr::Error);
@ -93,6 +94,9 @@ impl<T: ?Sized + AsRef<OsStr>> From<&T> for DisplayablePathBuf {
}
}
const RECENT_FILES_LIMIT: usize = 15;
const SESSION_DOCUMENT_LIMIT: usize = 8;
pub struct StateSearch {
pub kind: StateSearchKind,
pub focus: bool,
@ -133,6 +137,7 @@ pub struct State {
pub menubar_color_fg: StraightRgba,
pub documents: DocumentManager,
pub recent_files: Vec<DisplayablePathBuf>,
// A ring buffer of the last 10 errors.
pub error_log: [String; 10],
@ -164,6 +169,8 @@ pub struct State {
pub wants_go_to_file: bool,
pub wants_preferences: bool,
pub wants_about: bool,
pub wants_recent_files: bool,
pub wants_command_palette: bool,
pub wants_close: bool,
pub wants_exit: bool,
pub wants_goto: bool,
@ -174,7 +181,10 @@ pub struct State {
pub osc_clipboard_sync: bool,
pub osc_clipboard_always_send: bool,
pub exit: bool,
pub skip_session_restore: bool,
pub preferences: Preferences,
pub command_palette_filter: String,
pub command_palette_selection: usize,
system_palette: [StraightRgba; INDEXED_COLORS_COUNT],
}
@ -186,6 +196,7 @@ impl State {
menubar_color_fg: StraightRgba::zero(),
documents: Default::default(),
recent_files: Vec::new(),
error_log: [const { String::new() }; 10],
error_log_index: 0,
@ -216,6 +227,8 @@ impl State {
wants_go_to_file: false,
wants_preferences: false,
wants_about: false,
wants_recent_files: false,
wants_command_palette: false,
wants_close: false,
wants_exit: false,
wants_goto: false,
@ -226,7 +239,10 @@ impl State {
osc_clipboard_sync: false,
osc_clipboard_always_send: false,
exit: false,
skip_session_restore: false,
preferences,
command_palette_filter: String::new(),
command_palette_selection: 0,
system_palette: framebuffer::DEFAULT_THEME,
})
}
@ -316,6 +332,69 @@ pub fn draw_dialog_preferences(ctx: &mut Context, state: &mut State) {
ctx.needs_rerender();
}
if ctx.checkbox(
"pref-line-numbers",
loc(LocId::PreferencesShowLineNumbers),
&mut state.preferences.show_line_numbers,
) {
state.apply_preferences_to_documents();
state.save_preferences();
ctx.needs_rerender();
}
if ctx.checkbox(
"pref-word-wrap",
loc(LocId::PreferencesWordWrap),
&mut state.preferences.word_wrap,
) {
state.apply_preferences_to_documents();
state.save_preferences();
ctx.needs_rerender();
}
if ctx.checkbox(
"pref-indent-tabs",
loc(LocId::PreferencesIndentWithTabs),
&mut state.preferences.indent_with_tabs,
) {
state.apply_preferences_to_documents();
state.save_preferences();
ctx.needs_rerender();
}
ctx.table_begin("pref-tab-width");
ctx.attr_padding(Rect::three(0, 0, 1));
ctx.table_set_cell_gap(Size { width: 1, height: 0 });
ctx.table_next_row();
{
ctx.label("pref-tab-width-label", loc(LocId::PreferencesTabWidth));
ctx.attr_intrinsic_size(Size { width: COORD_TYPE_SAFE_MAX, height: 1 });
let mut changed = None;
if ctx.button("pref-tab-dec", "-", ButtonStyle::default()) {
changed = Some(state.preferences.tab_width.saturating_sub(1));
}
ctx.label(
"pref-tab-width-value",
&arena_format!(ctx.arena(), "{}", state.preferences.tab_width),
);
ctx.attr_position(Position::Center);
if ctx.button("pref-tab-inc", "+", ButtonStyle::default()) {
changed = Some(state.preferences.tab_width.saturating_add(1));
}
if let Some(new_width) = changed {
let new_width = new_width.clamp(1, 8);
if state.preferences.tab_width != new_width {
state.preferences.tab_width = new_width;
state.apply_preferences_to_documents();
state.save_preferences();
ctx.needs_rerender();
}
}
}
ctx.table_end();
ctx.label("colorscheme-label", loc(LocId::PreferencesColorscheme));
ctx.attr_padding(Rect::three(0, 0, 1));
@ -349,6 +428,62 @@ pub fn draw_dialog_preferences(ctx: &mut Context, state: &mut State) {
}
}
pub fn draw_recent_files_dialog(ctx: &mut Context, state: &mut State) {
if state.recent_files.is_empty() {
state.wants_recent_files = false;
return;
}
let mut close = false;
let mut open_path: Option<PathBuf> = None;
ctx.modal_begin("recent-files", loc(LocId::RecentFilesDialogTitle));
ctx.attr_focus_well();
ctx.attr_padding(Rect::three(1, 2, 1));
{
if ctx.contains_focus() && ctx.consume_shortcut(vk::ESCAPE) {
close = true;
}
ctx.block_begin("recent-list");
ctx.attr_padding(Rect::three(0, 0, 1));
for (idx, entry) in state.recent_files.iter().enumerate() {
ctx.next_block_id_mixin(idx as u64);
ctx.attr_overflow(Overflow::TruncateTail);
if ctx.button("recent-entry", entry.as_str(), ButtonStyle::default()) {
open_path = Some(entry.as_path().to_path_buf());
}
}
ctx.block_end();
ctx.attr_position(Position::Center);
if ctx.button("recent-close", loc(LocId::SearchClose), ButtonStyle::default()) {
close = true;
}
}
if ctx.modal_end() {
close = true;
}
if let Some(path) = open_path {
let prefs = state.preferences.clone();
match state.documents.add_file_path(&path) {
Ok(doc) => {
prefs.apply_to_document(doc);
state.mark_file_recent_path(&path);
state.wants_recent_files = false;
ctx.needs_rerender();
}
Err(err) => error_log_add(ctx, state, err),
}
return;
}
if close {
state.wants_recent_files = false;
}
}
impl State {
pub fn apply_preferences_to_documents(&mut self) {
let prefs = self.preferences.clone();
@ -361,6 +496,86 @@ impl State {
self.preferences.save_to_disk();
}
pub fn initialize_session(&mut self) {
if let Some(session_file) = session::load() {
self.set_recent_files_from_session(session_file.recent_files);
if !self.skip_session_restore {
self.restore_session_documents(&session_file.open_documents);
}
}
}
pub fn save_session(&self) {
let mut session_file = session::SessionFile {
version: session::SESSION_VERSION,
open_documents: Vec::new(),
recent_files: Vec::new(),
};
for doc in self.documents.iter().take(SESSION_DOCUMENT_LIMIT) {
if let Some(path) = &doc.path {
let cursor = doc.buffer.borrow().cursor_logical_pos();
session_file.open_documents.push(session::SessionDocument {
path: path.to_string_lossy().into_owned(),
line: cursor.y as i64,
column: cursor.x as i64,
});
}
}
for entry in self.recent_files.iter().take(RECENT_FILES_LIMIT) {
session_file.recent_files.push(entry.as_str().to_string());
}
let _ = session::save(&session_file);
}
pub fn mark_file_recent_path<P: AsRef<Path>>(&mut self, path: P) {
let normalized = path::normalize(path.as_ref());
if normalized.as_os_str().is_empty() {
return;
}
self.recent_files.retain(|entry| entry.as_path() != normalized.as_path());
self.recent_files.insert(0, DisplayablePathBuf::from_path(normalized));
if self.recent_files.len() > RECENT_FILES_LIMIT {
self.recent_files.truncate(RECENT_FILES_LIMIT);
}
}
fn set_recent_files_from_session(&mut self, entries: Vec<String>) {
self.recent_files.clear();
for entry in entries.into_iter().rev() {
if entry.is_empty() {
continue;
}
self.mark_file_recent_path(PathBuf::from(entry));
}
}
fn restore_session_documents(&mut self, entries: &[session::SessionDocument]) {
let prefs = self.preferences.clone();
for entry in entries.iter().rev().take(SESSION_DOCUMENT_LIMIT) {
if entry.path.is_empty() {
continue;
}
let path = PathBuf::from(&entry.path);
let mut opened = false;
if let Ok(doc) = self.documents.add_file_path(&path) {
opened = true;
prefs.apply_to_document(doc);
{
let mut tb = doc.buffer.borrow_mut();
let target =
Point { x: clamp_coord(entry.column), y: clamp_coord(entry.line) };
tb.cursor_move_to_logical(target);
}
}
if opened {
self.mark_file_recent_path(&path);
}
}
}
pub fn set_system_palette(&mut self, palette: [StraightRgba; INDEXED_COLORS_COUNT]) {
self.system_palette = palette;
}
@ -497,11 +712,23 @@ pub struct Preferences {
pub auto_close_pairs: bool,
pub line_highlight: bool,
pub colorscheme: ColorScheme,
pub show_line_numbers: bool,
pub word_wrap: bool,
pub indent_with_tabs: bool,
pub tab_width: u8,
}
impl Default for Preferences {
fn default() -> Self {
Self { auto_close_pairs: true, line_highlight: true, colorscheme: ColorScheme::System }
Self {
auto_close_pairs: true,
line_highlight: true,
colorscheme: ColorScheme::System,
show_line_numbers: true,
word_wrap: false,
indent_with_tabs: false,
tab_width: 4,
}
}
}
@ -509,6 +736,10 @@ impl Preferences {
fn apply_to_text_buffer(&self, tb: &mut buffer::TextBuffer) {
tb.set_auto_pair_enabled(self.auto_close_pairs);
tb.set_line_highlight_enabled(self.line_highlight);
tb.set_margin_enabled(self.show_line_numbers);
tb.set_word_wrap(self.word_wrap);
tb.set_indent_with_tabs(self.indent_with_tabs);
tb.set_tab_size(CoordType::from(self.tab_width));
}
pub fn apply_to_document(&self, doc: &mut Document) {
@ -550,6 +781,26 @@ impl Preferences {
prefs.colorscheme = val;
}
}
"show_line_numbers" => {
if let Some(val) = parse_bool(value) {
prefs.show_line_numbers = val;
}
}
"word_wrap" => {
if let Some(val) = parse_bool(value) {
prefs.word_wrap = val;
}
}
"indent_with_tabs" => {
if let Some(val) = parse_bool(value) {
prefs.indent_with_tabs = val;
}
}
"tab_width" => {
if let Some(val) = parse_u8_in_range(value, 1, 8) {
prefs.tab_width = val;
}
}
_ => {}
}
}
@ -566,10 +817,20 @@ impl Preferences {
}
}
let contents = format!(
"auto_close_pairs={}\nline_highlight={}\ncolorscheme={}\n",
"auto_close_pairs={}\n\
line_highlight={}\n\
colorscheme={}\n\
show_line_numbers={}\n\
word_wrap={}\n\
indent_with_tabs={}\n\
tab_width={}\n",
self.auto_close_pairs,
self.line_highlight,
self.colorscheme.as_str(),
self.show_line_numbers,
self.word_wrap,
self.indent_with_tabs,
self.tab_width,
);
let _ = fs::write(path, contents);
}
@ -663,7 +924,7 @@ const COLOR_SCHEME_HIGH_CONTRAST: [StraightRgba; INDEXED_COLORS_COUNT] = [
rgba(0xffffffff),
];
fn preferences_file_path() -> Option<PathBuf> {
pub(crate) fn config_dir() -> Option<PathBuf> {
let base = if cfg!(windows) {
env::var_os("APPDATA").map(PathBuf::from)
} else {
@ -677,7 +938,11 @@ fn preferences_file_path() -> Option<PathBuf> {
#[cfg(not(windows))]
let subdir = PathBuf::from("edit");
Some(base.join(subdir).join("preferences.toml"))
Some(base.join(subdir))
}
fn preferences_file_path() -> Option<PathBuf> {
config_dir().map(|dir| dir.join("preferences.toml"))
}
fn parse_bool(value: &str) -> Option<bool> {
@ -687,3 +952,11 @@ fn parse_bool(value: &str) -> Option<bool> {
_ => None,
}
}
fn parse_u8_in_range(value: &str, min: u8, max: u8) -> Option<u8> {
value.parse::<u8>().ok().map(|v| v.clamp(min, max))
}
fn clamp_coord(value: i64) -> CoordType {
value.clamp(0, isize::MAX as i64) as CoordType
}