// Copyright 2025 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Editor wrapper (ported from `cosmic-text/src/edit/editor.rs`).
///
/// This MoonBit port keeps the same high-level behavior but is implemented in a
/// functional style (methods return updated `Editor`).
fn order_cursors(a : Cursor, b : Cursor) -> (Cursor, Cursor) {
if a.line < b.line {
(a, b)
} else if a.line > b.line {
(b, a)
} else if a.index <= b.index {
(a, b)
} else {
(b, a)
}
}
///|
fn clamp(v : Int, lo : Int, hi : Int) -> Int {
if v < lo {
lo
} else if v > hi {
hi
} else {
v
}
}
///|
fn leading_whitespace(s : String) -> String {
let sb = StringBuilder::new(size_hint=32)
for ch in s {
if ch.is_whitespace() {
sb.write_char(ch)
} else {
break
}
}
sb.to_string()
}
///|
fn indent_scan(text : String, tab_width : Int) -> (Int, Int, Int) {
// Returns (last_indent_index, after_whitespace_index, whitespace_char_count).
let mut last_indent = 0
let mut after_ws = text.length()
let mut count = 0
for p in text.iter2() {
let idx = p.0
let c = p.1
if !c.is_whitespace() {
after_ws = idx
break
}
if tab_width > 0 && count % tab_width == 0 {
last_indent = idx
}
count = count + 1
}
(last_indent, after_ws, count)
}
///|
fn selection_shift_ge(
sel : Selection,
line_i : Int,
threshold : Int,
delta : Int,
) -> Selection {
match sel {
None => None
Normal(c) =>
if c.line == line_i && c.index >= threshold {
Normal(Cursor::new_with_affinity(c.line, c.index + delta, c.affinity))
} else {
sel
}
Line(c) =>
if c.line == line_i && c.index >= threshold {
Line(Cursor::new_with_affinity(c.line, c.index + delta, c.affinity))
} else {
sel
}
Word(c) =>
if c.line == line_i && c.index >= threshold {
Word(Cursor::new_with_affinity(c.line, c.index + delta, c.affinity))
} else {
sel
}
}
}
///|
fn selection_shift_gt_clamp(
sel : Selection,
line_i : Int,
threshold : Int,
delta : Int,
) -> Selection {
fn clamp_ge(v : Int, min : Int) -> Int {
if v < min {
min
} else {
v
}
}
match sel {
None => None
Normal(c) =>
if c.line == line_i && c.index > threshold {
Normal(
Cursor::new_with_affinity(
c.line,
clamp_ge(c.index - delta, threshold),
c.affinity,
),
)
} else {
sel
}
Line(c) =>
if c.line == line_i && c.index > threshold {
Line(
Cursor::new_with_affinity(
c.line,
clamp_ge(c.index - delta, threshold),
c.affinity,
),
)
} else {
sel
}
Word(c) =>
if c.line == line_i && c.index > threshold {
Word(
Cursor::new_with_affinity(
c.line,
clamp_ge(c.index - delta, threshold),
c.affinity,
),
)
} else {
sel
}
}
}
///|
fn line_items_for_insert(data : String) -> Array[(Int, Int, LineEnding)] {
let items : Array[(Int, Int, LineEnding)] = []
let mut iter = LineIter::new(data)
while true {
let (next_iter, item_opt) = iter.next()
iter = next_iter
match item_opt {
None => break
Some(item) => items.push(item)
}
}
// If the inserted data ends with a line ending, ensure a trailing empty line.
if items.length() > 0 {
let last = items[items.length() - 1]
if last.2.as_str() != "" {
let len = data.length()
items.push((len, len, LineEnding::None))
}
}
items
}
///|
fn deleted_text(buffer : Buffer, start : Cursor, end : Cursor) -> String {
let lines = buffer.lines()
let start_line = start.line
let end_line = end.line
if start_line < 0 ||
end_line < 0 ||
start_line >= lines.length() ||
end_line >= lines.length() {
return ""
}
if start_line == end_line {
let line = lines[start_line].text()
let a = clamp(start.index, 0, line.length())
let b = clamp(end.index, a, line.length())
slice_string(line, a, b)
} else {
let sb = StringBuilder::new(size_hint=64)
let first = lines[start_line]
let first_text = first.text()
let a = clamp(start.index, 0, first_text.length())
sb.write_view(
first_text[:].view(start_offset=a, end_offset=first_text.length()),
)
sb.write_string(first.ending().as_str())
for i in (start_line + 1).. Editor {
Editor::{
buffer,
cursor: Cursor::new(0, 0),
cursor_x_opt: None,
selection: Selection::None,
cursor_moved: false,
auto_indent: false,
change_opt: None,
}
}
///|
pub fn Editor::buffer(self : Editor) -> Buffer {
self.buffer
}
///|
pub fn Editor::cursor(self : Editor) -> Cursor {
self.cursor
}
///|
pub fn Editor::set_cursor(self : Editor, cursor : Cursor) -> Editor {
if self.cursor == cursor {
self
} else {
Editor::{
..self,
cursor,
cursor_moved: true,
buffer: self.buffer.set_redraw(true),
}
}
}
///|
pub fn Editor::selection(self : Editor) -> Selection {
self.selection
}
///|
pub fn Editor::set_selection(self : Editor, selection : Selection) -> Editor {
if self.selection == selection {
self
} else {
Editor::{ ..self, selection, }
}
}
///|
pub fn Editor::auto_indent(self : Editor) -> Bool {
self.auto_indent
}
///|
pub fn Editor::set_auto_indent(self : Editor, auto_indent : Bool) -> Editor {
if self.auto_indent == auto_indent {
self
} else {
Editor::{ ..self, auto_indent, }
}
}
///|
/// Get the bounds of the current selection (line/word selection is expanded).
pub fn Editor::selection_bounds(self : Editor) -> (Cursor, Cursor)? {
let cursor = self.cursor
let buffer = self.buffer
match self.selection {
Selection::None => None
Selection::Normal(select) => Some(order_cursors(select, cursor))
Selection::Line(select) => {
let start_line = if select.line < cursor.line {
select.line
} else {
cursor.line
}
let end_line = if select.line > cursor.line {
select.line
} else {
cursor.line
}
let lines = buffer.lines()
let end_index = lines[end_line].text().length()
Some((Cursor::new(start_line, 0), Cursor::new(end_line, end_index)))
}
Selection::Word(select) => {
let (start0, end0) = order_cursors(select, cursor)
let mut start = start0
let mut end = end0
// Move start to beginning of word (whitespace-based MVP).
{
let line = buffer.lines()[start.line]
let ranges = word_indices_uax29(line.text())
let mut found = 0
let mut i = ranges.length() - 1
while i >= 0 {
let (ws, _we) = ranges[i]
if ws < start.index {
found = ws
break
}
if i == 0 {
break
}
i = i - 1
}
start = Cursor::new_with_affinity(start.line, found, start.affinity)
}
// Move end to end of word (whitespace-based MVP).
{
let line = buffer.lines()[end.line]
let ranges = word_indices_uax29(line.text())
let mut found = line.text().length()
for pair in ranges {
let we = pair.1
if we > end.index {
found = we
break
}
}
end = Cursor::new_with_affinity(end.line, found, end.affinity)
}
Some((start, end))
}
}
}
///|
pub fn Editor::tab_width(self : Editor) -> Int {
self.buffer.tab_width
}
///|
/// Set tab width in spaces. A value of 0 is ignored (matches upstream).
pub fn Editor::set_tab_width(self : Editor, tab_width : Int) -> Editor {
if tab_width <= 0 {
self
} else {
Editor::{ ..self, buffer: self.buffer.set_tab_width(tab_width) }
}
}
///|
pub fn Editor::start_change(self : Editor) -> Editor {
match self.change_opt {
Some(_) => self
None => Editor::{ ..self, change_opt: Some(Change::default()) }
}
}
///|
pub fn Editor::finish_change(self : Editor) -> (Editor, Change?) {
(Editor::{ ..self, change_opt: None }, self.change_opt)
}
///|
/// Insert `data` at the specified `cursor`, returning the updated editor and the new cursor.
///
/// NOTE: `attrs_list_opt` is applied to inserted text; when `None`, we use the previous character's attrs as defaults.
pub fn Editor::insert_at(
self : Editor,
cursor : Cursor,
data : String,
attrs_list_opt : AttrsList?,
) -> (Editor, Cursor) {
if data.length() == 0 {
return (self, cursor)
}
let mut cursor = cursor
let mut buffer = self.buffer
let lines0 = buffer.lines()
// Ensure there are enough lines in the buffer to handle this cursor.
let lines : Array[BufferLine] = []
for l in lines0 {
lines.push(l)
}
while cursor.line >= lines.length() {
let mut last_ending = LineEnding::None
if lines.length() > 0 {
let last_i = lines.length() - 1
let last_line0 = lines[last_i]
last_ending = last_line0.ending()
// Ensure a valid line ending is always set on interior lines.
if last_ending.as_str() == "" {
let last_line = last_line0.set_ending(LineEnding::default())
lines[last_i] = last_line
last_ending = last_line.ending()
}
}
let defaults = match attrs_list_opt {
Some(al) => al.defaults()
None =>
if lines.length() > 0 {
lines[lines.length() - 1].attrs_list().defaults()
} else {
Attrs::new()
}
}
lines.push(
BufferLine::new("", last_ending, AttrsList::new(defaults), Advanced),
)
}
// Clamp cursor index to the target line.
let target0 = lines[cursor.line]
let idx = clamp(cursor.index, 0, target0.text().length())
cursor = Cursor::new_with_affinity(cursor.line, idx, cursor.affinity)
let (left, after) = target0.split_off(idx)
let after_len = after.text().length()
// Determine attributes for inserted text (defaults + spans).
let mut attrs_remaining = match attrs_list_opt {
Some(al) => al
None =>
AttrsList::new(
left.attrs_list().get_span(if idx > 0 { idx - 1 } else { 0 }),
)
}
let parts = line_items_for_insert(data)
// data.length() > 0 implies at least one part.
let p0 = parts[0]
let first_text = slice_string(data, p0.0, p0.1)
let (first_attrs, rest_attrs) = attrs_remaining.split_off(first_text.length())
attrs_remaining = rest_attrs
let first = left.append(
BufferLine::new(first_text, p0.2, first_attrs, Advanced),
)
let out : Array[BufferLine] = []
for i in 0.. ()
Some(change0) => {
let items : Array[ChangeItem] = []
for it in change0.items {
items.push(it)
}
items.push(ChangeItem::{
start: cursor,
end: new_cursor,
text: data,
insert: true,
})
editor = Editor::{ ..editor, change_opt: Some(Change::{ items, }) }
}
}
(editor, new_cursor)
}
///|
/// Delete text starting at `start` cursor and ending at `end` cursor.
pub fn Editor::delete_range(
self : Editor,
start : Cursor,
end : Cursor,
) -> Editor {
let (start, end) = order_cursors(start, end)
if start.line == end.line && start.index == end.index {
return self
}
let buffer0 = self.buffer
let lines0 = buffer0.lines()
if start.line < 0 || start.line >= lines0.length() {
return self
}
let out : Array[BufferLine] = []
for i in 0..= lines0.length() {
return self
}
let start_line0 = lines0[start.line]
let end_line0 = lines0[end.line]
let idx0 = clamp(start.index, 0, start_line0.text().length())
let idx1 = clamp(end.index, 0, end_line0.text().length())
let (head, _tail) = start_line0.split_off(idx0)
let (_removed, after) = end_line0.split_off(idx1)
out.push(head.append(after))
for i in (end.line + 1).. ()
Some(change0) => {
let items : Array[ChangeItem] = []
for it in change0.items {
items.push(it)
}
items.push(ChangeItem::{
start,
end,
text: deleted_text(buffer0, start, end),
insert: false,
})
editor = Editor::{ ..editor, change_opt: Some(Change::{ items, }) }
}
}
editor
}
///|
pub fn Editor::copy_selection(self : Editor) -> String? {
match self.selection_bounds() {
None => None
Some((start0, end0)) => {
let lines = self.buffer.lines()
let start = Cursor::new(
start0.line,
clamp(start0.index, 0, lines[start0.line].text().length()),
)
let end = Cursor::new(
end0.line,
clamp(end0.index, 0, lines[end0.line].text().length()),
)
if start.line == end.line {
let text = lines[start.line].text()
Some(slice_string(text, start.index, end.index))
} else {
let sb = StringBuilder::new(size_hint=64)
// First line.
{
let text = lines[start.line].text()
sb.write_view(
text[:].view(start_offset=start.index, end_offset=text.length()),
)
sb.write_string("\n")
}
// Interior lines.
for line_i in (start.line + 1).. (Editor, Bool) {
match self.selection_bounds() {
None => (self, false)
Some((start, end)) => {
let editor = self
.set_cursor(start)
.set_selection(Selection::None)
.delete_range(start, end)
(editor, true)
}
}
}
///|
/// Insert string at current cursor, replacing selection if present.
pub fn Editor::insert_string(
self : Editor,
data : String,
attrs_list_opt : AttrsList?,
) -> Editor {
let (editor0, _deleted) = self.delete_selection()
let cursor = editor0.cursor
let (editor1, new_cursor) = editor0.insert_at(cursor, data, attrs_list_opt)
editor1.set_cursor(new_cursor)
}
///|
pub fn Editor::apply_change(self : Editor, change : Change) -> (Editor, Bool) {
// Cannot apply changes if there is a pending non-empty change.
match self.change_opt {
None => ()
Some(pending) => if pending.items.length() > 0 { return (self, false) }
}
let mut editor = Editor::{ ..self, change_opt: None }
let mut cursor = editor.cursor
for item in change.items {
if item.insert {
let (e, c) = editor.insert_at(item.start, item.text, None)
editor = e
cursor = c
} else {
editor = editor.set_cursor(item.start)
cursor = item.start
editor = editor.delete_range(item.start, item.end)
}
}
(editor.set_cursor(cursor), true)
}
///|
///|
/// Perform an action on the editor (MVP: cursor motion is text-index based, no hit-testing yet).
pub fn Editor::action(self : Editor, action : Action) -> Editor {
let old_cursor = self.cursor
let mut editor = self
match action {
Motion(motion) =>
match
editor.buffer.cursor_motion(editor.cursor, editor.cursor_x_opt, motion) {
None => ()
Some((new_cursor, new_x_opt)) => {
editor = editor.set_cursor(new_cursor)
editor = Editor::{ ..editor, cursor_x_opt: new_x_opt }
}
}
Escape => {
match editor.selection {
Selection::None => ()
_ =>
editor = Editor::{ ..editor, buffer: editor.buffer.set_redraw(true) }
}
editor = editor.set_selection(Selection::None)
}
Insert(ch) =>
if ch.is_control() && ch != '\t' && ch != '\n' {
()
} else if ch == '\n' {
editor = editor.action(Enter)
} else {
editor = editor.insert_string(ch.to_string(), None)
}
Enter =>
if editor.auto_indent {
let line = editor.buffer.lines()[editor.cursor.line].text()
editor = editor.insert_string("\n" + leading_whitespace(line), None)
} else {
editor = editor.insert_string("\n", None)
}
Backspace => {
let (e0, deleted) = editor.delete_selection()
editor = e0
if !deleted {
let end = editor.cursor
let mut start = end
if end.index > 0 {
let text = editor.buffer.lines()[end.line].text()
let ranges = grapheme_indices_uax29(text)
let mut found = 0
for r in ranges {
if end.index > r.0 && end.index <= r.1 {
found = r.0
}
}
start = Cursor::new_with_affinity(end.line, found, end.affinity)
} else if end.line > 0 {
let prev = end.line - 1
start = Cursor::new_with_affinity(
prev,
editor.buffer.lines()[prev].text().length(),
end.affinity,
)
}
if start != end {
editor = editor.set_cursor(start).delete_range(start, end)
}
}
}
Delete => {
let (e0, deleted) = editor.delete_selection()
editor = e0
if !deleted {
let start = editor.cursor
let mut end = start
let line = editor.buffer.lines()[start.line].text()
if start.index < line.length() {
let ranges = grapheme_indices_uax29(line)
let mut found = line.length()
for r in ranges {
if start.index >= r.0 && start.index < r.1 {
found = r.1
break
}
}
end = Cursor::new_with_affinity(start.line, found, start.affinity)
} else if start.line + 1 < editor.buffer.lines().length() {
end = Cursor::new_with_affinity(start.line + 1, 0, start.affinity)
}
if start != end {
editor = editor.delete_range(start, end).set_cursor(start)
}
}
}
Indent => {
let tab_width = editor.tab_width()
let (start, end) = match editor.selection_bounds() {
Some(some) => some
None => (editor.cursor, editor.cursor)
}
for line_i in start.line..<=end.line {
let text = editor.buffer.lines()[line_i].text()
let (_last_indent, after_ws, count) = indent_scan(text, tab_width)
let required = tab_width - count % tab_width
let indent = String::make(required, ' ')
let (e, _c) = editor.insert_at(
Cursor::new(line_i, after_ws),
indent,
None,
)
editor = e
if editor.cursor.line == line_i && editor.cursor.index >= after_ws {
editor = editor.set_cursor(
Cursor::new_with_affinity(
editor.cursor.line,
editor.cursor.index + required,
editor.cursor.affinity,
),
)
}
editor = Editor::{
..editor,
selection: selection_shift_ge(
editor.selection,
line_i,
after_ws,
required,
),
buffer: editor.buffer.set_redraw(true),
}
}
}
Unindent => {
let tab_width = editor.tab_width()
let (start, end) = match editor.selection_bounds() {
Some(some) => some
None => (editor.cursor, editor.cursor)
}
for line_i in start.line..<=end.line {
let text = editor.buffer.lines()[line_i].text()
let (last_indent, after_ws, _count) = indent_scan(text, tab_width)
// No de-indent required.
if last_indent == after_ws {
continue
}
let delta = after_ws - last_indent
editor = editor.delete_range(
Cursor::new(line_i, last_indent),
Cursor::new(line_i, after_ws),
)
if editor.cursor.line == line_i && editor.cursor.index > last_indent {
let shifted = editor.cursor.index - delta
let new_idx = if shifted < last_indent {
last_indent
} else {
shifted
}
editor = editor.set_cursor(
Cursor::new_with_affinity(
editor.cursor.line,
new_idx,
editor.cursor.affinity,
),
)
}
editor = Editor::{
..editor,
selection: selection_shift_gt_clamp(
editor.selection,
line_i,
last_indent,
delta,
),
buffer: editor.buffer.set_redraw(true),
}
}
}
Click(x, y) => {
editor = editor.set_selection(Selection::None)
match
editor.buffer.hit(
Float::from_double(x.to_double()),
Float::from_double(y.to_double()),
) {
None => ()
Some(new_cursor) => editor = editor.set_cursor(new_cursor)
}
}
DoubleClick(x, y) => {
editor = editor.action(Click(x, y))
editor = editor.set_selection(Selection::Word(editor.cursor))
editor = Editor::{ ..editor, buffer: editor.buffer.set_redraw(true) }
}
TripleClick(x, y) => {
editor = editor.action(Click(x, y))
editor = editor.set_selection(Selection::Line(editor.cursor))
editor = Editor::{ ..editor, buffer: editor.buffer.set_redraw(true) }
}
Drag(x, y) => {
match editor.selection {
Selection::None => {
editor = editor.set_selection(Selection::Normal(editor.cursor))
editor = Editor::{ ..editor, buffer: editor.buffer.set_redraw(true) }
}
_ => ()
}
match
editor.buffer.hit(
Float::from_double(x.to_double()),
Float::from_double(y.to_double()),
) {
None => ()
Some(new_cursor) => editor = editor.set_cursor(new_cursor)
}
}
Scroll(pixels) => {
let scroll0 = editor.buffer.scroll()
let scroll = Scroll::{ ..scroll0, vertical: scroll0.vertical + pixels }
editor = Editor::{ ..editor, buffer: editor.buffer.set_scroll(scroll) }
}
}
if old_cursor != editor.cursor {
editor = Editor::{
..editor,
cursor_moved: true,
buffer: editor.buffer.set_redraw(true),
}
}
editor
}