mod search;
mod suggest;
use std::ops::Range;
use egui::{
Color32, Context, FontId, Id, Key, Modifiers, ScrollArea, TextEdit as EguiTextEdit, TextFormat,
TextStyle, Ui,
text::{CCursor, CCursorRange, LayoutJob, LayoutSection, TextWrapping},
};
use search::Search;
use suggest::Suggest;
use crate::{
completion, edits,
lineops::{self, LineMove, Segment},
options::Options,
syntax::{Class, Syntax},
wordops::{self, WordSwap},
};
const fn shift_ctrl() -> Modifiers {
Modifiers {
ctrl: true,
shift: true,
..Modifiers::NONE
}
}
const fn alt_shift() -> Modifiers {
Modifiers {
alt: true,
shift: true,
..Modifiers::NONE
}
}
const fn alt_ctrl() -> Modifiers {
Modifiers {
alt: true,
ctrl: true,
..Modifiers::NONE
}
}
const fn alt_shift_ctrl() -> Modifiers {
Modifiers {
alt: true,
shift: true,
ctrl: true,
..Modifiers::NONE
}
}
pub struct TextEditor {
id: u64,
cursor: usize,
anchor: usize,
words: Vec<String>,
suggest: Suggest,
search: Search,
pending_external: Option<(usize, usize)>,
options: Options,
autofocus: bool,
syntax: Option<Syntax>,
rows: Option<usize>,
}
const fn class_color(class: Class) -> Color32 {
match class {
Class::Comment => Color32::GRAY,
Class::String => Color32::from_rgb(140, 200, 120),
Class::Keyword => Color32::from_rgb(200, 140, 220),
Class::Type => Color32::from_rgb(120, 190, 200),
Class::Number => Color32::from_rgb(220, 200, 120),
}
}
fn layout_job(ui: &Ui, text: &str, syntax: Option<&Syntax>, wrap_width: f32) -> LayoutJob {
let font = ui
.style()
.text_styles
.get(&TextStyle::Monospace)
.cloned()
.unwrap_or_else(|| FontId::monospace(12.0));
let plain = ui.visuals().text_color();
let format = |color| TextFormat {
font_id: font.clone(),
color,
..TextFormat::default()
};
let mut job = LayoutJob {
text: text.to_owned(),
wrap: TextWrapping {
max_width: wrap_width,
..TextWrapping::default()
},
..LayoutJob::default()
};
let mut section = |byte_range: Range<usize>, color| {
job.sections.push(LayoutSection {
leading_space: 0.0,
byte_range: byte_range.start.into()..byte_range.end.into(),
format: format(color),
});
};
let mut index = 0;
for (range, class) in syntax.map(|syntax| syntax.spans(text)).unwrap_or_default() {
if range.start > index {
section(index..range.start, plain);
}
index = range.end;
section(range, class_color(class));
}
if index < text.len() {
section(index..text.len(), plain);
}
job
}
#[derive(Default)]
pub struct TextEditorOutput {
pub changed: bool,
}
impl Default for TextEditor {
fn default() -> Self {
Self::new(0)
}
}
impl TextEditor {
#[must_use]
pub fn new(id: u64) -> Self {
Self {
id,
cursor: 0,
anchor: 0,
words: Vec::new(),
suggest: Suggest::default(),
search: Search::default(),
pending_external: None,
options: Options {
wrap: false,
..Options::default()
},
autofocus: true,
syntax: None,
rows: None,
}
}
#[must_use]
pub const fn rows(mut self, rows: usize) -> Self {
self.rows = Some(rows);
self
}
#[must_use]
pub const fn autofocus(mut self, autofocus: bool) -> Self {
self.autofocus = autofocus;
self
}
pub fn set_syntax(&mut self, syntax: Option<Syntax>) {
self.syntax = syntax;
}
#[must_use]
pub const fn wrap(mut self, wrap: bool) -> Self {
self.options.wrap = wrap;
self
}
pub const fn set_options(&mut self, options: Options) {
self.options = options;
}
pub fn open_find(&mut self, replacing: bool) {
self.search.open(String::new(), replacing);
}
pub fn refresh_words(&mut self, text: &str) {
self.words = completion::collect_words(text)
.into_iter()
.map(|(word, _)| word)
.collect();
}
pub fn clamp_cursor(&mut self, text: &str) {
self.cursor = self.cursor.min(text.chars().count());
self.anchor = self.cursor;
}
pub const fn set_cursor(&mut self, cursor: usize, anchor: usize) {
self.pending_external = Some((cursor, anchor));
}
#[must_use]
pub const fn cursor(&self) -> usize {
self.cursor
}
#[must_use]
pub const fn anchor(&self) -> usize {
self.anchor
}
#[must_use]
pub fn has_focus(&self, ctx: &Context) -> bool {
ctx.memory(|memory| memory.has_focus(self.editor_id()))
}
pub fn request_focus(&self, ctx: &Context) {
ctx.memory_mut(|memory| memory.request_focus(self.editor_id()));
}
#[must_use]
pub const fn suggesting(&self) -> bool {
self.suggest.visible()
}
fn editor_id(&self) -> Id {
Id::new(("idet-core-text-editor", self.id))
}
fn draw_text(
&self,
ui: &mut Ui,
text: &mut String,
editor_id: Id,
) -> egui::text_edit::TextEditOutput {
let syntax = self.syntax.as_ref();
let row_height = ui.text_style_height(&TextStyle::Monospace);
let wrap = self.options.wrap;
let mut draw = |ui: &mut Ui, rows: usize| {
let desired_width = if wrap {
ui.available_width()
} else {
f32::INFINITY
};
let mut layouter = |ui: &Ui, buffer: &dyn egui::TextBuffer, wrap_width: f32| {
ui.fonts_mut(|fonts| {
fonts.layout_job(layout_job(ui, buffer.as_str(), syntax, wrap_width))
})
};
EguiTextEdit::multiline(text)
.id(editor_id)
.code_editor()
.desired_width(desired_width)
.desired_rows(rows)
.layouter(&mut layouter)
.show(ui)
};
if let Some(rows) = self.rows {
return draw(ui, rows);
}
let rows = (ui.available_height() / row_height).max(1.0) as usize;
ScrollArea::vertical()
.auto_shrink(false)
.show(ui, |ui| draw(ui, rows))
.inner
}
fn handle_move_and_swap(
&self,
ctx: &Context,
text: &mut String,
has_focus: bool,
) -> (Option<(usize, usize)>, bool) {
let mut pending = None;
let mut edited = false;
if !has_focus {
return (pending, edited);
}
for (modifiers, segment) in [
(Modifiers::ALT, Segment::Line),
(alt_shift(), Segment::Block),
(alt_ctrl(), Segment::Section),
(alt_shift_ctrl(), Segment::Section),
] {
for (key, direction) in [
(Key::ArrowUp, LineMove::Up),
(Key::ArrowDown, LineMove::Down),
] {
if ctx.input_mut(|i| i.consume_key(modifiers, key))
&& let Some((new_text, cursor)) =
lineops::move_segment(text, self.cursor, segment, direction)
{
*text = new_text;
pending = Some((cursor, cursor));
edited = true;
}
}
}
let selection = (self.cursor, self.anchor);
if ctx.input_mut(|i| i.consume_key(Modifiers::ALT, Key::ArrowLeft))
&& let Some((new_text, range)) = wordops::swap(text, selection, WordSwap::Prev)
{
*text = new_text;
pending = Some(range);
edited = true;
}
if ctx.input_mut(|i| i.consume_key(Modifiers::ALT, Key::ArrowRight))
&& let Some((new_text, range)) = wordops::swap(text, selection, WordSwap::Next)
{
*text = new_text;
pending = Some(range);
edited = true;
}
(pending, edited)
}
fn selected_text(&self, text: &str) -> String {
if self.anchor == self.cursor {
return String::new();
}
let start = self.cursor.min(self.anchor);
let end = self.cursor.max(self.anchor);
text.chars().skip(start).take(end - start).collect()
}
fn handle_search_shortcuts(
&mut self,
ctx: &Context,
text: &str,
has_focus: bool,
) -> Option<(usize, usize)> {
if has_focus && ctx.input_mut(|i| i.consume_key(Modifiers::CTRL, Key::F)) {
let selected = self.selected_text(text);
self.search.open(selected, false);
}
if has_focus && ctx.input_mut(|i| i.consume_key(Modifiers::CTRL, Key::H)) {
let selected = self.selected_text(text);
self.search.open(selected, true);
}
if has_focus && ctx.input_mut(|i| i.consume_key(Modifiers::CTRL, Key::G)) {
return self
.search
.jump_to_match(text, self.cursor, self.anchor, true);
}
if has_focus && ctx.input_mut(|i| i.consume_key(shift_ctrl(), Key::G)) {
return self
.search
.jump_to_match(text, self.cursor, self.anchor, false);
}
None
}
fn handle_suggest_navigation(&mut self, ctx: &Context, has_focus: bool) {
if !has_focus || !self.suggest.visible() {
return;
}
let length = self.suggest.matches.len();
if self.suggest.selected == 0
&& ctx.input(|i| i.key_pressed(Key::ArrowUp))
&& !ctx.input(|i| i.modifiers.any())
{
ctx.input_mut(|i| {
i.consume_key(Modifiers::NONE, Key::ArrowUp);
});
self.suggest.open = false;
self.suggest.suppressed = true;
} else if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowUp)) {
self.suggest.selected = self.suggest.selected.saturating_sub(1);
}
if self.suggest.selected + 1 == length
&& ctx.input(|i| i.key_pressed(Key::ArrowDown))
&& !ctx.input(|i| i.modifiers.any())
{
ctx.input_mut(|i| {
i.consume_key(Modifiers::NONE, Key::ArrowDown);
});
self.suggest.open = false;
self.suggest.suppressed = true;
} else if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::ArrowDown)) {
self.suggest.selected += 1;
}
}
fn handle_indent_and_suggest(
&mut self,
ctx: &Context,
text: &mut String,
has_focus: bool,
) -> (Option<(usize, usize)>, bool) {
let mut pending = None;
let mut edited = false;
self.handle_suggest_navigation(ctx, has_focus);
if !has_focus {
return (pending, edited);
}
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Escape)) {
self.suggest.open = false;
self.suggest.suppressed = true;
} else if ctx.input(|i| {
i.events
.iter()
.any(|event| matches!(event, egui::Event::Text(_)))
}) {
self.suggest.suppressed = false;
}
let popup = self.suggest.visible();
if ctx.input_mut(|i| i.consume_key(Modifiers::CTRL, Key::Tab)) {
let (new_text, cursor) = edits::dedent_line(text, self.cursor, self.options.tab_width);
*text = new_text;
pending = Some((cursor, cursor));
edited = true;
} else if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Tab)) {
pending = if popup {
self.suggest.accept(text, self.cursor)
} else {
let (new_text, cursor) =
edits::indent_line(text, self.cursor, self.options.tab_width);
*text = new_text;
edited = true;
Some((cursor, cursor))
};
}
if ctx.input_mut(|i| i.consume_key(Modifiers::NONE, Key::Enter)) {
pending = if popup {
self.suggest.accept(text, self.cursor)
} else {
let (new_text, cursor) = edits::newline_indent(text, self.cursor);
*text = new_text;
edited = true;
Some((cursor, cursor))
};
}
(pending, edited)
}
pub fn show(&mut self, ui: &mut Ui, text: &mut String) -> (egui::Response, TextEditorOutput) {
let ctx = ui.ctx().clone();
let mut pending = None;
let mut edited = false;
let editor_id = self.editor_id();
let has_focus = ctx.memory(|memory| memory.has_focus(editor_id));
let typed = ctx.input(|i| {
i.events
.iter()
.any(|event| matches!(event, egui::Event::Text(_)))
});
if let Some(range) = self.handle_search_shortcuts(&ctx, text, has_focus) {
pending = Some(range);
}
let (move_pending, move_edited) = self.handle_move_and_swap(&ctx, text, has_focus);
if let Some(range) = move_pending {
pending = Some(range);
}
edited |= move_edited;
let (indent_pending, indent_edited) = self.handle_indent_and_suggest(&ctx, text, has_focus);
if let Some(range) = indent_pending {
pending = Some(range);
}
edited |= indent_edited;
let search = self
.search
.draw(&ctx, text, self.cursor, self.anchor, self.id);
if let Some(range) = search.cursor {
pending = Some(range);
}
edited |= search.edited;
if let Some((cursor, anchor)) = self.pending_external.take() {
pending = Some((anchor, cursor));
}
let output = self.draw_text(ui, text, editor_id);
let response = output.response.response.clone();
if self.autofocus && ui.ctx().memory(|m| m.focused().is_none()) {
response.request_focus();
}
let changed = response.changed();
if let Some((start, end)) = pending {
let mut state = output.state;
let range = if start == end {
CCursorRange::one(CCursor::new(start))
} else {
CCursorRange::two(CCursor::new(start), CCursor::new(end))
};
state.cursor.set_char_range(Some(range));
state.store(ui.ctx(), editor_id);
self.cursor = end;
self.anchor = start;
} else if let Some(range) = output.cursor_range {
self.cursor = range.primary.index.into();
self.anchor = range.secondary.index.into();
}
if changed || edited {
self.refresh_words(text);
}
if changed && typed {
self.suggest.refresh(text, self.cursor, &self.words);
} else if changed || edited {
self.suggest.open = false;
}
if !response.has_focus() {
self.suggest.open = false;
}
if self.suggest.visible() {
self.suggest.draw(
ui.ctx(),
&output.galley,
output.galley_pos,
self.cursor,
self.id,
);
}
(
response,
TextEditorOutput {
changed: changed || edited,
},
)
}
}