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::{
carets, completion, edits,
lineops::{self, LineMove, Segment},
options::Options,
syntax::{Class, Syntax},
wordops::{self, WordSwap},
};
fn mark_match(
ui: &Ui,
output: &egui::text_edit::TextEditOutput,
(start, end): (usize, usize),
follow: bool,
) {
let offset = output.galley_pos.to_vec2();
let galley = &output.galley;
let from = galley
.pos_from_cursor(CCursor::new(start.min(end)))
.translate(offset);
let to = galley
.pos_from_cursor(CCursor::new(start.max(end)))
.translate(offset);
let rect = from.union(to);
let selection = ui.visuals().selection;
ui.painter().rect(
rect,
2.0,
selection.bg_fill.gamma_multiply(0.5),
egui::Stroke::new(1.0, selection.stroke.color),
egui::StrokeKind::Inside,
);
if follow {
ui.scroll_to_rect(rect, Some(egui::Align::Center));
}
}
fn mark_carets(ui: &Ui, output: &egui::text_edit::TextEditOutput, carets: &[usize]) {
if carets.is_empty() {
return;
}
let offset = output.galley_pos.to_vec2();
let stroke = ui.visuals().text_cursor.stroke;
for caret in carets {
let rect = output
.galley
.pos_from_cursor(CCursor::new(*caret))
.translate(offset);
ui.painter()
.line_segment([rect.left_top(), rect.left_bottom()], stroke);
}
}
fn page_step(ctx: &Context) -> Option<(f32, bool)> {
for (key, direction) in [(Key::PageDown, 1.0), (Key::PageUp, -1.0)] {
if ctx.input_mut(|input| input.consume_key(Modifiers::NONE, key)) {
return Some((direction, false));
}
if ctx.input_mut(|input| input.consume_key(Modifiers::SHIFT, key)) {
return Some((direction, true));
}
}
None
}
fn page_move(
ui: &Ui,
output: &egui::text_edit::TextEditOutput,
direction: f32,
height: f32,
) -> Option<usize> {
let galley = &output.galley;
let primary = output.cursor_range?.primary;
let here = galley.pos_from_cursor(primary).center();
let moved = galley.cursor_from_pos(egui::vec2(here.x, direction.mul_add(height, here.y)));
let rect = galley
.pos_from_cursor(moved)
.translate(output.galley_pos.to_vec2());
ui.scroll_to_rect(rect, None);
Some(moved.index.into())
}
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,
extra: Vec<usize>,
words: Vec<(String, usize)>,
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,
extra: Vec::new(),
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 const fn close_find(&mut self) {
self.search.open = false;
}
#[must_use]
pub const fn searching(&self) -> bool {
self.search.open
}
#[must_use]
pub fn search_terms(&self) -> (&str, &str) {
(&self.search.term, &self.search.replacement)
}
pub fn set_search_terms(&mut self, term: &str, replacement: &str) {
term.clone_into(&mut self.search.term);
replacement.clone_into(&mut self.search.replacement);
if !term.is_empty() {
self.search.last_term = Some(term.to_owned());
}
}
pub fn refresh_words(&mut self, text: &str) {
self.words = completion::collect_words(text);
}
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));
}
pub fn step_match(&mut self, text: &str, forward: bool) {
if let Some((start, end)) =
self.search
.jump_to_match(text, self.cursor, self.anchor, forward)
{
self.pending_external = Some((end, start));
}
}
#[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,
marked: Option<(usize, usize)>,
follow: bool,
page: Option<f32>,
) -> (egui::text_edit::TextEditOutput, Option<usize>) {
let syntax = self.syntax.as_ref();
let row_height = ui.text_style_height(&TextStyle::Monospace);
let height = self
.rows
.map_or_else(|| ui.available_height(), |rows| rows as f32 * row_height);
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))
})
};
let output = EguiTextEdit::multiline(text)
.id(editor_id)
.code_editor()
.desired_width(desired_width)
.desired_rows(rows)
.layouter(&mut layouter)
.show(ui);
if let Some(range) = marked {
mark_match(ui, &output, range, follow);
}
mark_carets(ui, &output, &self.extra);
let paged = page.and_then(|direction| page_move(ui, &output, direction, height));
(output, paged)
};
if let Some(rows) = self.rows {
return draw(ui, rows);
}
let rows = (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 [
(alt_shift_ctrl(), Segment::Section),
(alt_ctrl(), Segment::Section),
(alt_shift(), Segment::Block),
(Modifiers::ALT, Segment::Line),
] {
for (key, direction) in [
(Key::ArrowUp, LineMove::Up),
(Key::ArrowDown, LineMove::Down),
] {
if !ctx.input_mut(|i| i.consume_key(modifiers, key)) {
continue;
}
if self.anchor != self.cursor {
if let Some((new_text, (from, to))) =
lineops::move_segments(text, (self.anchor, self.cursor), segment, direction)
{
*text = new_text;
pending = Some(if self.anchor <= self.cursor {
(from, to)
} else {
(to, from)
});
edited = true;
}
} else if 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)> {
let reachable = has_focus || self.search.open;
if reachable && ctx.input_mut(|i| i.consume_key(Modifiers::CTRL, Key::F)) {
let selected = self.selected_text(text);
self.search.open(selected, false);
}
if reachable && 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(shift_ctrl(), Key::G)) {
return self
.search
.jump_to_match(text, self.cursor, self.anchor, false);
}
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);
}
None
}
fn stacked(&self, text: &str, down: bool) -> Option<usize> {
let carets = self.extra.iter().copied().chain([self.cursor]);
let furthest = if down { carets.max() } else { carets.min() }?;
let start = edits::line_start_char(text, furthest);
let column = furthest - start;
if down {
let end = edits::line_end_char(text, furthest);
if end >= text.chars().count() {
return None;
}
let next = end + 1;
Some((next + column).min(edits::line_end_char(text, next)))
} else {
if start == 0 {
return None;
}
let end = start - 1;
Some((edits::line_start_char(text, end) + column).min(end))
}
}
fn add_caret(&mut self, position: usize) {
self.extra.push(position);
self.extra.sort_unstable();
self.extra.dedup();
}
fn repeat_edit(
&mut self,
ctx: &Context,
text: &mut String,
was: Option<String>,
before: usize,
output: &egui::text_edit::TextEditOutput,
editor_id: Id,
) -> bool {
let Some(edit) = was.and_then(|was| carets::taken(&was, text)) else {
return false;
};
let moved = carets::spread(text, &edit, before, self.cursor, &mut self.extra);
let mut state = output.state.clone();
state
.cursor
.set_char_range(Some(CCursorRange::one(CCursor::new(moved))));
state.store(ctx, editor_id);
self.cursor = moved;
self.anchor = moved;
true
}
fn handle_caret_shortcuts(&mut self, ctx: &Context, text: &str, has_focus: bool) {
if !self.extra.is_empty()
&& !self.suggest.visible()
&& ctx.input_mut(|input| input.consume_key(Modifiers::NONE, Key::Escape))
{
self.extra.clear();
return;
}
if !has_focus {
return;
}
for (key, down) in [(Key::ArrowDown, true), (Key::ArrowUp, false)] {
if ctx.input_mut(|input| input.consume_key(shift_ctrl(), key))
&& let Some(position) = self.stacked(text, down)
{
self.add_caret(position);
}
}
}
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)
}
fn page_target(
&self,
output: &egui::text_edit::TextEditOutput,
paged: Option<usize>,
page: Option<(f32, bool)>,
) -> Option<(usize, usize)> {
let moved = paged?;
let (_, extend) = page?;
let anchor = if extend {
output
.cursor_range
.map_or(self.anchor, |range| range.secondary.index.into())
} else {
moved
};
Some((anchor, moved))
}
fn place_cursor(
&mut self,
ctx: &Context,
editor_id: Id,
output: &egui::text_edit::TextEditOutput,
pending: Option<(usize, usize)>,
) {
if let Some((start, end)) = pending {
let mut state = output.state.clone();
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(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();
}
}
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);
}
self.handle_caret_shortcuts(&ctx, text, has_focus);
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((first, rest)) = search.carets.split_first() {
pending = Some((*first, *first));
self.extra = rest.to_vec();
}
if let Some((cursor, anchor)) = self.pending_external.take() {
pending = Some((anchor, cursor));
}
let marked = (self.search.open && !has_focus)
.then(|| {
pending
.or_else(|| (self.cursor != self.anchor).then_some((self.anchor, self.cursor)))
})
.flatten();
let before = self.cursor;
let was = (!self.extra.is_empty()).then(|| text.clone());
let page = has_focus.then(|| page_step(&ctx)).flatten();
let (output, paged) = self.draw_text(
ui,
text,
editor_id,
marked,
pending.is_some(),
page.map(|(direction, _)| direction),
);
if let Some(range) = self.page_target(&output, paged, page) {
pending = Some(range);
}
let response = output.response.response.clone();
if self.autofocus && ui.ctx().memory(|m| m.focused().is_none()) {
response.request_focus();
}
let changed = response.changed();
self.place_cursor(&ctx, editor_id, &output, pending);
if response.clicked() && ctx.input(|input| input.modifiers.alt) {
self.add_caret(before);
}
edited |= self.repeat_edit(&ctx, text, was, before, &output, editor_id);
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,
},
)
}
}