feat: enhance input component and task modal UX

- Text selection & clipboard: Add full text selection support with Ctrl+A/C/X/V operations, visual highlight, and proper cursor positioning
- Multiple annotations: Fix annotation creation to properly append multiple entries instead of overwriting
- Navigation improvements: Improve annotation list navigation to cycle between items and input field
- UI layout fixes: Add flex wrapping to tag displays to handle long lists gracefully
- Focus management: Fix focus retention when switching between edit/view modes in task modal
- Tag suggestions: Prevent auto-acceptance of suggestions on Enter, allowing custom tag input
This commit is contained in:
Ignacio Perez
2026-01-04 19:45:27 -03:00
parent 540627db75
commit 0adcb9e512
8 changed files with 342 additions and 112 deletions
+265 -42
View File
@@ -14,6 +14,7 @@ pub struct Input {
placeholder: gpui::SharedString, placeholder: gpui::SharedString,
cursor_pos: usize, cursor_pos: usize,
selection_anchor: Option<usize>,
multiline: bool, multiline: bool,
suggestions: Vec<Suggestion>, suggestions: Vec<Suggestion>,
@@ -39,6 +40,7 @@ impl Input {
placeholder: placeholder.into(), placeholder: placeholder.into(),
cursor_pos: 0, cursor_pos: 0,
selection_anchor: None,
multiline: false, multiline: false,
suggestions: vec![], suggestions: vec![],
@@ -101,6 +103,11 @@ impl Input {
self.suggestions_open self.suggestions_open
} }
pub fn close_suggestions(&mut self, cx: &mut gpui::Context<Self>) {
self.suggestions_open = false;
cx.notify();
}
pub fn set_value(&mut self, value: impl Into<String>, cx: &mut gpui::Context<Self>) { pub fn set_value(&mut self, value: impl Into<String>, cx: &mut gpui::Context<Self>) {
self.value = value.into(); self.value = value.into();
self.cursor_pos = self.value.len(); self.cursor_pos = self.value.len();
@@ -130,6 +137,67 @@ impl Input {
cx.notify(); cx.notify();
} }
fn has_selection(&self) -> bool {
self.selection_anchor.is_some()
}
fn selection_range(&self) -> Option<std::ops::Range<usize>> {
self.selection_anchor.map(|anchor| {
let start = anchor.min(self.cursor_pos);
let end = anchor.max(self.cursor_pos);
start..end
})
}
fn clear_selection(&mut self) {
self.selection_anchor = None;
}
fn set_selection(&mut self, anchor: usize, cursor: usize) {
self.selection_anchor = Some(anchor);
self.cursor_pos = cursor;
}
fn select_all(&mut self) {
self.selection_anchor = Some(0);
self.cursor_pos = self.value.len();
}
fn delete_selection(&mut self, cx: &mut gpui::Context<Self>) -> bool {
if let Some(range) = self.selection_range() {
self.value.drain(range.clone());
self.cursor_pos = range.start;
self.clear_selection();
if let Some(on_change) = self.on_change.clone() {
on_change(&self.value, cx);
}
self.refresh_suggestions(cx);
cx.notify();
return true;
}
false
}
fn replace_selection(&mut self, text: &str, cx: &mut gpui::Context<Self>) {
let range = self.selection_range();
let insert_pos = range.as_ref().map(|r| r.start).unwrap_or(self.cursor_pos);
let delete_range = range.unwrap_or(self.cursor_pos..self.cursor_pos);
self.value.replace_range(delete_range.clone(), text);
self.cursor_pos = insert_pos + text.len();
self.clear_selection();
if let Some(on_change) = self.on_change.clone() {
on_change(&self.value, cx);
}
self.refresh_suggestions(cx);
cx.notify();
}
fn selected_text(&self) -> Option<&str> {
self.selection_range().map(|range| &self.value[range])
}
fn word_start_before(&self, pos: usize) -> usize { fn word_start_before(&self, pos: usize) -> usize {
if pos == 0 { if pos == 0 {
return 0; return 0;
@@ -167,26 +235,46 @@ impl Input {
i i
} }
fn move_left(&mut self) { fn move_left(&mut self, cx: &mut gpui::Context<Self>, clear_selection: bool) {
if self.cursor_pos > 0 { if self.cursor_pos > 0 {
let mut new_pos = self.cursor_pos - 1; let mut new_pos = self.cursor_pos - 1;
while new_pos > 0 && !self.value.is_char_boundary(new_pos) { while new_pos > 0 && !self.value.is_char_boundary(new_pos) {
new_pos -= 1; new_pos -= 1;
} }
self.cursor_pos = new_pos; self.cursor_pos = new_pos;
if clear_selection {
self.clear_selection();
}
cx.notify();
} }
} }
fn move_right(&mut self) { fn move_right(&mut self, cx: &mut gpui::Context<Self>, clear_selection: bool) {
if self.cursor_pos < self.value.len() { if self.cursor_pos < self.value.len() {
let mut new_pos = self.cursor_pos + 1; let mut new_pos = self.cursor_pos + 1;
while new_pos < self.value.len() && !self.value.is_char_boundary(new_pos) { while new_pos < self.value.len() && !self.value.is_char_boundary(new_pos) {
new_pos += 1; new_pos += 1;
} }
self.cursor_pos = new_pos; self.cursor_pos = new_pos;
if clear_selection {
self.clear_selection();
}
cx.notify();
} }
} }
fn move_word_left(&mut self, cx: &mut gpui::Context<Self>) {
self.cursor_pos = self.word_start_before(self.cursor_pos);
self.clear_selection();
cx.notify();
}
fn move_word_right(&mut self, cx: &mut gpui::Context<Self>) {
self.cursor_pos = self.word_end_after(self.cursor_pos);
self.clear_selection();
cx.notify();
}
fn refresh_suggestions(&mut self, cx: &mut gpui::Context<Self>) { fn refresh_suggestions(&mut self, cx: &mut gpui::Context<Self>) {
if let Some(suggest) = &self.suggest { if let Some(suggest) = &self.suggest {
self.suggestions = suggest(&self.value); self.suggestions = suggest(&self.value);
@@ -271,23 +359,20 @@ impl Input {
} }
fn insert_text(&mut self, text: &str, cx: &mut gpui::Context<Self>) { fn insert_text(&mut self, text: &str, cx: &mut gpui::Context<Self>) {
self.value.insert_str(self.cursor_pos, text); self.replace_selection(text, cx);
self.cursor_pos += text.len();
if let Some(on_change) = self.on_change.clone() {
on_change(&self.value, cx);
}
self.refresh_suggestions(cx);
cx.notify();
} }
fn delete_backward(&mut self, cx: &mut gpui::Context<Self>) { fn delete_backward(&mut self, cx: &mut gpui::Context<Self>) {
if self.has_selection() {
self.delete_selection(cx);
return;
}
if self.cursor_pos == 0 { if self.cursor_pos == 0 {
return; return;
} }
let old_pos = self.cursor_pos; let old_pos = self.cursor_pos;
self.move_left(); self.move_left(cx, false);
self.value.drain(self.cursor_pos..old_pos); self.value.drain(self.cursor_pos..old_pos);
if let Some(on_change) = self.on_change.clone() { if let Some(on_change) = self.on_change.clone() {
@@ -298,6 +383,10 @@ impl Input {
} }
fn delete_forward(&mut self, cx: &mut gpui::Context<Self>) { fn delete_forward(&mut self, cx: &mut gpui::Context<Self>) {
if self.has_selection() {
self.delete_selection(cx);
return;
}
if self.cursor_pos >= self.value.len() { if self.cursor_pos >= self.value.len() {
return; return;
} }
@@ -316,6 +405,10 @@ impl Input {
} }
fn delete_word_backward(&mut self, cx: &mut gpui::Context<Self>) { fn delete_word_backward(&mut self, cx: &mut gpui::Context<Self>) {
if self.has_selection() {
self.delete_selection(cx);
return;
}
if self.cursor_pos == 0 { if self.cursor_pos == 0 {
return; return;
} }
@@ -332,6 +425,10 @@ impl Input {
} }
fn delete_word_forward(&mut self, cx: &mut gpui::Context<Self>) { fn delete_word_forward(&mut self, cx: &mut gpui::Context<Self>) {
if self.has_selection() {
self.delete_selection(cx);
return;
}
if self.cursor_pos >= self.value.len() { if self.cursor_pos >= self.value.len() {
return; return;
} }
@@ -381,6 +478,43 @@ impl Input {
return; return;
} }
if ctrl {
match key {
"a" => {
self.select_all();
cx.notify();
return;
}
"c" => {
if let Some(text) = self.selected_text() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.to_string()));
}
return;
}
"x" => {
if let Some(text) = self.selected_text() {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.to_string()));
self.delete_selection(cx);
}
return;
}
"v" => {
if let Some(item) = cx.read_from_clipboard() {
if let Some(text) = item.text() {
let sanitized = if !self.multiline {
text.replace('\n', " ").replace("\r\n", " ")
} else {
text.replace("\r\n", "\n")
};
self.replace_selection(&sanitized, cx);
}
}
return;
}
_ => {}
}
}
match key { match key {
"enter" => { "enter" => {
// For multiline, Enter inserts newline (modal handles exit via commands) // For multiline, Enter inserts newline (modal handles exit via commands)
@@ -433,32 +567,59 @@ impl Input {
"left" => { "left" => {
if ctrl { if ctrl {
self.cursor_pos = self.word_start_before(self.cursor_pos); self.move_word_left(cx);
cx.notify(); } else if shift {
if self.selection_anchor.is_none() {
self.selection_anchor = Some(self.cursor_pos);
}
self.move_left(cx, false);
} else { } else {
self.move_left(); self.move_left(cx, true);
cx.notify();
} }
} }
"right" => { "right" => {
if ctrl { if ctrl {
self.cursor_pos = self.word_end_after(self.cursor_pos); self.move_word_right(cx);
cx.notify(); } else if shift {
if self.selection_anchor.is_none() {
self.selection_anchor = Some(self.cursor_pos);
}
self.move_right(cx, false);
} else { } else {
self.move_right(); self.move_right(cx, true);
cx.notify();
} }
} }
"home" => { "home" => {
self.cursor_pos = 0; if shift && self.selection_anchor.is_none() {
cx.notify(); self.selection_anchor = Some(self.cursor_pos);
self.cursor_pos = 0;
cx.notify();
} else if shift {
self.cursor_pos = 0;
cx.notify();
} else {
self.cursor_pos = 0;
self.clear_selection();
cx.notify();
}
} }
"end" => { "end" => {
self.cursor_pos = self.value.len(); let len = self.value.len();
cx.notify(); if shift && self.selection_anchor.is_none() {
self.selection_anchor = Some(self.cursor_pos);
self.cursor_pos = len;
cx.notify();
} else if shift {
self.cursor_pos = len;
cx.notify();
} else {
self.cursor_pos = len;
self.clear_selection();
cx.notify();
}
} }
"backspace" => { "backspace" => {
@@ -481,11 +642,6 @@ impl Input {
self.delete_word_backward(cx); self.delete_word_backward(cx);
} }
"a" if ctrl => {
self.cursor_pos = 0;
cx.notify();
}
"e" if ctrl => { "e" if ctrl => {
self.cursor_pos = self.value.len(); self.cursor_pos = self.value.len();
cx.notify(); cx.notify();
@@ -729,22 +885,89 @@ impl gpui::Render for Input {
gpui::div().id(self.id.clone()).into_any_element() gpui::div().id(self.id.clone()).into_any_element()
}; };
let content = if self.has_selection() {
let range = self.selection_range().unwrap();
let cursor_at_end = self.cursor_pos >= range.end;
let before_sel = &self.value[..range.start];
let selected_text = &self.value[range.clone()];
let after_sel = &self.value[range.end..];
let cursor = if is_focused {
gpui::div().w_px().h_4().bg(theme.accent).into_any_element()
} else {
gpui::div().into_any_element()
};
if cursor_at_end {
gpui::div()
.flex()
.flex_row()
.items_center()
.child(
gpui::div()
.text_color(theme.foreground)
.child(before_sel.to_string()),
)
.child(
gpui::div()
.bg(theme.selection)
.text_color(theme.selection_foreground)
.child(selected_text.to_string()),
)
.child(cursor)
.child(
gpui::div()
.text_color(theme.foreground)
.child(after_sel.to_string()),
)
.into_any_element()
} else {
gpui::div()
.flex()
.flex_row()
.items_center()
.child(
gpui::div()
.text_color(theme.foreground)
.child(before_sel.to_string()),
)
.child(cursor)
.child(
gpui::div()
.bg(theme.selection)
.text_color(theme.selection_foreground)
.child(selected_text.to_string()),
)
.child(
gpui::div()
.text_color(theme.foreground)
.child(after_sel.to_string()),
)
.into_any_element()
}
} else {
gpui::div()
.flex()
.flex_row()
.items_center()
.child(
gpui::div()
.text_color(theme.foreground)
.child(before.to_string()),
)
.child(cursor)
.child(
gpui::div()
.text_color(theme.foreground)
.child(after.to_string()),
)
.into_any_element()
};
gpui::div() gpui::div()
.id(self.id.clone()) .id(self.id.clone())
.flex() .child(content)
.flex_row()
.items_center()
.child(
gpui::div()
.text_color(theme.foreground)
.child(before.to_string()),
)
.child(cursor)
.child(
gpui::div()
.text_color(theme.foreground)
.child(after.to_string()),
)
.into_any_element() .into_any_element()
}; };
+43 -46
View File
@@ -401,14 +401,18 @@ impl App {
annotations_add, annotations_add,
annotations_delete, annotations_delete,
} = update; } = update;
let has_description = description.is_some();
let has_project = project.is_some();
let has_priority = priority.is_some();
let has_due = due.is_some();
let has_tags = tags.is_some();
let has_status = status.is_some();
let has_annotations = !annotations_add.is_empty() || !annotations_delete.is_empty();
let mut latest_task: Option<task::Task> = None; let mut latest_task: Option<task::Task> = None;
if description.is_some() if has_description || has_project || has_priority || has_tags || has_due {
|| project.is_some()
|| priority.is_some()
|| tags.is_some()
|| due.is_some()
{
match self.task_service.update_task( match self.task_service.update_task(
task_id, task_id,
description, description,
@@ -433,39 +437,22 @@ impl App {
} }
} }
if let Some(status) = status { if has_status {
let status_result = match status { match status {
task::TaskStatus::Completed => self.task_service.complete_task(task_id), Some(task::TaskStatus::Completed) => {
task::TaskStatus::Pending => self.task_service.reopen_task(task_id), latest_task = self.task_service.complete_task(task_id).ok()
task::TaskStatus::Deleted => {
self.task_service.delete_task(task_id).and_then(|_| {
self.task_service
.get_task(task_id)
.and_then(|task| task.ok_or(task::TaskError::NotFound(task_id)))
})
} }
_ => self Some(task::TaskStatus::Pending) => {
.task_service latest_task = self.task_service.reopen_task(task_id).ok()
.get_task(task_id)
.and_then(|task| task.ok_or(task::TaskError::NotFound(task_id))),
};
match status_result {
Ok(task) => latest_task = Some(task),
Err(e) => {
log::error!("[App] Failed to update status: {}", e);
self.toast_host.update(cx, |host, cx| {
host.push(
ToastKind::Error,
format!("Failed to update status: {}", e),
cx,
);
});
if let Some(task) = latest_task {
self.apply_task_update(task, cx);
}
return;
} }
Some(task::TaskStatus::Deleted) => {
let _ = self.task_service.delete_task(task_id);
latest_task = self.task_service.get_task(task_id).ok().flatten();
}
_ => {
latest_task = self.task_service.get_task(task_id).ok().flatten();
}
None => {}
} }
} }
@@ -481,10 +468,6 @@ impl App {
cx, cx,
); );
}); });
if let Some(task) = latest_task {
self.sync_task_detail(task, cx);
}
return;
} }
} }
} }
@@ -501,15 +484,29 @@ impl App {
cx, cx,
); );
}); });
if let Some(task) = latest_task {
self.sync_task_detail(task, cx);
}
return;
} }
} }
} }
if let Some(task) = latest_task { let task_to_sync = if latest_task.is_some() {
latest_task.take()
} else if has_description
|| has_project
|| has_priority
|| has_tags
|| has_due
|| has_status
|| has_annotations
{
self.task_service.get_task(task_id).ok().flatten()
} else {
self.task_detail_modal.update(cx, |modal, cx| {
modal.cancel_edit(None, cx);
});
return;
};
if let Some(task) = task_to_sync {
self.apply_task_update(task, cx); self.apply_task_update(task, cx);
} else { } else {
self.task_detail_modal.update(cx, |modal, cx| { self.task_detail_modal.update(cx, |modal, cx| {
+6 -4
View File
@@ -610,10 +610,12 @@ impl TaskService {
.map_err(|e| TaskError::Storage(e.to_string()))? .map_err(|e| TaskError::Storage(e.to_string()))?
.ok_or(TaskError::NotFound(uuid))?; .ok_or(TaskError::NotFound(uuid))?;
let annotation = taskchampion::Annotation { let mut entry = Utc::now();
entry: Utc::now(), while tc_task.get_annotations().any(|a| a.entry == entry) {
description, entry = entry + chrono::Duration::nanoseconds(1);
}; }
let annotation = taskchampion::Annotation { entry, description };
tc_task tc_task
.add_annotation(annotation, &mut ops) .add_annotation(annotation, &mut ops)
+4 -6
View File
@@ -41,11 +41,10 @@ pub(super) enum AnnotationOrigin {
}, },
} }
fn annotation_id(entry: DateTime<Utc>, text: &str, index: usize) -> AnnotationId { fn annotation_id(entry: DateTime<Utc>, text: &str) -> AnnotationId {
let mut hasher = DefaultHasher::new(); let mut hasher = DefaultHasher::new();
entry.timestamp_millis().hash(&mut hasher); entry.timestamp_millis().hash(&mut hasher);
text.hash(&mut hasher); text.hash(&mut hasher);
index.hash(&mut hasher);
hasher.finish() hasher.finish()
} }
@@ -54,9 +53,8 @@ impl AnnotationState {
let items: Vec<AnnotationView> = detail let items: Vec<AnnotationView> = detail
.annotations .annotations
.iter() .iter()
.enumerate() .map(|annotation| AnnotationView {
.map(|(index, annotation)| AnnotationView { id: annotation_id(annotation.entry, &annotation.content),
id: annotation_id(annotation.entry, &annotation.content, index),
created_at: annotation.entry, created_at: annotation.entry,
text: annotation.content.clone().into(), text: annotation.content.clone().into(),
origin: AnnotationOrigin::Original, origin: AnnotationOrigin::Original,
@@ -78,7 +76,7 @@ impl AnnotationState {
} }
pub(super) fn add_local(&mut self, text: SharedString, created_at: DateTime<Utc>) { pub(super) fn add_local(&mut self, text: SharedString, created_at: DateTime<Utc>) {
let id = annotation_id(created_at, text.as_ref(), self.items.len()); let id = annotation_id(created_at, text.as_ref());
self.items.push(AnnotationView { self.items.push(AnnotationView {
id, id,
created_at, created_at,
+6 -11
View File
@@ -877,6 +877,8 @@ impl TaskDetailModal {
self.entities.annotation_input.update(cx, |input, cx| { self.entities.annotation_input.update(cx, |input, cx| {
input.clear(cx); input.clear(cx);
}); });
self.state.annotation_selected = None;
cx.notify(); cx.notify();
} }
@@ -1334,6 +1336,10 @@ impl TaskDetailModal {
match self.state.modal_focus { match self.state.modal_focus {
ModalFocus::TagsInput => { ModalFocus::TagsInput => {
self.entities.tags_input.update(cx, |input, cx| {
input.close_suggestions(cx);
});
self.sync_form_from_inputs(cx); self.sync_form_from_inputs(cx);
if let Some(InlineEditTarget::Tag(i)) = self.state.inline_edit { if let Some(InlineEditTarget::Tag(i)) = self.state.inline_edit {
@@ -1581,7 +1587,6 @@ impl TaskDetailModal {
ConfirmAction::DeleteTag { index, .. } => { ConfirmAction::DeleteTag { index, .. } => {
if index < self.state.form.tags.len() { if index < self.state.form.tags.len() {
self.state.form.tags.remove(index); self.state.form.tags.remove(index);
// Adjust selection
if self.state.form.tags.is_empty() { if self.state.form.tags.is_empty() {
self.state.tag_selected = None; self.state.tag_selected = None;
} else if let Some(sel) = self.state.tag_selected { } else if let Some(sel) = self.state.tag_selected {
@@ -1710,16 +1715,6 @@ impl TaskDetailModal {
match ann.origin { match ann.origin {
AnnotationOrigin::Added => { AnnotationOrigin::Added => {
self.state.inline_edit =
Some(InlineEditTarget::Annotation(actual_index));
let ann_value = ann.text.to_string();
self.entities.annotation_input.update(cx, |input, cx| {
input.set_value(ann_value, cx);
});
self.enter_edit_field(window, cx);
return CommandResult::Handled; return CommandResult::Handled;
} }
AnnotationOrigin::Original => { AnnotationOrigin::Original => {
+1
View File
@@ -61,6 +61,7 @@ pub(super) fn render_task_detail_modal(
tags_input, tags_input,
status_dropdown, status_dropdown,
priority_dropdown, priority_dropdown,
focus_handle,
form_focus_handle, form_focus_handle,
scroll_handle, scroll_handle,
&theme, &theme,
+16 -2
View File
@@ -120,6 +120,7 @@ pub(super) fn render_task_detail_panel<OnCloseClick>(
tags_input: &gpui::Entity<Input>, tags_input: &gpui::Entity<Input>,
status_dropdown: &gpui::Entity<Dropdown>, status_dropdown: &gpui::Entity<Dropdown>,
priority_dropdown: &gpui::Entity<Dropdown>, priority_dropdown: &gpui::Entity<Dropdown>,
focus_handle: &gpui::FocusHandle,
form_focus_handle: &gpui::FocusHandle, form_focus_handle: &gpui::FocusHandle,
scroll_handle: &gpui::ScrollHandle, scroll_handle: &gpui::ScrollHandle,
theme: &Theme, theme: &Theme,
@@ -300,7 +301,11 @@ where
.min_h_0() .min_h_0()
.overflow_y_scroll() .overflow_y_scroll()
.track_scroll(scroll_handle) .track_scroll(scroll_handle)
.track_focus(form_focus_handle) .track_focus(if mode == ModalMode::View {
focus_handle
} else {
form_focus_handle
})
.px(gpui::rems(1.0)) .px(gpui::rems(1.0))
.py(gpui::rems(0.75)) .py(gpui::rems(0.75))
.gap_4() .gap_4()
@@ -678,6 +683,7 @@ fn render_tags_section(
gpui::div() gpui::div()
.flex() .flex()
.flex_wrap()
.items_center() .items_center()
.gap_2() .gap_2()
.children(chips) .children(chips)
@@ -694,6 +700,7 @@ fn render_tags_section(
gpui::div() gpui::div()
.flex() .flex()
.flex_wrap()
.gap_2() .gap_2()
.children(chips) .children(chips)
.into_any_element() .into_any_element()
@@ -711,7 +718,14 @@ fn render_tags_section(
.custom(Theme::alpha(theme.muted, 0.2), theme.muted) .custom(Theme::alpha(theme.muted, 0.2), theme.muted)
.into_any_element() .into_any_element()
}); });
div.child(gpui::div().flex().gap_2().children(vchips).text_sm()) div.child(
gpui::div()
.flex()
.flex_wrap()
.gap_2()
.children(vchips)
.text_sm(),
)
}), }),
) )
} }
+1 -1
View File
@@ -108,7 +108,7 @@ impl Default for SortState {
fn default() -> Self { fn default() -> Self {
Self { Self {
column: SortColumn::Priority, column: SortColumn::Priority,
direction: SortDirection::Desc, direction: SortDirection::Asc,
} }
} }
} }