mirror of
https://github.com/microsoft/edit.git
synced 2025-12-23 07:07:25 +00:00
Add session restore and command palette
This commit is contained in:
parent
535c1d2d44
commit
3d025ca8de
10 changed files with 859 additions and 8 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -186,6 +186,7 @@ version = "1.2.1"
|
|||
dependencies = [
|
||||
"criterion",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml-span",
|
||||
"windows-sys",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
442
src/bin/edit/draw_command_palette.rs
Normal file
442
src/bin/edit/draw_command_palette.rs
Normal 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
51
src/bin/edit/session.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue