use std::ops::Range;
use crate::{Context, Window};
use super::super::InputState;
use super::super::rope_ext::RopeExt as _;
impl InputState {
pub(crate) fn copy_line(
&mut self,
_: &super::CopyLine,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.guard_line_op(cx) {
return;
}
self.collapse_for_line_op(cx);
let (start_row, end_row) = self.target_rows();
let chunk = self.rows_text(start_row, end_row);
let insert_at = self.core.text.line_end_offset(end_row);
let insertion = format!("\n{chunk}");
let sel: Range<usize> = self.core.selected_range.into();
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&(insert_at..insert_at))),
&insertion,
window,
cx,
);
let shift = insertion.len();
self.core.selected_range = (sel.start + shift..sel.end + shift).into();
self.core.selection_reversed = false;
cx.notify();
}
pub(crate) fn delete_line(
&mut self,
_: &super::DeleteLine,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.guard_line_op(cx) {
return;
}
self.collapse_for_line_op(cx);
let (start_row, end_row) = self.target_rows();
let range = self.block_range(start_row, end_row);
self.replace_text_in_range_silent(Some(self.range_to_utf16(&range)), "", window, cx);
let last = self.core.text.lines_len().saturating_sub(1);
let row = start_row.min(last);
let offset = self
.core
.text
.line_start_offset(row)
.min(self.core.text.len());
self.core.selected_range = (offset..offset).into();
self.core.selection_reversed = false;
cx.notify();
}
pub(crate) fn move_line_up(
&mut self,
_: &super::MoveLineUp,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.guard_line_op(cx) {
return;
}
self.collapse_for_line_op(cx);
let (start_row, end_row) = self.target_rows();
if start_row == 0 {
return;
}
let prev = self.rows_text(start_row - 1, start_row - 1);
let block = self.rows_text(start_row, end_row);
let range = self.core.text.line_start_offset(start_row - 1)
..self.core.text.line_end_offset(end_row);
let replacement = format!("{block}\n{prev}");
let sel: Range<usize> = self.core.selected_range.into();
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&range)),
&replacement,
window,
cx,
);
let shift = prev.len() + 1;
self.core.selected_range =
(sel.start.saturating_sub(shift)..sel.end.saturating_sub(shift)).into();
self.core.selection_reversed = false;
cx.notify();
}
pub(crate) fn move_line_down(
&mut self,
_: &super::MoveLineDown,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.guard_line_op(cx) {
return;
}
self.collapse_for_line_op(cx);
let (start_row, end_row) = self.target_rows();
if end_row + 1 >= self.effective_line_count() {
return;
}
let next = self.rows_text(end_row + 1, end_row + 1);
let block = self.rows_text(start_row, end_row);
let range = self.core.text.line_start_offset(start_row)
..self.core.text.line_end_offset(end_row + 1);
let replacement = format!("{next}\n{block}");
let sel: Range<usize> = self.core.selected_range.into();
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&range)),
&replacement,
window,
cx,
);
let shift = next.len() + 1;
self.core.selected_range = (sel.start + shift..sel.end + shift).into();
self.core.selection_reversed = false;
cx.notify();
}
pub(crate) fn toggle_line_comment(
&mut self,
_: &super::ToggleLineComment,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.guard_line_op(cx) {
return;
}
self.collapse_for_line_op(cx);
let (start_row, end_row) = self.target_rows();
let prefix: String = self.line_comment_prefix.to_string();
let mut rows = Vec::new();
for row in start_row..=end_row {
let line_start = self.core.text.line_start_offset(row);
let line_end = self.core.text.line_end_offset(row);
let line = self.core.text.slice(line_start..line_end).to_string();
let indent_len = line.len() - line.trim_start().len();
if line.trim().is_empty() {
continue;
}
let commented = line
.get(indent_len..)
.is_some_and(|rest| rest.starts_with(&prefix));
rows.push((row, line_start + indent_len, commented));
}
if rows.is_empty() {
return;
}
let uncomment = rows.iter().all(|(_, _, commented)| *commented);
let sel: Range<usize> = self.core.selected_range.into();
let mut cursor = sel.start;
let mut sel_end = sel.end;
for (row, pos, _) in rows.iter().rev() {
if uncomment {
let line_start = self.core.text.line_start_offset(*row);
let line_end = self.core.text.line_end_offset(*row);
let line = self.core.text.slice(line_start..line_end).to_string();
let indent_len = line.len() - line.trim_start().len();
let mut remove_end = *pos + prefix.len();
let has_trailing_space = line
.get(indent_len + prefix.len()..)
.is_some_and(|rest| rest.starts_with(' '));
if has_trailing_space {
remove_end += 1;
}
let removed = remove_end - *pos;
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&(*pos..remove_end))),
"",
window,
cx,
);
cursor = shift_for_edit(cursor, *pos, removed, removed);
sel_end = shift_for_edit(sel_end, *pos, removed, removed);
} else {
let insertion = format!("{prefix} ");
self.replace_text_in_range_silent(
Some(self.range_to_utf16(&(*pos..*pos))),
&insertion,
window,
cx,
);
cursor = shift_for_edit(cursor, *pos, 0, insertion.len());
sel_end = shift_for_edit(sel_end, *pos, 0, insertion.len());
}
}
self.core.selected_range = (cursor..sel_end).into();
self.core.selection_reversed = false;
cx.notify();
}
pub(crate) fn join_lines(
&mut self,
_: &super::JoinLines,
window: &mut Window,
cx: &mut Context<Self>,
) {
if !self.guard_line_op(cx) {
return;
}
self.collapse_for_line_op(cx);
let (mut start_row, mut end_row) = self.target_rows();
let last = self.effective_line_count().saturating_sub(1);
if self.core.selected_range.is_empty() {
end_row = (start_row + 1).min(last);
} else {
end_row = end_row.min(last);
}
start_row = start_row.min(last);
if start_row >= end_row {
return;
}
let mut parts = Vec::new();
for row in start_row..=end_row {
let line = self
.core
.text
.slice(self.core.text.line_start_offset(row)..self.core.text.line_end_offset(row))
.to_string();
let trimmed = line.trim().to_string();
if !trimmed.is_empty() || parts.is_empty() {
parts.push(trimmed);
}
}
let joined = parts.join(" ");
let range =
self.core.text.line_start_offset(start_row)..self.core.text.line_end_offset(end_row);
let sel: Range<usize> = self.core.selected_range.into();
self.replace_text_in_range_silent(Some(self.range_to_utf16(&range)), &joined, window, cx);
let cursor = sel.start.max(range.start).min(range.start + joined.len());
self.core.selected_range = (cursor..cursor).into();
self.core.selection_reversed = false;
cx.notify();
}
fn guard_line_op(&self, cx: &mut Context<Self>) -> bool {
if self.read_only || self.disabled || !self.mode.is_multi_line() {
cx.propagate();
return false;
}
true
}
fn collapse_for_line_op(&mut self, cx: &mut Context<Self>) {
if !self.extra_selections.is_empty() {
self.extra_selections.clear();
cx.notify();
}
}
fn target_rows(&self) -> (usize, usize) {
let sel: Range<usize> = self.core.selected_range.into();
let last = self.effective_line_count().saturating_sub(1);
let start_row = self
.core
.text
.offset_to_point(sel.start.min(self.core.text.len()))
.row
.min(last);
let mut end_row = self
.core
.text
.offset_to_point(sel.end.min(self.core.text.len()))
.row
.min(last);
if !sel.is_empty()
&& sel.end == self.core.text.line_start_offset(end_row)
&& end_row > start_row
{
end_row -= 1;
}
(start_row, end_row)
}
fn effective_line_count(&self) -> usize {
let count = self.core.text.lines_len();
if count > 1 {
let last_start = self.core.text.line_start_offset(count - 1);
if last_start == self.core.text.len() {
return count - 1;
}
}
count
}
pub(crate) fn rows_text(&self, start_row: usize, end_row: usize) -> String {
let mut out = String::new();
for row in start_row..=end_row {
if row > start_row {
out.push('\n');
}
out.push_str(
&self
.core
.text
.slice(
self.core.text.line_start_offset(row)..self.core.text.line_end_offset(row),
)
.to_string(),
);
}
out
}
pub(crate) fn block_range(&self, start_row: usize, end_row: usize) -> Range<usize> {
let line_count = self.core.text.lines_len();
if end_row + 1 < line_count {
self.core.text.line_start_offset(start_row)
..self.core.text.line_start_offset(end_row + 1)
} else if start_row > 0 {
self.core.text.line_end_offset(start_row - 1)..self.core.text.len()
} else {
0..self.core.text.len()
}
}
}
fn shift_for_edit(offset: usize, edit: usize, removed: usize, added: usize) -> usize {
if offset <= edit {
offset
} else if offset >= edit + removed {
offset + added - removed
} else {
edit
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::AppContext as _;
use crate::Entity;
use crate::input_ui::{
CopyLine, DeleteLine, JoinLines, MoveLineDown, MoveLineUp, ToggleLineComment,
};
struct Probe {
state: Entity<InputState>,
}
impl crate::Render for Probe {
fn render(
&mut self,
_window: &mut Window,
_cx: &mut Context<Self>,
) -> impl crate::IntoElement {
crate::div()
}
}
macro_rules! assert_text_cursor {
($state:expr, $cx:expr, $text:expr, $cursor:expr) => {
assert_eq!(
$state.read_with($cx, |state, _| (state.value().to_string(), state.cursor())),
($text.to_string(), $cursor)
);
};
}
#[rgpui::test]
fn copy_line_down_duplicates_current_line(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
state.update(cx, |state, cx| state.replace("a\nb\nc", window, cx));
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|_, cx| {
state.update(cx, |state, cx| state.set_selected_range(2..2, cx));
});
cx.update(|window, cx| {
state.update(cx, |state, cx| state.copy_line(&CopyLine, window, cx));
});
assert_text_cursor!(state, cx, "a\nb\nb\nc", 4);
}
#[rgpui::test]
fn delete_first_line_moves_cursor_to_substitute(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
state.update(cx, |state, cx| state.replace("a\nb\nc", window, cx));
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|_, cx| {
state.update(cx, |state, cx| state.set_selected_range(0..0, cx));
});
cx.update(|window, cx| {
state.update(cx, |state, cx| state.delete_line(&DeleteLine, window, cx));
});
assert_text_cursor!(state, cx, "b\nc", 0);
}
#[rgpui::test]
fn move_line_down_and_up_roundtrip(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
state.update(cx, |state, cx| state.replace("a\nb\nc", window, cx));
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|_, cx| {
state.update(cx, |state, cx| state.set_selected_range(0..0, cx));
});
cx.update(|window, cx| {
state.update(cx, |state, cx| {
state.move_line_down(&MoveLineDown, window, cx)
});
});
assert_text_cursor!(state, cx, "b\na\nc", 2);
cx.update(|window, cx| {
state.update(cx, |state, cx| state.move_line_up(&MoveLineUp, window, cx));
});
assert_text_cursor!(state, cx, "a\nb\nc", 0);
}
#[rgpui::test]
fn move_line_at_boundary_is_noop(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
state.update(cx, |state, cx| state.replace("a\nb\nc", window, cx));
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|_, cx| {
state.update(cx, |state, cx| state.set_selected_range(0..0, cx));
});
cx.update(|window, cx| {
state.update(cx, |state, cx| state.move_line_up(&MoveLineUp, window, cx));
});
assert_text_cursor!(state, cx, "a\nb\nc", 0);
cx.update(|_, cx| {
state.update(cx, |state, cx| state.set_selected_range(4..4, cx));
});
cx.update(|window, cx| {
state.update(cx, |state, cx| {
state.move_line_down(&MoveLineDown, window, cx)
});
});
assert_text_cursor!(state, cx, "a\nb\nc", 4);
}
#[rgpui::test]
fn toggle_comment_roundtrip_skips_blank_lines(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
state.update(cx, |state, cx| {
state.replace("fn main() {\n let x = 1;\n\n}", window, cx)
});
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|_, cx| {
state.update(cx, |state, cx| state.set_selected_range(0..27, cx));
});
cx.update(|window, cx| {
state.update(cx, |state, cx| {
state.toggle_line_comment(&ToggleLineComment, window, cx)
});
});
assert_eq!(
state.read_with(cx, |state, _| state.value().to_string()),
"// fn main() {\n // let x = 1;\n\n}".to_string()
);
cx.update(|_, cx| {
state.update(cx, |state, cx| state.set_selected_range(0..33, cx));
});
cx.update(|window, cx| {
state.update(cx, |state, cx| {
state.toggle_line_comment(&ToggleLineComment, window, cx)
});
});
assert_eq!(
state.read_with(cx, |state, _| state.value().to_string()),
"fn main() {\n let x = 1;\n\n}".to_string()
);
}
#[rgpui::test]
fn join_lines_collapses_whitespace(cx: &mut crate::TestAppContext) {
cx.update(crate::input_ui::init);
cx.update(crate::theme::init);
let (probe, cx) = cx.add_window_view(|window, cx| {
let state = cx.new(|cx| InputState::new(window, cx).multi_line(true));
state.update(cx, |state, cx| {
state.replace("hello\n world\nrust", window, cx)
});
Probe { state }
});
let state = probe.read_with(cx, |probe, _| probe.state.clone());
cx.update(|_, cx| {
state.update(cx, |state, cx| state.set_selected_range(0..0, cx));
});
cx.update(|window, cx| {
state.update(cx, |state, cx| state.join_lines(&JoinLines, window, cx));
});
assert_text_cursor!(state, cx, "hello world\nrust", 0);
}
}