pub mod commands;
mod completion;
mod highlight_debug;
mod picker;
mod screen;
pub mod settings;
mod view;
use std::env;
use std::time::{Duration, Instant};
use crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
use ratatui::style::Modifier;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block as TuiBlock, Clear, Paragraph, Widget};
use ratatui::Frame;
use self::highlight_debug::{DebugEntry, DebugSection};
use self::picker::{Picker, PickerController, PickerMode};
use self::screen::{ScreenCapture, ScreenSelection};
use self::settings::keymap::{self, Keymaps, Shortcut};
use self::settings::statusline::StatusLinePosition;
use self::settings::theme::groups::{publish, scheme, HLGroup, GROUP_DECLARATIONS};
use self::settings::theme::Appearance;
use self::settings::{tabs as settings_tabs, Persisted, SavedSettings, Settings};
pub use self::view::{CommittedCells, ViewKind, Viewport};
use self::{
commands::Command,
completion::{Names, Request},
};
use super::cell::{CellState, EvaluationBlock, IncompleteBlock, OutputBlock, TextBlock};
use super::config::UI;
use super::debug::FrameStats;
use super::element::EventListener;
use super::feed::Feed;
use super::key::{KeyEvent, KeyMod};
use super::render::selection::Inclusions;
use super::render::RenderContext;
use super::statusline::{StatusLine, StatusLineContext, StatusLocation};
use super::{
action::Action,
block::{Block, Element, HLSpan, Reformatted, Rethemed, TextSpan, Ticked},
};
use crate::code::file::abbreviate_home;
use crate::code::highlight::Highlighter;
use crate::code::semantic::SEMANTIC_GROUPS;
use crate::input::InputAction;
use crate::input::{InputCode, InputContext};
pub enum Reaction {
Continue,
Submit,
ResumeIncomplete,
Post(String),
Restart,
Quit,
Copy(String),
ScrollView(i8),
Open(ViewKind),
ClosePane,
Save(Option<String>),
Load(String),
Resume,
}
pub enum EditorUpdate {
Exit,
Ignore,
Batch(Vec<Self>),
Incomplete {
source: String,
spans: Vec<HLSpan>,
reason: String,
},
SyntaxFailed {
id: u64,
source: String,
spans: Vec<HLSpan>,
message: String,
},
WhitespaceAccepted,
Diagnostic(String),
Message(Box<dyn Block>),
Welcome(String),
KnownNames(Vec<String>),
BoundNames(Vec<String>),
ExamplesComputed,
Pending {
id: u64,
cells: Vec<(String, Vec<HLSpan>)>,
},
Queued {
id: u64,
cells: Vec<(String, Vec<HLSpan>)>,
},
Started {
id: u64,
},
CellSucceeded {
id: u64,
index: usize,
number: u32,
state: CellState,
outputs: Vec<Box<dyn Block>>,
printed: Option<TextBlock>,
value: Option<OutputBlock>,
},
CellFailed {
id: u64,
index: usize,
number: u32,
printed: Option<TextBlock>,
message: String,
},
}
pub enum EditorEvent {
EditCommand(KeyEvent),
ResumeTyping(char),
ClosePickers,
RunCommand(Command),
ClosePicker,
RestoreDefaults,
RestoreKeymaps,
SetSettings(Settings),
Rebind(Shortcut, KeyEvent),
}
#[derive(Clone, Copy)]
enum ElementFocus {
Input,
Picker(PickerMode),
}
impl EditorUpdate {
fn moves_feed_rows(&self) -> bool {
matches!(
self,
Self::Incomplete { .. }
| Self::SyntaxFailed { .. }
| Self::Message(_)
| Self::Welcome(_)
| Self::ExamplesComputed
| Self::Pending { .. }
| Self::Queued { .. }
| Self::CellSucceeded { .. }
| Self::CellFailed { .. }
)
}
}
pub struct Editor {
feed: Feed,
input: InputCode,
settings: Settings,
keymaps: Keymaps,
status_line: StatusLine,
continuation: Option<String>,
processing: bool,
processing_started: Option<Instant>,
cancel_hint_shown: bool,
names: Names,
pickers: Vec<PickerController>,
feed_area: Rect,
feed_top: i64,
screen_selecting: bool,
screen_selection: Option<ScreenSelection>,
captures: Vec<ScreenCapture>,
selection_inclusions: Inclusions,
split: bool,
previewing: bool,
pane_focused: bool,
highlight_debug: bool,
highlight_debug_scroll: usize,
frame_stats: FrameStats,
last_click: Option<(Instant, Position)>,
pointer: Option<Position>,
control_held: bool,
}
impl Editor {
pub fn new(welcome: String) -> Self {
let appearance = Appearance::load();
publish(appearance.groups());
let saved = SavedSettings::load();
let mut input = InputCode::new();
input.set_settings(saved.layout);
let mut editor = Self {
feed: Feed::new(Box::new(TextBlock::system(welcome))),
input,
settings: Settings {
appearance,
layout: saved.layout,
statusline: saved.statusline,
rendering: saved.rendering,
selection: saved.selection,
},
keymaps: Keymaps::replaying(&saved.rebinds),
status_line: StatusLine::default(),
continuation: None,
processing: false,
processing_started: None,
cancel_hint_shown: false,
names: Names::default(),
pickers: Vec::new(),
feed_area: Rect::ZERO,
feed_top: 0,
screen_selecting: false,
screen_selection: None,
captures: Vec::new(),
selection_inclusions: Inclusions::default(),
split: false,
previewing: false,
pane_focused: true,
highlight_debug: false,
highlight_debug_scroll: 0,
frame_stats: FrameStats::default(),
last_click: None,
pointer: None,
control_held: false,
};
editor.refresh_status_line();
editor
}
pub fn set_processing(&mut self, processing: bool) {
if self.processing != processing {
self.processing = processing;
self.processing_started = processing.then(Instant::now);
self.cancel_hint_shown = false;
self.refresh_status_line();
}
}
pub fn animate(&mut self) -> bool {
if self.processing {
self.feed.on_event(&Ticked);
let show_hint = self
.processing_started
.is_some_and(|started| started.elapsed() >= Duration::from_secs(3));
if self.cancel_hint_shown != show_hint {
self.cancel_hint_shown = show_hint;
self.refresh_status_line();
}
}
self.processing
}
pub fn typing_remaining(&self) -> Option<Duration> {
self.input.typing_remaining()
}
pub fn settle_typing(&mut self) -> bool {
self.input.settle_typing()
}
pub fn example_timeout(&self) -> Duration {
self.settings.rendering.example_timeout()
}
pub fn language_server(&self) -> bool {
self.settings.rendering.language_server
}
pub fn semantic_highlighting(&self) -> bool {
self.settings.rendering.semantic_colors()
}
pub fn analysis_input(&self) -> (&str, Option<&str>, u64) {
let text = self.input.text();
let draft = if text.starts_with(commands::PREFIX) {
""
} else {
text
};
(draft, self.feed.incomplete_source(), self.input.revision())
}
pub fn rehighlight(&mut self, highlighter: &mut crate::code::semantic::SemanticHighlighter) {
let sources = highlighter
.segments()
.iter()
.map(|segment| segment.id)
.collect::<Vec<_>>();
self.feed.rehighlight(&sources, highlighter);
}
pub fn submission(&self) -> &str {
self.input.text()
}
pub fn set_context(&mut self, split: bool, previewing: bool, pane_focused: bool) {
self.split = split;
self.previewing = previewing;
self.pane_focused = pane_focused;
}
pub fn picker_open(&self) -> bool {
matches!(self.focus(), ElementFocus::Picker(_))
}
pub fn resolve_action(&self, key: KeyEvent) -> Option<Action> {
self.keymaps.action(
key,
matches!(self.focus(), ElementFocus::Picker(PickerMode::Keymaps)),
)
}
pub fn handle_preview_key(&mut self, key: KeyEvent) -> Reaction {
if matches!(self.focus(), ElementFocus::Picker(_)) {
return self.handle_picker_key(key);
}
if let Some(action) = self.handle_debug_key(key) {
return action;
}
Reaction::Continue
}
pub fn apply(&mut self, update: EditorUpdate) -> bool {
self.release_selection_for(&update);
match update {
EditorUpdate::Exit => return false,
EditorUpdate::Ignore => {}
EditorUpdate::Batch(updates) => {
return updates.into_iter().all(|update| self.apply(update));
}
EditorUpdate::Incomplete {
source,
spans,
reason,
} => {
self.input.accept_submission();
self.feed
.set_incomplete(IncompleteBlock::new(source, spans));
self.continuation = Some(reason);
}
EditorUpdate::SyntaxFailed {
id,
source,
spans,
message,
} => {
self.continuation = None;
if !self.input.text().is_empty() {
self.input.restore(source.clone());
self.input.accept_submission();
}
self.push_evaluation(EvaluationBlock::invalid(id, source, spans, message));
}
EditorUpdate::WhitespaceAccepted => {
self.continuation = None;
self.input.accept_submission();
}
EditorUpdate::Diagnostic(diagnostic) => self.input.set_diagnostic(diagnostic, None),
EditorUpdate::Message(block) => {
self.push_block(block);
}
EditorUpdate::Welcome(text) => {
self.feed.replace_opening(Box::new(TextBlock::system(text)));
}
EditorUpdate::KnownNames(names) => {
self.names.reset(names);
self.input.forget_completion();
}
EditorUpdate::BoundNames(names) => {
self.names.bind(names);
self.input.forget_completion();
}
EditorUpdate::ExamplesComputed => self.feed.on_event(&Reformatted),
EditorUpdate::Pending { id, cells } => {
self.continuation = None;
self.input.accept_submission();
self.push_evaluation(EvaluationBlock::new(id, cells));
}
EditorUpdate::Queued { id, cells } => {
self.continuation = None;
self.input.accept_submission();
self.push_evaluation(EvaluationBlock::queued(id, cells));
}
EditorUpdate::Started { id } => {
self.feed.update_evaluation(id, EvaluationBlock::start);
}
EditorUpdate::CellSucceeded {
id,
index,
number,
state,
outputs,
printed,
value,
} => {
self.input.clear_diagnostic();
let names = &mut self.names;
self.feed.update_evaluation(id, |evaluation| {
if let Some(source) = evaluation.source(index) {
names.record(source);
}
evaluation.succeed(index, number, state, outputs, printed, value)
});
self.input.forget_completion();
}
EditorUpdate::CellFailed {
id,
index,
number,
printed,
message,
} => {
self.input.clear_diagnostic();
self.feed.update_evaluation(id, |evaluation| {
evaluation.fail(index, number, printed, message)
});
}
}
self.refresh_status_line();
true
}
pub fn post(&mut self, update: EditorUpdate) -> bool {
self.release_selection_for(&update);
match update {
EditorUpdate::Batch(updates) => updates.into_iter().all(|update| self.post(update)),
EditorUpdate::Pending { id, cells } => {
self.push_evaluation(EvaluationBlock::new(id, cells));
self.refresh_status_line();
true
}
EditorUpdate::Queued { id, cells } => {
self.push_evaluation(EvaluationBlock::queued(id, cells));
self.refresh_status_line();
true
}
update => self.apply(update),
}
}
fn release_selection_for(&mut self, update: &EditorUpdate) {
if update.moves_feed_rows() && !self.screen_selecting {
self.screen_selection = None;
}
}
fn push_evaluation(&mut self, block: EvaluationBlock) {
self.feed.push_evaluation(block);
}
pub fn take_incomplete(&mut self) -> Option<String> {
let source = self.feed.take_incomplete()?.into_source();
self.continuation = None;
self.input.restore(source.clone());
self.refresh_status_line();
Some(source)
}
fn push_block(&mut self, block: Box<dyn Block>) {
self.feed.push(block);
}
pub fn paste(&mut self, text: &str) {
self.input.insert(text);
self.refresh_status_line();
}
const DOUBLE_CLICK: Duration = Duration::from_millis(400);
pub fn handle_mouse(&mut self, event: MouseEvent) -> Reaction {
let reaction = self.route_mouse(event);
self.copied_on_select(reaction)
}
fn copied_on_select(&self, reaction: Reaction) -> Reaction {
match reaction {
Reaction::Copy(_) if !self.settings.selection.copy_on_select => Reaction::Continue,
reaction => reaction,
}
}
fn route_mouse(&mut self, event: MouseEvent) -> Reaction {
let pointer = Position::new(event.column, event.row);
self.pointer = Some(pointer);
self.control_held = event.modifiers.contains(KeyModifiers::CONTROL);
if matches!(event.kind, MouseEventKind::Down(MouseButton::Left)) && self.control_held {
if let Some(link) = self.feed.link_at(pointer) {
self.screen_selection = None;
return Reaction::Post(format!("help {}", link.target));
}
}
let double = self.double_clicked(event);
if double && self.feed.toggle_fold(pointer) {
self.last_click = None;
self.screen_selection = None;
return Reaction::Continue;
}
if double && self.input.contains(event) {
self.screen_selection = None;
return self
.input
.handle_double_click(event)
.map_or(Reaction::Continue, Reaction::Copy);
}
if let Some(copy) = double.then(|| self.pick_screen_text(event)).flatten() {
return copy;
}
if let Some(action) = self.handle_screen_mouse(event) {
return action;
}
match event.kind {
MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {
let delta = if event.kind == MouseEventKind::ScrollUp {
1
} else {
-1
};
if self.input.contains(event) {
let field = self.input.field_mut();
match delta > 0 {
true => field.move_up(),
false => field.move_down(),
}
} else {
self.feed.scroll(delta);
}
}
_ => {}
}
let (handled, copy) = self.input.handle_mouse(event);
if handled {
self.screen_selection = None;
return copy.map_or(Reaction::Continue, Reaction::Copy);
}
Reaction::Continue
}
pub fn hold_control(&mut self, held: bool) {
self.control_held = held;
}
fn double_clicked(&mut self, event: MouseEvent) -> bool {
let MouseEventKind::Down(MouseButton::Left) = event.kind else {
return false;
};
let click = (Instant::now(), Position::new(event.column, event.row));
self.last_click
.replace(click)
.is_some_and(|(at, position)| {
position == click.1 && click.0.duration_since(at) <= Self::DOUBLE_CLICK
})
}
pub fn handle_preview_mouse(&mut self, event: MouseEvent) -> Reaction {
let double = self.double_clicked(event);
let reaction = double
.then(|| self.pick_screen_text(event))
.flatten()
.or_else(|| self.handle_screen_mouse(event))
.unwrap_or(Reaction::Continue);
self.copied_on_select(reaction)
}
pub fn scroll_feed(&mut self, rows: i8) {
self.feed.scroll(rows);
}
pub fn handle_input(&mut self, key: KeyEvent) -> Reaction {
if matches!(self.focus(), ElementFocus::Picker(_)) {
return self.handle_picker_key(key);
}
if let Some(action) = self.handle_debug_key(key) {
return action;
}
let edit = self.keymaps.edit(key);
if edit == Some(InputAction::HistoryPrevious)
&& self.input.text().is_empty()
&& self.continuation.is_some()
{
return Reaction::ResumeIncomplete;
}
self.screen_selection = None;
self.refresh_completion();
self.input.handle_edit_key(key, edit);
if self.input.text().is_empty() {
self.continuation = None;
}
if self.input.text() == commands::PREFIX {
self.open_filtered_commands();
}
self.refresh_status_line();
Reaction::Continue
}
pub fn handle_action(&mut self, action: Action) -> Reaction {
if matches!(self.focus(), ElementFocus::Picker(_)) {
return self.handle_picker_action(action);
}
if let Some(result) = self.handle_debug_action(action) {
return result;
}
if action != Action::CopySelection {
self.screen_selection = None;
}
if let Some(reaction) = self.perform(action, "") {
return reaction;
}
if action == Action::CopySelection {
let text = if self.previewing {
self.selected_screen_text()
} else {
self.input
.field()
.selected_text()
.map(str::to_string)
.or_else(|| self.selected_screen_text())
};
return text.map_or(Reaction::Continue, Reaction::Copy);
}
if self.previewing {
return Reaction::Continue;
}
if action == Action::Submit {
self.input.clear_diagnostic();
if let Some((command, argument)) = commands::exact(self.input.text()) {
let argument = argument.to_string();
self.continuation = None;
self.input.accept_submission();
return self
.perform(command.action, &argument)
.expect("a command's action means the same whatever has focus");
}
self.input.field_mut().clear_selection();
self.refresh_status_line();
return Reaction::Submit;
}
match action {
Action::CopySelection => unreachable!("copy selection is handled before editing"),
Action::Submit => unreachable!("submission is handled before component dispatch"),
Action::ScrollFeedUp => self.feed.scroll(1),
Action::ScrollFeedDown => self.feed.scroll(-1),
Action::OpenCommands => self.toggle_picker(PickerMode::Commands, Self::open_commands),
Action::ToggleHighlightDebug => unreachable!("debug toggles before input handling"),
Action::CancelExecution => unreachable!("cancelling is handled by the app"),
Action::OpenSettings
| Action::OpenKeymaps
| Action::ClearFeed
| Action::OpenCode
| Action::OpenValues
| Action::OpenStdOut
| Action::OpenOutput
| Action::OpenSyntaxTree
| Action::ClosePane
| Action::Restart
| Action::Quit
| Action::SaveSession
| Action::LoadCode
| Action::ResumeSession => {
unreachable!("focus-independent actions perform before input handling")
}
}
self.refresh_status_line();
Reaction::Continue
}
fn perform(&mut self, action: Action, argument: &str) -> Option<Reaction> {
Some(match action {
Action::OpenSettings => {
self.open_from_commands(PickerMode::Settings, Self::settings_picker);
Reaction::Continue
}
Action::OpenKeymaps => {
self.open_from_commands(PickerMode::Keymaps, Self::keymaps_picker);
Reaction::Continue
}
Action::ClearFeed => {
self.clear_feed();
Reaction::Continue
}
Action::OpenCode => self.leave_pickers(Reaction::Open(ViewKind::Code)),
Action::OpenValues => self.leave_pickers(Reaction::Open(ViewKind::Values)),
Action::OpenStdOut => self.leave_pickers(Reaction::Open(ViewKind::StdOut)),
Action::OpenOutput => self.leave_pickers(Reaction::Open(ViewKind::Output)),
Action::OpenSyntaxTree => self.leave_pickers(Reaction::Open(ViewKind::SyntaxTree)),
Action::ClosePane => self.leave_pickers(Reaction::ClosePane),
Action::Restart => self.leave_pickers(Reaction::Restart),
Action::Quit => self.leave_pickers(Reaction::Quit),
Action::SaveSession => self.leave_pickers(Reaction::Save(
(!argument.is_empty()).then(|| argument.to_string()),
)),
Action::LoadCode if argument.is_empty() => {
self.input
.set_diagnostic("name a file to run".to_string(), None);
Reaction::Continue
}
Action::LoadCode => self.leave_pickers(Reaction::Load(argument.to_string())),
Action::ResumeSession => self.leave_pickers(Reaction::Resume),
Action::CancelExecution
| Action::Submit
| Action::ScrollFeedUp
| Action::ScrollFeedDown
| Action::OpenCommands
| Action::ToggleHighlightDebug
| Action::CopySelection => return None,
})
}
fn leave_pickers(&mut self, reaction: Reaction) -> Reaction {
self.close_pickers();
reaction
}
fn open_commands(&mut self) {
let commands = commands::matching("");
self.pickers = vec![PickerController {
mode: PickerMode::Commands,
picker: commands::picker(&commands, &self.keymaps, ""),
}];
self.refresh_status_line();
}
fn toggle_picker(&mut self, mode: PickerMode, open: impl FnOnce(&mut Self)) {
if !self.close_if_open(mode) {
open(self);
}
}
fn close_if_open(&mut self, mode: PickerMode) -> bool {
let open = self
.pickers
.iter()
.any(|controller| controller.mode == mode);
if open {
self.pickers.clear();
self.refresh_status_line();
}
open
}
fn open_filtered_commands(&mut self) {
let commands = commands::matching("");
self.pickers = vec![PickerController {
mode: PickerMode::FilteredCommands,
picker: commands::picker(&commands, &self.keymaps, ""),
}];
self.refresh_status_line();
}
fn refresh_commands(&mut self) {
let Some(query) = self.input.text().strip_prefix(commands::PREFIX) else {
self.pickers.clear();
self.refresh_status_line();
return;
};
let commands = commands::matching(query);
let complete = commands::exact(self.input.text())
.is_some_and(|(command, _)| command.argument.is_none());
if commands.is_empty() || complete {
self.pickers.clear();
self.refresh_status_line();
return;
}
let Some(selected) = self
.pickers
.last()
.map(|controller| controller.picker.selected())
else {
return;
};
let mut picker = commands::picker(&commands, &self.keymaps, query);
picker.select(selected);
self.pickers.last_mut().unwrap().picker = picker;
}
fn close_pickers(&mut self) {
let filtered = self
.pickers
.last()
.is_some_and(|controller| matches!(controller.mode, PickerMode::FilteredCommands));
self.pickers.clear();
if filtered {
self.input.clear();
}
self.refresh_status_line();
}
fn run_command(&mut self, command: Command) -> Reaction {
if matches!(command.argument, Some(commands::Argument::Required(_))) {
self.close_pickers();
self.paste(&format!("{} ", command.literal()));
return Reaction::Continue;
}
if self
.pickers
.last()
.is_some_and(|controller| matches!(controller.mode, PickerMode::FilteredCommands))
{
self.input.restore(command.literal());
self.input.accept_submission();
}
self.handle_action(command.action)
}
fn picked_command(&self) -> Option<Command> {
let controller = self
.pickers
.last()
.filter(|controller| controller.mode == PickerMode::FilteredCommands)?;
let query = self.input.text().strip_prefix(commands::PREFIX)?;
commands::matching(query)
.get(controller.picker.selected())
.copied()
}
fn refresh_completion(&mut self) {
if !self.input.needs_completion() {
return;
}
self.input
.set_command_range(commands::name_range(self.input.text()));
let completion = completion::complete(Request {
text: self.input.text(),
cursor: self.input.field().cursor(),
pending: self.feed.incomplete_source(),
picked: self.picked_command(),
names: &self.names,
complete_after: usize::from(self.settings.layout.complete_after),
});
self.input.offer(completion);
}
fn clear_feed(&mut self) {
self.close_pickers();
self.feed.clear();
self.screen_selection = None;
}
fn settings_picker(&self) -> Picker {
Picker::tabbed(
settings_tabs::tabs(self.settings),
Some(settings_tabs::restore_defaults()),
)
}
fn refresh_settings_picker(&mut self) {
let tabs = settings_tabs::tabs(self.settings);
self.pickers.last_mut().unwrap().picker.set_tabs(tabs);
}
fn keymaps_picker(&self) -> Picker {
Picker::tabbed(
keymap::tabs(&self.keymaps),
Some(keymap::restore_defaults()),
)
}
fn handle_picker_key(&mut self, key: KeyEvent) -> Reaction {
let ElementFocus::Picker(_) = self.focus() else {
return Reaction::Continue;
};
self.input.forget_completion();
if let Some(event) = self.pickers.last_mut().unwrap().handle_key(key) {
let action = self.handle_picker_event(event);
if !matches!(action, Reaction::Continue) {
return action;
}
}
self.refresh_status_line();
Reaction::Continue
}
fn handle_picker_action(&mut self, action: Action) -> Reaction {
let ElementFocus::Picker(mode) = self.focus() else {
return Reaction::Continue;
};
if mode == PickerMode::Keymaps && action == Action::OpenKeymaps {
self.close_if_open(PickerMode::Keymaps);
return Reaction::Continue;
}
if action == Action::Quit {
return self.leave_pickers(Reaction::Quit);
}
if mode != PickerMode::Keymaps {
if let Some(result) = self.handle_debug_action(action) {
return result;
}
if action == Action::OpenCommands {
self.toggle_picker(PickerMode::Commands, Self::open_commands);
return Reaction::Continue;
}
if let Some(reaction) = self.perform(action, "") {
return reaction;
}
}
if let Some(event) = self.pickers.last_mut().unwrap().handle_action(action) {
let result = self.handle_picker_event(event);
if !matches!(result, Reaction::Continue) {
return result;
}
}
self.refresh_status_line();
Reaction::Continue
}
fn focus(&self) -> ElementFocus {
self.pickers.last().map_or(ElementFocus::Input, |picker| {
ElementFocus::Picker(picker.mode)
})
}
fn open_from_commands(&mut self, mode: PickerMode, picker: fn(&Self) -> Picker) {
if self.close_if_open(mode) {
return;
}
if matches!(
self.pickers.last().map(|controller| controller.mode),
Some(PickerMode::FilteredCommands)
) {
self.input.clear();
}
self.pickers.push(PickerController {
mode,
picker: picker(self),
});
self.refresh_status_line();
}
fn handle_picker_event(&mut self, event: EditorEvent) -> Reaction {
match event {
EditorEvent::EditCommand(key) => {
let revision = self.input.revision();
match key.code {
KeyCode::Tab if key.ctrl() => {
self.pickers.last_mut().unwrap().picker.move_selection(-1)
}
KeyCode::Tab => {
if let Some(command) = self.picked_command() {
self.input.restore(commands::completed(command));
}
}
KeyCode::BackTab => self.pickers.last_mut().unwrap().picker.move_selection(1),
KeyCode::Char(character) => self.input.field_mut().insert_char(character),
KeyCode::Backspace => self.input.field_mut().backspace(),
KeyCode::Delete => self.input.field_mut().delete_forward(),
KeyCode::Left => self.input.field_mut().move_left(),
KeyCode::Right => self.input.field_mut().move_right(),
KeyCode::Home => self.input.field_mut().move_home(),
KeyCode::End => self.input.field_mut().move_end(),
_ => {}
}
self.input.note_keystroke_since(revision);
self.refresh_commands();
}
EditorEvent::ResumeTyping(character) => {
self.pickers.clear();
if !self.previewing {
let revision = self.input.revision();
self.input.clear_diagnostic();
self.input.field_mut().insert_char(character);
self.input.note_keystroke_since(revision);
}
}
EditorEvent::ClosePickers => {
self.pickers.clear();
self.refresh_status_line();
}
EditorEvent::RunCommand(command) => return self.run_command(command),
EditorEvent::ClosePicker => {
self.pickers.pop();
if matches!(
self.pickers.last().map(|controller| controller.mode),
Some(PickerMode::Settings)
) {
self.refresh_settings_picker();
}
if matches!(
self.pickers.last().map(|controller| controller.mode),
Some(PickerMode::FilteredCommands)
) {
self.input.restore(commands::PREFIX.to_string());
self.refresh_commands();
}
}
EditorEvent::RestoreDefaults => {
self.keymaps = Keymaps::default();
self.set_settings(Settings::default());
}
EditorEvent::RestoreKeymaps => {
self.keymaps = Keymaps::default();
self.save_settings();
let tabs = keymap::tabs(&self.keymaps);
self.pickers.last_mut().unwrap().picker.set_tabs(tabs);
}
EditorEvent::SetSettings(settings) => self.set_settings(settings),
EditorEvent::Rebind(shortcut, key) => {
if self.keymaps.rebind(shortcut, key) {
self.save_settings();
let tabs = keymap::tabs(&self.keymaps);
self.pickers.last_mut().unwrap().picker.set_tabs(tabs);
}
}
}
Reaction::Continue
}
fn set_settings(&mut self, settings: Settings) {
if settings.reformats(&self.settings) {
self.feed.on_event(&Reformatted);
}
self.apply_appearance(settings.appearance);
self.settings = settings;
self.input.set_settings(settings.layout);
self.save_settings();
self.refresh_settings_picker();
}
fn save_settings(&self) {
SavedSettings {
layout: self.settings.layout,
statusline: self.settings.statusline,
rendering: self.settings.rendering,
selection: self.settings.selection,
rebinds: self.keymaps.rebinds().to_vec(),
}
.save();
}
fn apply_appearance(&mut self, appearance: Appearance) {
if self.settings.appearance == appearance {
return;
}
self.settings.appearance = appearance;
publish(appearance.groups());
self.feed.on_event(&Rethemed);
appearance.save();
}
fn handle_debug_key(&mut self, key: KeyEvent) -> Option<Reaction> {
if self.highlight_debug && key.code == KeyCode::Esc {
return Some(self.toggle_highlight_debug());
}
if self.highlight_debug {
match key.code {
KeyCode::Up => {
self.highlight_debug_scroll = self.highlight_debug_scroll.saturating_sub(1)
}
KeyCode::Down => {
self.highlight_debug_scroll = self.highlight_debug_scroll.saturating_add(1)
}
KeyCode::PageUp => {
self.highlight_debug_scroll = self.highlight_debug_scroll.saturating_sub(10)
}
KeyCode::PageDown => {
self.highlight_debug_scroll = self.highlight_debug_scroll.saturating_add(10)
}
_ => return None,
}
return Some(Reaction::Continue);
}
None
}
fn handle_debug_action(&mut self, action: Action) -> Option<Reaction> {
if action == Action::ToggleHighlightDebug {
return Some(self.toggle_highlight_debug());
}
if self.highlight_debug {
match action {
Action::ScrollFeedUp => {
self.highlight_debug_scroll = self.highlight_debug_scroll.saturating_sub(10)
}
Action::ScrollFeedDown => {
self.highlight_debug_scroll = self.highlight_debug_scroll.saturating_add(10)
}
_ => return None,
}
return Some(Reaction::Continue);
}
None
}
fn toggle_highlight_debug(&mut self) -> Reaction {
self.highlight_debug = !self.highlight_debug;
self.highlight_debug_scroll = 0;
Reaction::Continue
}
fn highlight_debug_sections(&self) -> Vec<DebugSection> {
let mut lsp = Vec::new();
let mut tree_sitter = Vec::new();
let mut interface = Vec::new();
for (group, style) in scheme().styled_groups() {
let entry = DebugEntry::group(group.description(), style);
if SEMANTIC_GROUPS.contains(&group) {
lsp.push(entry);
} else if GROUP_DECLARATIONS.draws_captures(group) {
tree_sitter.push(entry);
} else {
interface.push(entry);
}
}
let palette = self
.settings
.appearance
.variant()
.palette()
.colors()
.into_iter()
.map(|(name, color)| DebugEntry::swatch(name, color))
.collect();
vec![
DebugSection::new("LSP highlights", lsp),
DebugSection::new("tree-sitter highlights", tree_sitter),
DebugSection::new("UI", interface),
DebugSection::new("original palette", palette),
]
}
fn draw_highlight_debug(&mut self, frame: &mut Frame) {
let available = self.feed_area;
if !self.highlight_debug || available.width < 4 || available.height < 5 {
return;
}
let sections = self.highlight_debug_sections();
let title = format!(
" {} {} · debug ",
self.settings.appearance.scheme().label(),
self.settings.appearance.variant().label()
);
let entry_widths = sections
.iter()
.map(|section| {
section
.entries
.iter()
.map(DebugEntry::width)
.max()
.unwrap_or(1)
})
.collect::<Vec<_>>();
let tallest = sections
.iter()
.map(|section| section.entries.len())
.max()
.unwrap_or(0);
let visible_rows = usize::from(available.height - 4);
let inner_width = usize::from(available.width - 2);
let section_width = |section: &DebugSection, entry_width: usize, rows: usize| {
(section.entries.len().div_ceil(rows).max(1) * entry_width)
.max(section.title.chars().count() + 2)
};
let total_width = |rows: usize| {
sections
.iter()
.zip(&entry_widths)
.map(|(section, &entry_width)| section_width(section, entry_width, rows))
.sum::<usize>()
};
let rows = (visible_rows.min(tallest).max(1)..=tallest.max(1))
.find(|&rows| total_width(rows) <= inner_width)
.unwrap_or(tallest.max(1));
let content_width = total_width(rows).max(title.chars().count());
self.highlight_debug_scroll = self
.highlight_debug_scroll
.min(rows.saturating_sub(visible_rows));
let heading = scheme().prompt().add_modifier(Modifier::BOLD);
let section_widths = sections
.iter()
.zip(&entry_widths)
.map(|(section, &entry_width)| section_width(section, entry_width, rows))
.collect::<Vec<_>>();
let mut lines = vec![
Line::from(Span::styled(
self.frame_stats.label(),
scheme().frame_stats(),
)),
Line::from(
sections
.iter()
.zip(§ion_widths)
.map(|(section, &width)| {
Span::styled(format!("{:<width$}", section.title), heading)
})
.collect::<Vec<_>>(),
),
];
let shown_rows = rows.min(visible_rows);
lines.extend((0..shown_rows).map(|row| {
let row = self.highlight_debug_scroll + row;
Line::from(
sections
.iter()
.zip(&entry_widths)
.zip(§ion_widths)
.flat_map(|((section, &entry_width), &width)| {
let sub_columns = width / entry_width;
let mut spans = (0..sub_columns)
.flat_map(|column| match section.entries.get(column * rows + row) {
Some(entry) => entry.spans(entry_width),
None => vec![Span::raw(" ".repeat(entry_width))],
})
.collect::<Vec<_>>();
spans.push(Span::raw(" ".repeat(width % entry_width)));
spans
})
.collect::<Vec<_>>(),
)
}));
let width = (content_width + 2).min(usize::from(available.width)) as u16;
let height = lines.len() as u16 + 2;
let area = Rect::new(
available.right() - width,
available.bottom() - height,
width,
height,
);
let base = scheme().transcript().patch(scheme().value());
let block = TuiBlock::bordered()
.style(base)
.border_style(scheme().highlight_inspector_border())
.title(Span::styled(title, scheme().highlight_inspector_title()));
let block = if rows > visible_rows {
let first = self.highlight_debug_scroll + 1;
let last = self.highlight_debug_scroll + shown_rows;
block.title_bottom(Span::styled(
format!(" ↑↓ {first}–{last} of {rows} "),
scheme().highlight_inspector_title(),
))
} else {
block
};
frame.render_widget(Clear, area);
frame.render_widget(Paragraph::new(lines).style(base).block(block), area);
self.selection_inclusions.cover(area);
}
fn refresh_status_line(&mut self) {
self.status_line.state_keys = if self.cancel_hint_shown {
Some(vec![
TextSpan::new(
format!(
"{} ",
self.keymaps
.key_for(Action::CancelExecution)
.unwrap_or_default()
),
HLGroup::KeymapHint,
),
TextSpan::new("to cancel", HLGroup::StatuslineHintLabel),
])
} else if let Some(controller) = self.pickers.last() {
Some(controller.hints())
} else if self.continuation.is_some() && self.input.text().is_empty() {
Some(vec![
TextSpan::new(
format!("{} ", KeyCode::Up + KeyMod::Ctrl),
HLGroup::KeymapHint,
),
TextSpan::new("continue", HLGroup::StatuslineHintLabel),
])
} else {
None
};
}
fn idle_hints(&self) -> Vec<TextSpan> {
let settings_key = self
.keymaps
.key_for(Action::OpenSettings)
.unwrap_or_default();
let settings = [
TextSpan::new(format!("{settings_key} "), HLGroup::KeymapHint),
TextSpan::new("settings", HLGroup::StatuslineHintLabel),
];
match self.input.text().is_empty() {
true => {
let commands_key = self
.keymaps
.key_for(Action::OpenCommands)
.unwrap_or_default();
let mut hints = vec![
TextSpan::new(format!("{commands_key} "), HLGroup::KeymapHint),
TextSpan::new("commands · ", HLGroup::StatuslineHintLabel),
];
hints.extend(settings);
hints
}
false => settings.into(),
}
}
fn status_context(&self) -> StatusLineContext {
StatusLineContext {
idle: self.settings.statusline.idle,
location: Some(self.location()),
hints: self.idle_hints(),
state: self.shell_state(),
..StatusLineContext::default()
}
}
pub fn draw(
&mut self,
frame: &mut Frame,
area: Rect,
highlighter: &mut dyn Highlighter,
in_debugger: bool,
) {
if crate::ui::graphics::refresh_cell_size() {
self.feed.on_event(&Reformatted);
}
if self.pane_focused {
self.frame_stats.tick();
}
self.selection_inclusions.clear();
let theme = *scheme();
let input_prefix = UI.prompt_width();
frame.render_widget(Clear, area);
let editor_area = area;
self.refresh_completion();
let input_height = self
.input
.height(editor_area.width, input_prefix, editor_area.height);
let status_height = u16::from(self.statusline_shown() && !self.split);
let picker_height = self.pickers.last().map_or_else(
|| {
self.input
.offered()
.map_or(0, |completion| completion.list_height())
},
|controller| controller.picker.height(editor_area.width),
);
let picker_height = picker_height.min(
editor_area
.height
.saturating_sub(input_height + status_height),
);
let feed_height = editor_area
.height
.saturating_sub(input_height + picker_height + status_height);
let constraints = if self.settings.statusline.position == StatusLinePosition::AboveInput {
[
Constraint::Min(feed_height),
Constraint::Length(picker_height),
Constraint::Length(status_height),
Constraint::Length(input_height),
]
} else {
[
Constraint::Min(feed_height),
Constraint::Length(picker_height),
Constraint::Length(input_height),
Constraint::Length(status_height),
]
};
let sections = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(editor_area);
let (input_area, status_area) =
if self.settings.statusline.position == StatusLinePosition::AboveInput {
(sections[3], sections[2])
} else {
(sections[2], sections[3])
};
let context = RenderContext::new(editor_area.width, 0)
.with_fractions(self.settings.rendering.fractions)
.with_blackboard(self.settings.rendering.math_fonts)
.with_matrix_row_spacing(self.settings.rendering.matrix_row_spacing_percent)
.with_display(self.settings.rendering.maths)
.with_recent_outputs(self.feed.recent_outputs())
.with_output_limit(self.settings.layout.output_rows);
let input_context = RenderContext::new(editor_area.width, input_prefix);
self.feed_area = sections[0];
self.draw_feed(frame, sections[0], &context);
if let Some(controller) = self.pickers.last() {
controller
.picker
.render(&(), sections[1], frame.buffer_mut());
} else if let Some(completion) = self
.input
.offered()
.filter(|completion| completion.list_height() > 0)
{
completion.render_list(sections[1], frame.buffer_mut(), &theme);
}
self.refresh_completion();
let pending = self.feed.incomplete_source();
let command = pending.is_none() && commands::typed(self.input.text());
if let Some(cursor) = self.input.render(
InputContext {
render: &input_context,
highlighter,
command,
debugging: in_debugger,
focused: self.pane_focused,
pending,
},
input_area,
frame.buffer_mut(),
) {
frame.set_cursor_position(cursor);
}
TuiBlock::default()
.style(theme.statusline_bg())
.render(status_area, frame.buffer_mut());
self.status_line
.render(&self.status_context(), status_area, frame.buffer_mut());
self.draw_highlight_debug(frame);
self.capture_screen(frame);
self.paint_screen_selection(frame);
}
pub fn statusline_shown(&self) -> bool {
self.settings.statusline.position != StatusLinePosition::Off
}
fn location(&self) -> StatusLocation {
StatusLocation {
scheme: self.settings.appearance.scheme().label().to_string(),
directory: env::current_dir()
.map(|directory| abbreviate_home(&directory))
.unwrap_or_default(),
}
}
fn shell_state(&self) -> Option<TextSpan> {
if self.processing {
Some(TextSpan::new("Processing", HLGroup::StatuslineWarning))
} else {
self.continuation.as_ref().map(|reason| {
TextSpan::new(format!("Waiting for {reason}"), HLGroup::StatuslineState)
})
}
}
fn draw_feed(&mut self, frame: &mut Frame, area: Rect, context: &RenderContext) -> bool {
if area.width == 0 || area.height == 0 {
self.feed_top = 0;
return false;
}
let (underfilled, inclusions) = self.feed.render(
context,
area,
frame.buffer_mut(),
self.settings
.appearance
.canvas(context.groups().transcript()),
);
self.feed_top = -(self.feed.offset() as i64);
self.selection_inclusions.merge(inclusions);
if let Some(link) = self.pointer.and_then(|pointer| self.feed.link_at(pointer)) {
let hovered = context.groups().style(match self.control_held {
true => HLGroup::MarkupLinkOpen,
false => HLGroup::MarkupLinkHover,
});
for cells in link.cells {
frame.buffer_mut().set_style(cells, hovered);
}
}
underfilled
}
}
#[cfg(test)]
mod mouse_tests {
use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
use ratatui::buffer::Buffer;
use ratatui::layout::{Position, Rect};
use ratatui::style::Style;
use super::{Editor, Reaction};
use crate::backend::M2Value;
use crate::ui::cell::OutputBlock;
use crate::ui::feed::Feed;
use crate::ui::render::RenderContext;
const AREA: Rect = Rect::new(0, 0, 60, 10);
fn editor_with_link() -> (Editor, Position) {
let mut editor = Editor::new(String::new());
editor.feed = Feed::new(Box::new(OutputBlock::rendered(
M2Value::Markup {
html: "<p>See <a href='"isPrime"'>isPrime</a>.</p>".into(),
fallback: "See isPrime.".into(),
example_width: 60,
},
None,
)));
editor.feed.render(
&RenderContext::new(AREA.width, 0),
AREA,
&mut Buffer::empty(AREA),
Style::default(),
);
let pointer = (0..AREA.height)
.flat_map(|row| (0..AREA.width).map(move |column| Position::new(column, row)))
.find(|&position| editor.feed.link_at(position).is_some())
.expect("the documentation link is visible");
(editor, pointer)
}
#[test]
fn only_control_click_opens_a_documentation_link() {
let (mut editor, pointer) = editor_with_link();
let click = |modifiers| MouseEvent {
kind: MouseEventKind::Down(MouseButton::Left),
column: pointer.x,
row: pointer.y,
modifiers,
};
assert!(matches!(
editor.handle_mouse(click(KeyModifiers::NONE)),
Reaction::Continue
));
assert!(matches!(
editor.handle_mouse(click(KeyModifiers::CONTROL)),
Reaction::Post(command) if command == "help \"isPrime\""
));
}
}
#[cfg(test)]
mod processing_tests {
use super::*;
use ratatui::buffer::Buffer;
#[test]
fn cancel_hint_is_delayed_uses_the_binding_and_clears_when_idle() {
let mut editor = Editor::new(String::new());
let text = |editor: &Editor| {
let area = Rect::new(0, 0, 80, 1);
let mut buffer = Buffer::empty(area);
editor
.status_line
.render(&StatusLineContext::default(), area, &mut buffer);
(0..80).map(|x| buffer[(x, 0)].symbol()).collect::<String>()
};
editor.set_processing(true);
editor.animate();
assert!(!text(&editor).contains("to cancel"));
editor.processing_started = Some(Instant::now() - Duration::from_secs(3));
editor.animate();
assert!(text(&editor).contains("to cancel"));
assert!(text(&editor).contains(&editor.keymaps.key_for(Action::CancelExecution).unwrap()));
assert!(editor
.keymaps
.rebind(Action::CancelExecution, KeyEvent::new(KeyCode::F(24))));
editor.refresh_status_line();
assert!(text(&editor).contains(&editor.keymaps.key_for(Action::CancelExecution).unwrap()));
editor.set_processing(false);
assert!(!text(&editor).contains("to cancel"));
editor.set_processing(true);
editor.animate();
assert!(!text(&editor).contains("to cancel"));
}
}
#[cfg(test)]
mod picker_tests {
use crossterm::event::KeyCode;
use super::{Editor, PickerMode};
use crate::ui::action::Action;
#[test]
fn a_setting_closes_every_picker_once_chosen_after_its_typed_command_ran() {
let mut editor = Editor::new(String::new());
for character in "/setting".chars() {
editor.handle_input(KeyCode::Char(character).into());
}
assert_eq!(
editor.pickers.last().map(|controller| controller.mode),
Some(PickerMode::FilteredCommands)
);
editor.handle_action(Action::Submit);
assert_eq!(
editor.pickers.last().map(|controller| controller.mode),
Some(PickerMode::Settings)
);
editor.handle_action(Action::Submit);
assert!(editor.pickers.is_empty());
}
}