From b22a6d47e399b30392f34b857f930ae9f967d903 Mon Sep 17 00:00:00 2001 From: alanpoon Date: Thu, 19 Mar 2026 18:02:39 +0800 Subject: [PATCH 1/5] fix mentionable_input --- src/room/room_input_bar.rs | 29 ++-- src/shared/command_text_input.rs | 8 +- src/shared/mentionable_text_input.rs | 229 +++++++++++++++++++++++---- 3 files changed, 216 insertions(+), 50 deletions(-) diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index a9d23e3e7..7fab79ae1 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -98,20 +98,23 @@ script_mod! { margin: Inset{bottom: 9, left: 6, right: 0} } - mentionable_text_input := MentionableTextInput { - width: Fill, - height: Fit - margin: Inset{ top: 5, bottom: 12, left: 1, right: 1 }, - - persistent := RoundedView { - center := RoundedView { - text_input := RobrixTextInput { - empty_text: "Write a message (in Markdown) ..." - } - } - } + //mentionable_text_input := + MentionableTextInput { + // width: Fill, + // height: Fit + // margin: Inset{ top: 5, bottom: 12, left: 1, right: 1 }, + + // persistent := RoundedView { + // center := RoundedView { + // text_input := TextInput { + // empty_text: "Write a message (in Markdown) ..." + // } + // } + // } } - + // CommandTextInput { + + // } send_message_button := RobrixIconButton { // Disabled by default; enabled when text is inputted enabled: false, diff --git a/src/shared/command_text_input.rs b/src/shared/command_text_input.rs index eb4c59df5..46ce11f8c 100644 --- a/src/shared/command_text_input.rs +++ b/src/shared/command_text_input.rs @@ -223,6 +223,7 @@ impl Widget for CommandTextInput { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { + self.update_highlights(cx); self.ensure_popup_consistent(cx); @@ -237,7 +238,6 @@ impl Widget for CommandTextInput { self.is_text_input_focus_pending = false; self.text_input_ref().set_key_focus(cx); } - DrawStep::done() } @@ -696,12 +696,12 @@ impl CommandTextInput { /// Returns a reference to the inner `TextInput` widget. pub fn text_input_ref(&self) -> TextInputRef { - self.child(id!(text_input)).as_text_input() + self.child(id!(persistent)).child(id!(center)).child(id!(text_input)).as_text_input().as_text_input() } /// Returns a reference to the inner `TextInput` widget used for search. pub fn search_input_ref(&self) -> TextInputRef { - self.child(id!(search_input)).as_text_input() + self.child(id!(persistent)).child(id!(center)).child(id!(text_input)).as_text_input().as_text_input() } fn trigger_grapheme(&self) -> Option<&str> { @@ -752,7 +752,7 @@ impl CommandTextInput { let mut item = item.clone(); script_apply_eval!(cx, item, { show_bg: true, - cursor: MouseCursor.Hand + // cursor: MouseCursor.Hand }); // If there is a keyboard focus, prioritize it over mouse hover diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 129157779..ecdae25c0 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -24,11 +24,6 @@ const MOBILE_ITEM_HEIGHT: f64 = 64.0; script_mod! { use mod.prelude.widgets.* use mod.widgets.* - - mod.widgets.FOCUS_HOVER_COLOR = #C - - mod.widgets.KEYBOARD_FOCUS_OR_COLOR_HOVER = #1C274C - // Template for user list items in the mention dropdown mod.widgets.UserListItem = RoundedView { width: Fill, @@ -38,27 +33,39 @@ script_mod! { show_bg: true cursor: MouseCursor.Hand draw_bg +: { - color: instance(COLOR_PRIMARY), + color: instance(#ffffff), border_radius: uniform(4.0), hover: instance(0.0), selected: instance(0.0), pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size); - // Draw rounded rectangle with configurable radius sdf.box(0., 0., self.rect_size.x, self.rect_size.y, self.border_radius); + // Light blue hover color (#DBEAFE) + let hover_color = vec4(0.859, 0.918, 0.996, 1.0); if self.selected > 0.0 { - sdf.fill(KEYBOARD_FOCUS_OR_COLOR_HOVER) + sdf.fill(hover_color) } else if self.hover > 0.0 { - sdf.fill(KEYBOARD_FOCUS_OR_COLOR_HOVER) + sdf.fill(hover_color) } else { - // Default state sdf.fill(self.color) } return sdf.result } } + animator: Animator { + hover: { + default: off + off: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 0.0 }}} + on: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 1.0 }}} + } + selected: { + default: off + off: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 0.0 }}} + on: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 1.0 }}} + } + } flow: Down spacing: 2.0 @@ -110,7 +117,7 @@ script_mod! { show_bg: true cursor: MouseCursor.Hand draw_bg +: { - color: instance(COLOR_PRIMARY), + color: instance(#ffffff), border_radius: uniform(4.0), hover: instance(0.0), selected: instance(0.0), @@ -118,17 +125,31 @@ script_mod! { pixel: fn() { let sdf = Sdf2d.viewport(self.pos * self.rect_size); sdf.box(0., 0., self.rect_size.x, self.rect_size.y, self.border_radius); + // Light blue hover color (#DBEAFE) + let hover_color = vec4(0.859, 0.918, 0.996, 1.0); if self.selected > 0.0 { - sdf.fill(KEYBOARD_FOCUS_OR_COLOR_HOVER) + sdf.fill(hover_color) } else if self.hover > 0.0 { - sdf.fill(KEYBOARD_FOCUS_OR_COLOR_HOVER) + sdf.fill(hover_color) } else { sdf.fill(self.color) } return sdf.result } } + animator: Animator { + hover: { + default: off + off: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 0.0 }}} + on: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 1.0 }}} + } + selected: { + default: off + off: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 0.0 }}} + on: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 1.0 }}} + } + } flow: Down spacing: 2.0 align: Align{y: 0.5} @@ -226,27 +247,166 @@ script_mod! { } } - mod.widgets.MentionableTextInput = #(MentionableTextInput::register_widget(vm)) { + // Step 1: Register the base widget type + mod.widgets.MentionableTextInputBase = #(MentionableTextInput::register_widget(vm)) - width: Fill, + // Step 2: Define the full widget inheriting from CommandTextInput's DSL structure + mod.widgets.MentionableTextInput = mod.widgets.MentionableTextInputBase { + //..mod.widgets.CommandTextInput + flow: Down height: Fit trigger: "@" inline_search: true - color_focus: (mod.widgets.FOCUS_HOVER_COLOR), - color_hover: (mod.widgets.FOCUS_HOVER_COLOR), - + // Light blue colors for hover/focus highlighting (#DBEAFE) + color_focus: #DBEAFE + color_hover: #DBEAFE + + // popup := RoundedView { + // flow: Down + // height: Fit + // visible: false + + // draw_bg +: { + // color: instance(theme.color_fg_app) + // border_size: uniform(theme.beveling) + // border_color: instance(theme.color_bevel) + // border_radius: uniform(theme.corner_radius) + + // pixel: fn() { + // let sdf = Sdf2d.viewport(self.pos * self.rect_size) + + // // External outline (entire component including border) + // sdf.box_all( + // 0.0 + // 0.0 + // self.rect_size.x + // self.rect_size.y + // self.border_radius + // self.border_radius + // self.border_radius + // self.border_radius + // ) + // sdf.fill(self.border_color) // Fill the entire area with border color + + // // Internal outline (content area) + // sdf.box_all( + // self.border_size + // self.border_size + // self.rect_size.x - self.border_size * 2.0 + // self.rect_size.y - self.border_size * 2.0 + // self.border_radius - self.border_size + // self.border_radius - self.border_size + // self.border_radius - self.border_size + // self.border_radius - self.border_size + // ) + // sdf.fill(self.color) // Fill content area with background color + + // return sdf.result + // } + // } + + // header_view := View{ + // visible: true + // width: Fill + // height: Fit + // padding: Inset{left: 12., right: 12., top: 12., bottom: 12.} + // show_bg: true + // draw_bg +: { + // color: instance(theme.color_fg_app) + // top_radius: uniform(theme.corner_radius) + // border_color: instance(theme.color_bevel) + // border_width: uniform(theme.beveling) + // pixel: fn() { + // let sdf = Sdf2d.viewport(self.pos * self.rect_size) + // sdf.box_all( + // 0.0 + // 0.0 + // self.rect_size.x + // self.rect_size.y + // self.top_radius + // self.top_radius + // 1.0 + // 1.0 + // ) + // sdf.fill(self.color) + // return sdf.result + // } + // } + + // header_label := Label{ + // draw_text +: { + // color: theme.color_label_inner + // text_style: theme.font_regular{ + // font_size: theme.font_size_4 + // } + // } + // } + // } + + // // Wrapper workaround to hide search input when inline search is enabled + // // as we currently can't hide the search input avoiding events. + // search_input_wrapper := RoundedView{ + // height: Fit + // search_input := TextInput{ + // width: Fill + // height: Fit + // } + // } + + // list := mod.widgets.CommandTextInputList{ + // height: Fit + // } + // } popup := RoundedView { - spacing: 0.0 - padding: 0.0 + flow: Down + height: Fit + visible: false - draw_bg.color: (COLOR_SECONDARY) + draw_bg +: { + color: instance(#ffffff) + border_size: uniform(1.0) + border_color: instance(#cccccc) + border_radius: uniform(4.0) + + pixel: fn() { + let sdf = Sdf2d.viewport(self.pos * self.rect_size) + sdf.box_all( + 0.0, 0.0, + self.rect_size.x, self.rect_size.y, + self.border_radius, self.border_radius, + self.border_radius, self.border_radius + ) + sdf.fill(self.border_color) + sdf.box_all( + self.border_size, self.border_size, + self.rect_size.x - self.border_size * 2.0, + self.rect_size.y - self.border_size * 2.0, + self.border_radius - self.border_size, + self.border_radius - self.border_size, + self.border_radius - self.border_size, + self.border_radius - self.border_size + ) + sdf.fill(self.color) + return sdf.result + } + } + + header_view := View { + visible: true + width: Fill + height: Fit + padding: Inset{left: 8, right: 8, top: 8, bottom: 4} + show_bg: true + draw_bg +: { + color: instance(#ffffff) + } - header_view := SolidView { - margin: Inset{left: 4, right: 4} - draw_bg.color: (COLOR_ROBRIX_PURPLE) header_label := Label { - draw_text.color: (COLOR_PRIMARY_DARKER), + draw_text +: { + color: #333333 + text_style: theme.font_regular { font_size: 12.0 } + } text: "Users in this Room" } } @@ -258,12 +418,17 @@ script_mod! { padding: 0.0 } } - - persistent := RoundedView { + persistent := RoundedView{ + flow: Down + height: Fit top := View { height: 0 } bottom := View { height: 0 } center := RoundedView { + height: Fit + left := View{ width: Fit, height: Fit } + right := View{ width: Fit, height: Fit } text_input := RobrixTextInput { + width: Fill empty_text: "Start typing..." } } @@ -530,12 +695,12 @@ impl MentionableTextInput { if is_desktop { script_apply_eval!(cx, room_mention_item, { height: #(new_height), - flow: Flow.Right, + flow: #(Flow::Right{ row_align: turtle::RowAlign::Top, wrap: false }), }); } else { script_apply_eval!(cx, room_mention_item, { height: #(new_height), - flow: Flow.Down, + flow: #(Flow::Down), }); } @@ -613,17 +778,15 @@ impl MentionableTextInput { if is_desktop { let mut item_ref = item.clone(); script_apply_eval!(cx, item_ref, { - flow: Flow.Right, + flow: #(Flow::Right{wrap: false, row_align: turtle::RowAlign::Top}), height: #(DESKTOP_ITEM_HEIGHT), - align: Align{y: 0.5} }); item.view(cx, ids!(user_info.filler)).set_visible(cx, true); } else { let mut item_ref = item.clone(); script_apply_eval!(cx, item_ref, { - flow: Flow.Down, + flow: #(Flow::Down), height: #(MOBILE_ITEM_HEIGHT), - spacing: 2.0 }); item.view(cx, ids!(user_info.filler)).set_visible(cx, false); } From 8c28ab6593b3213cd4eacac10bd397a9a0adb889 Mon Sep 17 00:00:00 2001 From: alanpoon Date: Thu, 19 Mar 2026 21:59:53 +0800 Subject: [PATCH 2/5] roundedView --- src/shared/command_text_input.rs | 65 +++++++++++++++- src/shared/mentionable_text_input.rs | 109 ++------------------------- 2 files changed, 69 insertions(+), 105 deletions(-) diff --git a/src/shared/command_text_input.rs b/src/shared/command_text_input.rs index 46ce11f8c..35acc5fd3 100644 --- a/src/shared/command_text_input.rs +++ b/src/shared/command_text_input.rs @@ -144,13 +144,17 @@ enum InternalAction { /// trigger character is typed. /// /// Limitation: Selectable items are expected to be `View`s. -#[derive(Script, ScriptHook, Widget)] +#[derive(Script, Widget)] pub struct CommandTextInput { #[source] source: ScriptObjectRef, #[deref] deref: View, + /// DrawList for rendering popup in overlay + #[rust] + draw_list: Option, + /// The character that triggers the popup. /// /// If not set, popup can't be triggered by keyboard. @@ -210,6 +214,12 @@ pub struct CommandTextInput { prev_cursor_position: usize, } +impl ScriptHook for CommandTextInput { + fn on_after_new(&mut self, vm: &mut ScriptVm) { + self.draw_list = Some(DrawList2d::script_new(vm)); + } +} + impl Widget for CommandTextInput { fn set_text(&mut self, cx: &mut Cx, v: &str) { self.text_input_ref().set_text(cx, v); @@ -223,12 +233,54 @@ impl Widget for CommandTextInput { } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - self.update_highlights(cx); self.ensure_popup_consistent(cx); + // Get popup visibility state and temporarily hide it for main draw + let popup_was_visible = self.view(cx, ids!(popup)).visible(); + self.view(cx, ids!(popup)).set_visible(cx, false); + + // Draw the main view (persistent content only) while !self.deref.draw_walk(cx, scope, walk).is_done() {} + // Restore popup visibility + self.view(cx, ids!(popup)).set_visible(cx, popup_was_visible); + + // Draw popup in overlay if visible + if popup_was_visible { + // Get popup ref and text input rect before borrowing draw_list + let popup_ref = self.view(cx, ids!(popup)); + let text_input_rect = self.text_input_ref().area().rect(cx); + + if let Some(draw_list) = &mut self.draw_list { + draw_list.begin_overlay_reuse(cx); + + let size = cx.current_pass_size(); + cx.begin_root_turtle(size, Layout::flow_overlay()); + + // Calculate max popup height based on available space above text input + let margin = 10.0; + let max_popup_height = size.y - text_input_rect.size.y - margin; + + // Position popup above the text input + let popup_x = text_input_rect.pos.x; + let popup_y = margin; + + // Draw popup with constrained size + let popup_walk = Walk { + abs_pos: Some(dvec2(popup_x, popup_y)), + width: Size::Fixed(text_input_rect.size.x.max(300.0)), + height: Size::Fixed(max_popup_height), + ..Walk::default() + }; + + let _ = popup_ref.draw_walk(cx, scope, popup_walk); + + cx.end_pass_sized_turtle(); + draw_list.end(cx); + } + } + if self.is_search_input_focus_pending { self.is_search_input_focus_pending = false; self.search_input_ref().set_key_focus(cx); @@ -495,11 +547,20 @@ impl CommandTextInput { } self.view(cx, ids!(popup)).set_visible(cx, true); self.view(cx, ids!(popup)).redraw(cx); + self.redraw_overlay(cx); } fn hide_popup(&mut self, cx: &mut Cx) { self.clear_popup(cx); self.view(cx, ids!(popup)).set_visible(cx, false); + self.redraw_overlay(cx); + } + + /// Redraws the overlay containing the popup. + fn redraw_overlay(&self, cx: &mut Cx) { + if let Some(draw_list) = &self.draw_list { + draw_list.redraw(cx); + } } /// Clear all text and hide the popup going back to initial state. diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index ecdae25c0..7cefffa45 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -262,106 +262,12 @@ script_mod! { color_focus: #DBEAFE color_hover: #DBEAFE - // popup := RoundedView { - // flow: Down - // height: Fit - // visible: false - - // draw_bg +: { - // color: instance(theme.color_fg_app) - // border_size: uniform(theme.beveling) - // border_color: instance(theme.color_bevel) - // border_radius: uniform(theme.corner_radius) - - // pixel: fn() { - // let sdf = Sdf2d.viewport(self.pos * self.rect_size) - - // // External outline (entire component including border) - // sdf.box_all( - // 0.0 - // 0.0 - // self.rect_size.x - // self.rect_size.y - // self.border_radius - // self.border_radius - // self.border_radius - // self.border_radius - // ) - // sdf.fill(self.border_color) // Fill the entire area with border color - - // // Internal outline (content area) - // sdf.box_all( - // self.border_size - // self.border_size - // self.rect_size.x - self.border_size * 2.0 - // self.rect_size.y - self.border_size * 2.0 - // self.border_radius - self.border_size - // self.border_radius - self.border_size - // self.border_radius - self.border_size - // self.border_radius - self.border_size - // ) - // sdf.fill(self.color) // Fill content area with background color - - // return sdf.result - // } - // } - - // header_view := View{ - // visible: true - // width: Fill - // height: Fit - // padding: Inset{left: 12., right: 12., top: 12., bottom: 12.} - // show_bg: true - // draw_bg +: { - // color: instance(theme.color_fg_app) - // top_radius: uniform(theme.corner_radius) - // border_color: instance(theme.color_bevel) - // border_width: uniform(theme.beveling) - // pixel: fn() { - // let sdf = Sdf2d.viewport(self.pos * self.rect_size) - // sdf.box_all( - // 0.0 - // 0.0 - // self.rect_size.x - // self.rect_size.y - // self.top_radius - // self.top_radius - // 1.0 - // 1.0 - // ) - // sdf.fill(self.color) - // return sdf.result - // } - // } - - // header_label := Label{ - // draw_text +: { - // color: theme.color_label_inner - // text_style: theme.font_regular{ - // font_size: theme.font_size_4 - // } - // } - // } - // } - - // // Wrapper workaround to hide search input when inline search is enabled - // // as we currently can't hide the search input avoiding events. - // search_input_wrapper := RoundedView{ - // height: Fit - // search_input := TextInput{ - // width: Fill - // height: Fit - // } - // } - - // list := mod.widgets.CommandTextInputList{ - // height: Fit - // } - // } popup := RoundedView { + width: Fill flow: Down height: Fit visible: false + show_bg: true draw_bg +: { color: instance(#ffffff) @@ -391,20 +297,17 @@ script_mod! { return sdf.result } } - - header_view := View { + + header_view := RoundedView { visible: true width: Fill height: Fit padding: Inset{left: 8, right: 8, top: 8, bottom: 4} - show_bg: true - draw_bg +: { - color: instance(#ffffff) - } + draw_bg.color: (COLOR_ROBRIX_PURPLE) header_label := Label { draw_text +: { - color: #333333 + color: #ffffff text_style: theme.font_regular { font_size: 12.0 } } text: "Users in this Room" From 8b57d68f819e4e5b9fbf2d4146d9e4ce6eccc20d Mon Sep 17 00:00:00 2001 From: alanpoon Date: Thu, 2 Apr 2026 11:30:38 +0800 Subject: [PATCH 3/5] input_2 --- src/shared/mentionable_text_input.rs | 379 ++++++++++++++++++++++----- 1 file changed, 313 insertions(+), 66 deletions(-) diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 7cefffa45..5dc2b73b3 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -11,7 +11,7 @@ use crate::utils; use makepad_widgets::{text::selection::Cursor, *}; use crate::{LivePtr, widget_ref_from_live_ptr}; -use matrix_sdk::ruma::{events::{room::message::RoomMessageEventContent, Mentions}, OwnedRoomId, OwnedUserId}; +use matrix_sdk::ruma::{events::{room::message::RoomMessageEventContent, Mentions}, OwnedMxcUri, OwnedRoomId, OwnedUserId}; use matrix_sdk::room::RoomMember; use std::collections::{BTreeMap, BTreeSet}; use unicode_segmentation::UnicodeSegmentation; @@ -247,6 +247,85 @@ script_mod! { } } + // Template for user mention pill shown in the input area + mod.widgets.UserPill = RoundedView { + width: Fit, + height: Fit, + margin: Inset{left: 2, right: 2, top: 2, bottom: 2} + padding: Inset{left: 4, right: 2, top: 2, bottom: 2} + show_bg: true + draw_bg +: { + color: instance(#E8F4FD), + border_radius: uniform(12.0), + } + flow: Right, + spacing: 4.0, + align: Align{y: 0.5} + + pill_avatar := Avatar { + width: 18, + height: 18, + text_view +: { + text +: { + draw_text +: { + text_style: theme.font_regular { font_size: 9.0 } + } + } + } + } + + pill_username := Label { + height: Fit, + draw_text +: { + color: #1976D2, + text_style: theme.font_regular {font_size: 12.0} + } + } + + close_button := RoundedView { + width: 16, + height: 16, + show_bg: true + cursor: MouseCursor.Hand + draw_bg +: { + color: instance(#00000000), + border_radius: uniform(8.0), + hover: instance(0.0), + + pixel: fn() { + let sdf = Sdf2d.viewport(self.pos * self.rect_size); + sdf.circle(self.rect_size.x * 0.5, self.rect_size.y * 0.5, self.rect_size.x * 0.5); + // Light red hover color + let hover_color = vec4(0.95, 0.85, 0.85, 1.0); + if self.hover > 0.0 { + sdf.fill(hover_color) + } else { + sdf.fill(self.color) + } + return sdf.result + } + } + animator: Animator { + hover: { + default: off + off: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 0.0 }}} + on: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 1.0 }}} + } + } + align: Align{x: 0.5, y: 0.5} + + close_icon := Label { + width: Fit, + height: Fit, + draw_text +: { + color: #666, + text_style: theme.font_regular {font_size: 10.0} + } + text: "×" + } + } + } + // Step 1: Register the base widget type mod.widgets.MentionableTextInputBase = #(MentionableTextInput::register_widget(vm)) @@ -328,6 +407,22 @@ script_mod! { bottom := View { height: 0 } center := RoundedView { height: Fit + flow: Right + align: Align{y: 0.5} + pills_container := View { + width: Fit + height: Fit + flow: Right + spacing: 2.0 + align: Align{y: 0.5} + + // Pre-defined pill slots (max 5 pills) + pill_0 := mod.widgets.UserPill { visible: false } + pill_1 := mod.widgets.UserPill { visible: false } + pill_2 := mod.widgets.UserPill { visible: false } + pill_3 := mod.widgets.UserPill { visible: false } + pill_4 := mod.widgets.UserPill { visible: false } + } left := View{ width: Fit, height: Fit } right := View{ width: Fit, height: Fit } text_input := RobrixTextInput { @@ -342,6 +437,7 @@ script_mod! { room_mention_list_item: mod.widgets.RoomMentionListItem {} loading_indicator: mod.widgets.LoadingIndicator {} no_matches_indicator: mod.widgets.NoMatchesIndicator {} + user_pill: mod.widgets.UserPill {} } } @@ -361,6 +457,14 @@ pub enum MentionableTextInputAction { } } +/// Data for a selected user pill +#[derive(Clone, Debug)] +pub struct SelectedPill { + pub user_id: OwnedUserId, + pub display_name: String, + pub avatar_url: Option, +} + /// Widget that extends CommandTextInput with @mention capabilities #[derive(Script, ScriptHook, Widget)] pub struct MentionableTextInput { @@ -375,6 +479,8 @@ pub struct MentionableTextInput { #[live] loading_indicator: Option, /// Template for no matches indicator #[live] no_matches_indicator: Option, + /// Template for user pill + #[live] user_pill: Option, /// Position where the @ mention starts #[rust] current_mention_start_index: Option, /// The set of users that were mentioned (at one point) in this text input. @@ -392,6 +498,10 @@ pub struct MentionableTextInput { #[rust] can_notify_room: bool, /// Whether the room members are currently being loaded #[rust] members_loading: bool, + /// Selected user pills to display in the input + #[rust] selected_pills: Vec, + /// Widget references for rendered pills (to handle close button events) + #[rust] pill_widgets: Vec, } @@ -399,6 +509,9 @@ impl Widget for MentionableTextInput { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.cmd_text_input.handle_event(cx, event, scope); + // Handle pill close button clicks + self.handle_pill_events(cx, event); + // Best practice: Always check Scope first to get current context // Scope represents the current widget context as passed down from parents let scope_room_id = scope.props.get::() @@ -761,43 +874,62 @@ impl MentionableTextInput { let is_room_mention = room_mention_text == "Notify the entire room" && room_user_id_text == "@room"; - let mention_to_insert = if is_room_mention { - // Always set to true, don't reset previously selected @room mentions + if is_room_mention { + // For @room, insert text directly (no pill) self.possible_room_mention = true; - "@room ".to_string() + let mention_to_insert = "@room ".to_string(); + + // Use utility function to safely replace text + let new_text = utils::safe_replace_by_byte_indices( + ¤t_text, + start_idx, + head, + &mention_to_insert, + ); + + self.cmd_text_input.set_text(cx, &new_text); + // Calculate new cursor position + let new_pos = start_idx + mention_to_insert.len(); + text_input_ref.set_cursor(cx, Cursor { index: new_pos, prefer_next_row: false }, false); } else { - // User selected a specific user + // User selected a specific user - add as pill let username = selected.label(cx, ids!(user_info.username)).text(); let user_id_str = selected.label(cx, ids!(user_id)).text(); let Ok(user_id): Result = user_id_str.clone().try_into() else { log!("Failed to parse user_id: {}", user_id_str); return; }; - self.possible_mentions.insert(user_id.clone(), username.clone()); - - // Currently, we directly insert the markdown link for user mentions - // instead of the user's display name, because we don't yet have a way - // to track mentioned display names and replace them later. - format!( - "[{username}]({}) ", - user_id.matrix_to_uri(), - ) - }; - - - // Use utility function to safely replace text - let new_text = utils::safe_replace_by_byte_indices( - ¤t_text, - start_idx, - head, - &mention_to_insert, - ); - - self.cmd_text_input.set_text(cx, &new_text); - // Calculate new cursor position - let new_pos = start_idx + mention_to_insert.len(); - text_input_ref.set_cursor(cx, Cursor { index: new_pos, prefer_next_row: false }, false); + // Check if user is already in pills (avoid duplicates) + if !self.selected_pills.iter().any(|p| p.user_id == user_id) { + // Get avatar URL from the selected item's avatar widget + // We'll store None for now since we don't have easy access to the MXC URI here + // The avatar will be re-fetched when rendering the pill + let avatar_url = None; + + self.selected_pills.push(SelectedPill { + user_id: user_id.clone(), + display_name: username.clone(), + avatar_url, + }); + + // Track in possible_mentions for message creation + self.possible_mentions.insert(user_id, username); + } + + // Remove the @ and any partial search text from the input + let new_text = utils::safe_replace_by_byte_indices( + ¤t_text, + start_idx, + head, + "", + ); + self.cmd_text_input.set_text(cx, &new_text); + text_input_ref.set_cursor(cx, Cursor { index: start_idx, prefer_next_row: false }, false); + + // Render the pills + self.render_pills(cx); + } } self.is_searching = false; @@ -805,6 +937,118 @@ impl MentionableTextInput { self.close_mention_popup(cx); } + /// Renders all selected pills in the pills_container using pre-defined pill slots + fn render_pills(&mut self, cx: &mut Cx) { + // Clear existing pill widget references + self.pill_widgets.clear(); + + // Pre-defined pill slot IDs + let pill_ids = [ + ids!(persistent.center.pills_container.pill_0), + ids!(persistent.center.pills_container.pill_1), + ids!(persistent.center.pills_container.pill_2), + ids!(persistent.center.pills_container.pill_3), + ids!(persistent.center.pills_container.pill_4), + ]; + + // Update each pill slot + for (index, pill_id) in pill_ids.iter().enumerate() { + let pill_view = self.cmd_text_input.view(cx, *pill_id); + + if index < self.selected_pills.len() { + let pill_data = &self.selected_pills[index]; + + // Show and configure the pill + pill_view.set_visible(cx, true); + + // Set the username + pill_view.label(cx, ids!(pill_username)).set_text(cx, &pill_data.display_name); + + // Set up avatar + let avatar = pill_view.avatar(cx, ids!(pill_avatar)); + if let Some(avatar_url) = &pill_data.avatar_url { + match get_or_fetch_avatar(cx, avatar_url) { + AvatarCacheEntry::Loaded(avatar_data) => { + let _ = avatar.show_image(cx, None, |cx, img| { + utils::load_png_or_jpg(&img, cx, &avatar_data) + }); + } + AvatarCacheEntry::Requested | AvatarCacheEntry::Failed => { + avatar.show_text(cx, None, None, &pill_data.display_name); + } + } + } else { + avatar.show_text(cx, None, None, &pill_data.display_name); + } + + // Store reference for event handling + self.pill_widgets.push(pill_view.clone().into()); + } else { + // Hide unused pill slots + pill_view.set_visible(cx, false); + } + } + + self.redraw(cx); + } + + /// Removes a pill by user_id + fn remove_pill_by_user_id(&mut self, cx: &mut Cx, user_id: &OwnedUserId) { + if let Some(index) = self.selected_pills.iter().position(|p| &p.user_id == user_id) { + let removed = self.selected_pills.remove(index); + self.possible_mentions.remove(&removed.user_id); + self.render_pills(cx); + } + } + + /// Clears all pills + fn clear_all_pills(&mut self, cx: &mut Cx) { + self.selected_pills.clear(); + self.pill_widgets.clear(); + self.possible_mentions.clear(); + self.render_pills(cx); + } + + /// Handles pill close button click events + fn handle_pill_events(&mut self, cx: &mut Cx, event: &Event) { + // Pre-defined pill slot IDs + let pill_ids = [ + ids!(persistent.center.pills_container.pill_0), + ids!(persistent.center.pills_container.pill_1), + ids!(persistent.center.pills_container.pill_2), + ids!(persistent.center.pills_container.pill_3), + ids!(persistent.center.pills_container.pill_4), + ]; + + // Check each visible pill's close button for clicks + for (index, pill_id) in pill_ids.iter().enumerate() { + if index >= self.selected_pills.len() { + break; + } + + let pill_view = self.cmd_text_input.view(cx, *pill_id); + let close_button = pill_view.view(cx, ids!(close_button)); + let area = close_button.area(); + + match event.hits(cx, area) { + Hit::FingerUp(fue) if fue.is_over && fue.was_tap() => { + // Remove this pill + let removed = self.selected_pills.remove(index); + self.possible_mentions.remove(&removed.user_id); + self.render_pills(cx); + return; + } + Hit::FingerHoverIn(_) => { + close_button.animate_state(cx, ids!(hover.on)); + } + Hit::FingerHoverOut(_) => { + close_button.animate_state(cx, ids!(hover.off)); + } + _ => {} + } + } + } + /// Core text change handler that manages mention context fn handle_text_change(&mut self, cx: &mut Cx, scope: &mut Scope, text: String) { // Check if text is empty or contains only whitespace @@ -1182,69 +1426,72 @@ impl MentionableTextInputRef { self.borrow().is_some_and(|inner| inner.can_notify_room()) } - /// Returns the mentions actually present in the given html message content. - fn get_real_mentions_in_html_text(&self, html: &str) -> Mentions { + /// Returns the mentions from selected pills plus any @room mention in the text. + fn get_mentions_from_pills_and_text(&self, text: &str) -> Mentions { let mut mentions = Mentions::new(); let Some(inner) = self.borrow() else { return mentions; }; - let mut user_ids = BTreeSet::new(); - - for (user_id, username) in &inner.possible_mentions { - if html.contains(&format!( - "{}", - user_id.matrix_to_uri(), - username, - )) { - user_ids.insert(user_id.clone()); - } - } + // Get user IDs from selected pills + let user_ids: BTreeSet = inner.selected_pills + .iter() + .map(|pill| pill.user_id.clone()) + .collect(); mentions.user_ids = user_ids; - // Check for @room mention in HTML content - mentions.room = inner.possible_room_mention && html.contains("@room"); + // Check for @room mention in text content + mentions.room = inner.possible_room_mention && text.contains("@room"); mentions } - /// Returns the mentions actually present in the given markdown message content. - fn get_real_mentions_in_markdown_text(&self, markdown: &str) -> Mentions { - let mut mentions = Mentions::new(); - + /// Builds the message text with pill mentions converted to markdown links. + fn build_text_with_pill_mentions(&self, entered_text: &str) -> String { let Some(inner) = self.borrow() else { - return mentions; + return entered_text.to_string(); }; - let mut user_ids = BTreeSet::new(); - for (user_id, username) in &inner.possible_mentions { - // Check both username format and user_id format for flexibility - let username_pattern = format!("[{}]({})", username, user_id.matrix_to_uri()); - let userid_pattern = format!("[{}]({})", user_id, user_id.matrix_to_uri()); - - if markdown.contains(&username_pattern) || markdown.contains(&userid_pattern) { - user_ids.insert(user_id.clone()); - } + if inner.selected_pills.is_empty() { + return entered_text.to_string(); } - mentions.user_ids = user_ids; - // Check for @room mention in markdown content - mentions.room = inner.possible_room_mention && markdown.contains("@room"); - mentions + // Build mention prefix from pills + let mention_prefix: String = inner.selected_pills + .iter() + .map(|pill| format!("[{}]({}) ", pill.display_name, pill.user_id.matrix_to_uri())) + .collect(); + + // Prepend pill mentions to the entered text + format!("{}{}", mention_prefix, entered_text) } /// Processes entered text and creates a message with mentions based on detected message type. /// This method handles /html, /plain prefixes and defaults to markdown. + /// Pill mentions are converted to markdown links and prepended to the message. pub fn create_message_with_mentions(&self, entered_text: &str) -> RoomMessageEventContent { if let Some(html_text) = entered_text.strip_prefix("/html") { - let message = RoomMessageEventContent::text_html(html_text, html_text); - message.add_mentions(self.get_real_mentions_in_html_text(html_text)) + let full_text = self.build_text_with_pill_mentions(html_text); + let message = RoomMessageEventContent::text_html(&full_text, &full_text); + message.add_mentions(self.get_mentions_from_pills_and_text(&full_text)) } else if let Some(plain_text) = entered_text.strip_prefix("/plain") { // Plain text messages don't support mentions RoomMessageEventContent::text_plain(plain_text) } else { - let message = RoomMessageEventContent::text_markdown(entered_text); - message.add_mentions(self.get_real_mentions_in_markdown_text(entered_text)) + let full_text = self.build_text_with_pill_mentions(entered_text); + let message = RoomMessageEventContent::text_markdown(&full_text); + message.add_mentions(self.get_mentions_from_pills_and_text(&full_text)) + } + } + + /// Clears all selected pills + pub fn clear_pills(&self, cx: &mut Cx) { + if let Some(mut inner) = self.borrow_mut() { + inner.selected_pills.clear(); + inner.pill_widgets.clear(); + inner.possible_mentions.clear(); + inner.possible_room_mention = false; + inner.render_pills(cx); } } From 96cd39217c530b377bf40efaa498fa058b7bd296 Mon Sep 17 00:00:00 2001 From: alanpoon Date: Thu, 2 Apr 2026 12:39:58 +0800 Subject: [PATCH 4/5] add pills --- src/room/room_input_bar.rs | 9 +++- src/shared/mentionable_text_input.rs | 64 +++++++++++++++++----------- 2 files changed, 47 insertions(+), 26 deletions(-) diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index 7fab79ae1..c3af22dff 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -292,7 +292,9 @@ impl RoomInputBar { || text_input.returned(actions).is_some_and(|(_, m)| m.is_primary()) { let entered_text = mentionable_text_input.text().trim().to_string(); - if !entered_text.is_empty() { + let has_pills = mentionable_text_input.has_pills(); + // Send message if there's text OR if there are mention pills + if !entered_text.is_empty() || has_pills { let message = mentionable_text_input.create_message_with_mentions(&entered_text); let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| event_tl_item.event_id().map(|event_id| { @@ -324,6 +326,7 @@ impl RoomInputBar { self.clear_replying_to(cx); mentionable_text_input.set_text(cx, ""); + mentionable_text_input.clear_pills(cx); self.enable_send_message_button(cx, false); } } @@ -340,7 +343,9 @@ impl RoomInputBar { } else { text_input.text().is_empty() }; - self.enable_send_message_button(cx, !is_text_input_empty); + // Enable send button if there's text OR if there are mention pills + let has_content = !is_text_input_empty || mentionable_text_input.has_pills(); + self.enable_send_message_button(cx, has_content); // Handle the user pressing the up arrow in an empty message input box // to edit their latest sent message. diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 5dc2b73b3..f6f336a5b 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -56,14 +56,14 @@ script_mod! { } animator: Animator { hover: { - default: off - off: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 0.0 }}} - on: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 1.0 }}} + default: @off + off: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 0.0 }}} + on: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 1.0 }}} } selected: { - default: off - off: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 0.0 }}} - on: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 1.0 }}} + default: @off + off: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 0.0 }}} + on: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 1.0 }}} } } flow: Down @@ -140,14 +140,14 @@ script_mod! { } animator: Animator { hover: { - default: off - off: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 0.0 }}} - on: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 1.0 }}} + default: @off + off: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 0.0 }}} + on: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 1.0 }}} } selected: { - default: off - off: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 0.0 }}} - on: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 1.0 }}} + default: @off + off: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 0.0 }}} + on: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { selected: 1.0 }}} } } flow: Down @@ -307,9 +307,9 @@ script_mod! { } animator: Animator { hover: { - default: off - off: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 0.0 }}} - on: { from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 1.0 }}} + default: @off + off: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 0.0 }}} + on: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 1.0 }}} } } align: Align{x: 0.5, y: 0.5} @@ -500,8 +500,8 @@ pub struct MentionableTextInput { #[rust] members_loading: bool, /// Selected user pills to display in the input #[rust] selected_pills: Vec, - /// Widget references for rendered pills (to handle close button events) - #[rust] pill_widgets: Vec, + /// View references for rendered pills (to handle close button events) + #[rust] pill_widgets: Vec, } @@ -895,6 +895,7 @@ impl MentionableTextInput { // User selected a specific user - add as pill let username = selected.label(cx, ids!(user_info.username)).text(); let user_id_str = selected.label(cx, ids!(user_id)).text(); + log!("[MentionableTextInput {:p}] User selected: {} ({})", self as *const _, username, user_id_str); let Ok(user_id): Result = user_id_str.clone().try_into() else { log!("Failed to parse user_id: {}", user_id_str); return; @@ -939,6 +940,16 @@ impl MentionableTextInput { /// Renders all selected pills in the pills_container using pre-defined pill slots fn render_pills(&mut self, cx: &mut Cx) { + // Log with widget address to identify different instances + log!("[MentionableTextInput {:p}] render_pills called with {} pills", self as *const _, self.selected_pills.len()); + for (i, pill) in self.selected_pills.iter().enumerate() { + log!("[MentionableTextInput {:p}] pill {}: {} ({})", self as *const _, i, pill.display_name, pill.user_id); + } + + // Log the container + let pills_container = self.cmd_text_input.view(cx, ids!(persistent.center.pills_container)); + log!("[MentionableTextInput {:p}] pills_container area: {:?}", self as *const _, pills_container.area()); + // Clear existing pill widget references self.pill_widgets.clear(); @@ -954,11 +965,13 @@ impl MentionableTextInput { // Update each pill slot for (index, pill_id) in pill_ids.iter().enumerate() { let pill_view = self.cmd_text_input.view(cx, *pill_id); + log!("[MentionableTextInput] pill_view index {} area: {:?}", index, pill_view.area()); if index < self.selected_pills.len() { let pill_data = &self.selected_pills[index]; // Show and configure the pill + log!("[MentionableTextInput] Setting pill {} visible: true, name: {}", index, pill_data.display_name); pill_view.set_visible(cx, true); // Set the username @@ -982,7 +995,7 @@ impl MentionableTextInput { } // Store reference for event handling - self.pill_widgets.push(pill_view.clone().into()); + self.pill_widgets.push(pill_view); } else { // Hide unused pill slots pill_view.set_visible(cx, false); @@ -1038,12 +1051,6 @@ impl MentionableTextInput { self.render_pills(cx); return; } - Hit::FingerHoverIn(_) => { - close_button.animate_state(cx, ids!(hover.on)); - } - Hit::FingerHoverOut(_) => { - close_button.animate_state(cx, ids!(hover.off)); - } _ => {} } } @@ -1051,9 +1058,13 @@ impl MentionableTextInput { /// Core text change handler that manages mention context fn handle_text_change(&mut self, cx: &mut Cx, scope: &mut Scope, text: String) { + log!("[MentionableTextInput {:p}] handle_text_change called, text: '{}', pills: {}", + self as *const _, text, self.selected_pills.len()); + // Check if text is empty or contains only whitespace let trimmed_text = text.trim(); if trimmed_text.is_empty() { + log!("[MentionableTextInput {:p}] Text is empty, clearing possible_mentions (not pills)", self as *const _); self.possible_mentions.clear(); self.possible_room_mention = false; if self.is_searching { @@ -1495,4 +1506,9 @@ impl MentionableTextInputRef { } } + /// Returns true if there are any selected pills + pub fn has_pills(&self) -> bool { + self.borrow().is_some_and(|inner| !inner.selected_pills.is_empty()) + } + } From 028a92dac0472c1b9ff33f052dcc9e3b7db8c72f Mon Sep 17 00:00:00 2001 From: alanpoon Date: Fri, 10 Apr 2026 20:01:12 +0800 Subject: [PATCH 5/5] code cleanup --- build.rs | 14 + src/room/room_input_bar.rs | 9 +- src/shared/command_text_input.rs | 2 +- src/shared/mentionable_text_input.rs | 1077 ++++++++++++++++++++------ 4 files changed, 859 insertions(+), 243 deletions(-) create mode 100644 build.rs diff --git a/build.rs b/build.rs new file mode 100644 index 000000000..218879a52 --- /dev/null +++ b/build.rs @@ -0,0 +1,14 @@ +fn main() { + // Note: `#[cfg(windows)]` checks the *host* OS, not the *target*. + // We must check the target env at runtime to avoid running this + // when cross-compiling (e.g., building for Android on a Windows CI runner). + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os == "windows" { + #[cfg(windows)] + { + let mut res = winresource::WindowsResource::new(); + res.set_icon("resources/icon.ico"); + res.compile().expect("Failed to compile Windows resources"); + } + } +} diff --git a/src/room/room_input_bar.rs b/src/room/room_input_bar.rs index ee3a63b47..019471d80 100644 --- a/src/room/room_input_bar.rs +++ b/src/room/room_input_bar.rs @@ -329,9 +329,7 @@ impl RoomInputBar { || text_input.returned(actions).is_some_and(|(_, m)| m.is_primary()) { let entered_text = mentionable_text_input.text().trim().to_string(); - let has_pills = mentionable_text_input.has_pills(); - // Send message if there's text OR if there are mention pills - if !entered_text.is_empty() || has_pills { + if !entered_text.is_empty() { let message = mentionable_text_input.create_message_with_mentions(&entered_text); let replied_to = self.replying_to.take().and_then(|(event_tl_item, _emb)| event_tl_item.event_id().map(|event_id| { @@ -365,7 +363,6 @@ impl RoomInputBar { self.clear_replying_to(cx); mentionable_text_input.set_text(cx, ""); - mentionable_text_input.clear_pills(cx); self.enable_send_message_button(cx, false); } } @@ -382,9 +379,7 @@ impl RoomInputBar { } else { text_input.text().is_empty() }; - // Enable send button if there's text OR if there are mention pills - let has_content = !is_text_input_empty || mentionable_text_input.has_pills(); - self.enable_send_message_button(cx, has_content); + self.enable_send_message_button(cx, !is_text_input_empty); // Handle the user pressing the up arrow in an empty message input box // to edit their latest sent message. diff --git a/src/shared/command_text_input.rs b/src/shared/command_text_input.rs index fc20aa3f4..93fc5d75c 100644 --- a/src/shared/command_text_input.rs +++ b/src/shared/command_text_input.rs @@ -290,6 +290,7 @@ impl Widget for CommandTextInput { self.is_text_input_focus_pending = false; self.text_input_ref().set_key_focus(cx); } + DrawStep::done() } @@ -813,7 +814,6 @@ impl CommandTextInput { let mut item = item.clone(); script_apply_eval!(cx, item, { show_bg: true, - // cursor: MouseCursor.Hand }); // If there is a keyboard focus, prioritize it over mouse hover diff --git a/src/shared/mentionable_text_input.rs b/src/shared/mentionable_text_input.rs index 5d93dd524..feec99966 100644 --- a/src/shared/mentionable_text_input.rs +++ b/src/shared/mentionable_text_input.rs @@ -1,17 +1,25 @@ -//! A temporary mock/placeholder for MentionableTextInput that uses a simple TextInput -//! instead of the full @mention popup system (CommandTextInput). +//! MentionableTextInput component provides text input with @mention capabilities +//! Can be used in any context where user mentions are needed (message input, editing) //! -//! This preserves the same external-facing API so that the real MentionableTextInput -//! can be slotted back in later without changing the code that depends on it. - -use std::collections::{BTreeMap, BTreeSet}; -use makepad_widgets::*; -use matrix_sdk::ruma::{ - events::{room::message::RoomMessageEventContent, Mentions}, - OwnedMxcUri, OwnedRoomId, OwnedUserId, -}; -use crate::LivePtr; +use crate::avatar_cache::*; use crate::shared::command_text_input::CommandTextInput; +use crate::shared::avatar::AvatarWidgetRefExt; +use crate::shared::bouncing_dots::BouncingDotsWidgetRefExt; +use crate::shared::styles::COLOR_UNKNOWN_ROOM_AVATAR; +use crate::utils; + + +use makepad_widgets::{text::selection::Cursor, *}; +use crate::{LivePtr, widget_ref_from_live_ptr}; +use matrix_sdk::ruma::{events::room::message::RoomMessageEventContent, OwnedRoomId, OwnedUserId}; +use matrix_sdk::room::RoomMember; +use std::collections::BTreeMap; +use unicode_segmentation::UnicodeSegmentation; +use crate::home::room_screen::RoomScreenProps; + +// Constants for mention popup height calculations +const DESKTOP_ITEM_HEIGHT: f64 = 32.0; +const MOBILE_ITEM_HEIGHT: f64 = 64.0; script_mod! { use mod.prelude.widgets.* @@ -103,7 +111,7 @@ script_mod! { // Template for the @room mention list item mod.widgets.RoomMentionListItem = RoundedView { width: Fill, - height: Fit, + height: Fit margin: Inset{left: 4, right: 4} padding: Inset{left: 8, right: 8, top: 4, bottom: 4} show_bg: true @@ -239,85 +247,6 @@ script_mod! { } } - // Template for user mention pill shown in the input area - mod.widgets.UserPill = RoundedView { - width: Fit, - height: Fit, - margin: Inset{left: 2, right: 2, top: 2, bottom: 2} - padding: Inset{left: 4, right: 2, top: 2, bottom: 2} - show_bg: true - draw_bg +: { - color: instance(#E8F4FD), - border_radius: uniform(12.0), - } - flow: Right, - spacing: 4.0, - align: Align{y: 0.5} - - pill_avatar := Avatar { - width: 18, - height: 18, - text_view +: { - text +: { - draw_text +: { - text_style: theme.font_regular { font_size: 9.0 } - } - } - } - } - - pill_username := Label { - height: Fit, - draw_text +: { - color: #1976D2, - text_style: theme.font_regular {font_size: 12.0} - } - } - - close_button := RoundedView { - width: 16, - height: 16, - show_bg: true - cursor: MouseCursor.Hand - draw_bg +: { - color: instance(#00000000), - border_radius: uniform(8.0), - hover: instance(0.0), - - pixel: fn() { - let sdf = Sdf2d.viewport(self.pos * self.rect_size); - sdf.circle(self.rect_size.x * 0.5, self.rect_size.y * 0.5, self.rect_size.x * 0.5); - // Light red hover color - let hover_color = vec4(0.95, 0.85, 0.85, 1.0); - if self.hover > 0.0 { - sdf.fill(hover_color) - } else { - sdf.fill(self.color) - } - return sdf.result - } - } - animator: Animator { - hover: { - default: @off - off: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 0.0 }}} - on: AnimatorState{ from: {all: Forward{duration: 0.1}} apply: { draw_bg: { hover: 1.0 }}} - } - } - align: Align{x: 0.5, y: 0.5} - - close_icon := Label { - width: Fit, - height: Fit, - draw_text +: { - color: #666, - text_style: theme.font_regular {font_size: 10.0} - } - text: "×" - } - } - } - // Step 1: Register the base widget type mod.widgets.MentionableTextInputBase = #(MentionableTextInput::register_widget(vm)) @@ -329,20 +258,58 @@ script_mod! { trigger: "@" inline_search: true - color_focus: (mod.widgets.FOCUS_HOVER_COLOR), - color_hover: (mod.widgets.FOCUS_HOVER_COLOR), + // Light blue colors for hover/focus highlighting (#DBEAFE) + color_focus: #DBEAFE + color_hover: #DBEAFE popup := RoundedView { - spacing: 0.0 - padding: 0.0 + width: Fill + flow: Down + height: Fit + visible: false + show_bg: true - draw_bg.color: (COLOR_SECONDARY) + draw_bg +: { + color: instance(#ffffff) + border_size: uniform(1.0) + border_color: instance(#cccccc) + border_radius: uniform(4.0) - header_view := SolidView { - margin: Inset{left: 4, right: 4} + pixel: fn() { + let sdf = Sdf2d.viewport(self.pos * self.rect_size) + sdf.box_all( + 0.0, 0.0, + self.rect_size.x, self.rect_size.y, + self.border_radius, self.border_radius, + self.border_radius, self.border_radius + ) + sdf.fill(self.border_color) + sdf.box_all( + self.border_size, self.border_size, + self.rect_size.x - self.border_size * 2.0, + self.rect_size.y - self.border_size * 2.0, + self.border_radius - self.border_size, + self.border_radius - self.border_size, + self.border_radius - self.border_size, + self.border_radius - self.border_size + ) + sdf.fill(self.color) + return sdf.result + } + } + + header_view := RoundedView { + visible: true + width: Fill + height: Fit + padding: Inset{left: 8, right: 8, top: 8, bottom: 4} draw_bg.color: (COLOR_ROBRIX_PURPLE) + header_label := Label { - draw_text.color: (COLOR_PRIMARY_DARKER), + draw_text +: { + color: #ffffff + text_style: theme.font_regular { font_size: 12.0 } + } text: "Users in this Room" } } @@ -354,39 +321,22 @@ script_mod! { padding: 0.0 } } - - persistent := RoundedView { - width: Fill, - height: Fit, - flow: Down, + persistent := RoundedView{ + flow: Down + height: Fit top := View { height: 0 } center := RoundedView { height: Fit flow: Right align: Align{y: 0.5} - pills_container := View { - width: Fit - height: Fit - flow: Right - spacing: 2.0 - align: Align{y: 0.5} - - // Pre-defined pill slots (max 5 pills) - pill_0 := mod.widgets.UserPill { visible: false } - pill_1 := mod.widgets.UserPill { visible: false } - pill_2 := mod.widgets.UserPill { visible: false } - pill_3 := mod.widgets.UserPill { visible: false } - pill_4 := mod.widgets.UserPill { visible: false } - } left := View{ width: Fit, height: Fit } right := View{ width: Fit, height: Fit } - width: Fill, - height: Fit, text_input := RobrixTextInput { width: Fill empty_text: "Start typing..." } } + bottom := View { height: 0 } } // Template for user list items in the mention popup @@ -394,17 +344,9 @@ script_mod! { room_mention_list_item: mod.widgets.RoomMentionListItem {} loading_indicator: mod.widgets.LoadingIndicator {} no_matches_indicator: mod.widgets.NoMatchesIndicator {} - user_pill: mod.widgets.UserPill {} } } -// /// A special string used to denote the start of a mention within -// /// the actual text being edited. -// /// This is used to help easily locate and distinguish actual mentions -// /// from normal `@` characters. -// const MENTION_START_STRING: &str = "\u{8288}@\u{8288}"; - - #[derive(Debug)] pub enum MentionableTextInputAction { /// Notifies the MentionableTextInput about updated power levels for the room. @@ -414,16 +356,7 @@ pub enum MentionableTextInputAction { } } -/// Data for a selected user pill -#[derive(Clone, Debug)] -pub struct SelectedPill { - pub user_id: OwnedUserId, - pub display_name: String, - pub avatar_url: Option, -} - -/// Temporary mock widget that wraps a simple TextInput (RobrixTextInput) -/// while preserving the same external API as the real MentionableTextInput. +/// Widget that extends CommandTextInput with @mention capabilities #[derive(Script, ScriptHook, Widget)] pub struct MentionableTextInput { #[source] source: ScriptObjectRef, @@ -437,8 +370,6 @@ pub struct MentionableTextInput { #[live] loading_indicator: Option, /// Template for no matches indicator #[live] no_matches_indicator: Option, - /// Template for user pill - #[live] user_pill: Option, /// Position where the @ mention starts #[rust] current_mention_start_index: Option, /// The set of users that were mentioned (at one point) in this text input. @@ -453,168 +384,844 @@ pub struct MentionableTextInput { /// Indicates if currently in mention search mode #[rust] is_searching: bool, /// Whether the current user can notify everyone in the room (@room mention) - #[deref] view: View, - /// Whether the current user can notify everyone in the room (@room mention). - /// Stored but not used in this mock; kept for API compatibility. #[rust] can_notify_room: bool, /// Whether the room members are currently being loaded #[rust] members_loading: bool, - /// Selected user pills to display in the input - #[rust] selected_pills: Vec, - /// View references for rendered pills (to handle close button events) - #[rust] pill_widgets: Vec, } + impl Widget for MentionableTextInput { fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) { self.cmd_text_input.handle_event(cx, event, scope); - // Handle pill close button clicks - self.handle_pill_events(cx, event); + // Best practice: Always check Scope first to get current context + // Scope represents the current widget context as passed down from parents + let scope_room_id = scope.props.get::() + .expect("BUG: RoomScreenProps should be available in Scope::props for MentionableTextInput") + .room_name_id + .room_id() + .clone(); - // Handle MentionableTextInputAction for API compatibility. if let Event::Actions(actions) = event { + let text_input_ref = self.cmd_text_input.text_input_ref(); + let text_input_uid = text_input_ref.widget_uid(); + let text_input_area = text_input_ref.area(); + let has_focus = cx.has_key_focus(text_input_area); + + // Handle item selection from mention popup + if let Some(selected) = self.cmd_text_input.item_selected(actions) { + self.on_user_selected(cx, scope, selected); + } + + // Handle build items request + if self.cmd_text_input.should_build_items(actions) { + if has_focus { + let search_text = self.cmd_text_input.search_text().to_lowercase(); + self.update_user_list(cx, &search_text, scope); + } else if self.cmd_text_input.view(cx, ids!(popup)).visible() { + self.close_mention_popup(cx); + } + } + + // Process all actions for action in actions { - if let Some(MentionableTextInputAction::PowerLevelsUpdated { - can_notify_room, .. - }) = action.downcast_ref() - { - self.can_notify_room = *can_notify_room; + // Handle TextInput changes + if let Some(widget_action) = action.as_widget_action() { + if widget_action.widget_uid == text_input_uid { + if let TextInputAction::Changed(text) = widget_action.cast() { + if has_focus { + self.handle_text_change(cx, scope, text.to_owned()); + } + continue; // Continue processing other actions + } + } + } + + // Handle MentionableTextInputAction actions + if let Some(MentionableTextInputAction::PowerLevelsUpdated { room_id, can_notify_room }) = action.downcast_ref() { + if &scope_room_id != room_id { + continue; + } + + if self.can_notify_room != *can_notify_room { + self.can_notify_room = *can_notify_room; + if self.is_searching && has_focus { + let search_text = self.cmd_text_input.search_text().to_lowercase(); + self.update_user_list(cx, &search_text, scope); + } else { + self.redraw(cx); + } + } + } + } + + // Close popup if focus is lost + if !has_focus && self.cmd_text_input.view(cx, ids!(popup)).visible() { + self.close_mention_popup(cx); + } + } + + // Check if we were waiting for members and they're now available + if self.members_loading && self.is_searching { + let room_props = scope + .props + .get::() + .expect("RoomScreenProps should be available in scope"); + + if let Some(room_members) = &room_props.room_members { + if !room_members.is_empty() { + // Members are now available, update the list + self.members_loading = false; + let text_input = self.cmd_text_input.text_input_ref(); + let text_input_area = text_input.area(); + let is_focused = cx.has_key_focus(text_input_area); + + if is_focused { + let search_text = self.cmd_text_input.search_text().to_lowercase(); + self.update_user_list(cx, &search_text, scope); + } } } } } fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep { - self.view.draw_walk(cx, scope, walk) + self.cmd_text_input.draw_walk(cx, scope, walk) } + /// Returns the current text content fn text(&self) -> String { - self.child_by_path(ids!(text_input)).as_text_input().text() + self.cmd_text_input.text_input_ref().text() } +} - fn set_text(&mut self, cx: &mut Cx, text: &str) { - self.text_input(cx, ids!(persistent.center.text_input)).set_text(cx, text); - self.redraw(cx); + +impl MentionableTextInput { + + /// Check if members are loading and show loading indicator if needed. + /// + /// Returns true if we should return early because we're in the loading state. + fn handle_members_loading_state( + &mut self, + cx: &mut Cx, + room_members: &Option>>, + ) -> bool { + let Some(room_members) = room_members else { + self.members_loading = true; + self.show_loading_indicator(cx); + return true; + }; + + let members_are_empty = room_members.is_empty(); + + if members_are_empty && !self.members_loading { + // Members list is empty and we're not already showing loading - start loading state + self.members_loading = true; + self.show_loading_indicator(cx); + return true; + } else if !members_are_empty && self.members_loading { + // Members have been loaded, stop loading state + self.members_loading = false; + } else if members_are_empty && self.members_loading { + // Still loading and members are empty - keep showing loading indicator + return true; + } + + false } - fn set_key_focus(&self, cx: &mut Cx) { - self.text_input(cx, ids!(persistent.center.text_input)).set_key_focus(cx); + /// Tries to add the `@room` mention item to the list of selectable popup mentions. + /// + /// Returns true if @room item was added to the list and will be displayed in the popup. + fn try_search_messages_mention_item( + &mut self, + cx: &mut Cx, + search_text: &str, + room_props: &RoomScreenProps, + is_desktop: bool, + ) -> bool { + if !self.can_notify_room || !("@room".contains(search_text) || search_text.is_empty()) { + return false; + } + + let Some(ptr) = self.room_mention_list_item else { return false }; + let room_mention_item = widget_ref_from_live_ptr(cx, Some(ptr)); + let mut room_avatar_shown = false; + + let avatar_ref = room_mention_item.avatar(cx, ids!(user_info.room_avatar)); + + // Get room avatar fallback text from room name (with automatic ID fallback) + let room_label = room_props.room_name_id.to_string(); + let room_name_first_char = room_label + .graphemes(true) + .find(|g| *g != "#" && *g != "!" && *g != "@") + .map(|s| s.to_uppercase()) + .filter(|s| s.chars().all(|c| c.is_alphabetic())) + .unwrap_or_else(|| "R".to_string()); + + if let Some(avatar_url) = &room_props.room_avatar_url { + match get_or_fetch_avatar(cx, avatar_url) { + AvatarCacheEntry::Loaded(avatar_data) => { + // Display room avatar + let result = avatar_ref.show_image(cx, None, |cx, img| { + utils::load_png_or_jpg(&img, cx, &avatar_data) + }); + if result.is_ok() { + room_avatar_shown = true; + } else { + log!("Failed to show @room avatar with room avatar image"); + } + }, + AvatarCacheEntry::Requested => { + avatar_ref.show_text(cx, Some(COLOR_UNKNOWN_ROOM_AVATAR), None, &room_name_first_char); + room_avatar_shown = true; + }, + AvatarCacheEntry::Failed => { + log!("Failed to load room avatar for @room"); + } + } + } + + // If unable to display room avatar, show first character of room name + if !room_avatar_shown { + avatar_ref.show_text(cx, Some(COLOR_UNKNOWN_ROOM_AVATAR), None, &room_name_first_char); + } + + // Apply layout and height styling based on device type + let new_height = if is_desktop { DESKTOP_ITEM_HEIGHT } else { MOBILE_ITEM_HEIGHT }; + let mut room_mention_item = room_mention_item; + if is_desktop { + script_apply_eval!(cx, room_mention_item, { + height: #(new_height), + flow: #(Flow::Right{ row_align: turtle::RowAlign::Top, wrap: false }), + }); + } else { + script_apply_eval!(cx, room_mention_item, { + height: #(new_height), + flow: #(Flow::Down), + }); + } + + self.cmd_text_input.add_item(cx, room_mention_item); + true } -} -impl MentionableTextInput { + /// Find and sort matching members based on search text + fn find_and_sort_matching_members( + &self, + search_text: &str, + room_members: &std::sync::Arc>, + max_matched_members: usize, + ) -> Vec<(String, RoomMember)> { + let mut prioritized_members = Vec::new(); + + // Get current user ID to filter out self-mentions + let current_user_id = crate::sliding_sync::current_user_id(); + + for member in room_members.iter() { + if prioritized_members.len() >= max_matched_members { + break; + } - /// Sets whether the current user can notify the entire room (@room mention). - pub fn set_can_notify_room(&mut self, can_notify: bool) { - self.can_notify_room = can_notify; + // Skip the current user - users should not be able to mention themselves + if let Some(ref current_id) = current_user_id { + if member.user_id() == current_id { + continue; + } + } + + // Check if this member matches the search text (including Matrix ID) + if self.user_matches_search(member, search_text) { + let display_name = member + .display_name() + .map(|n| n.to_string()) + .unwrap_or_else(|| member.user_id().to_string()); + + let priority = self.get_match_priority(member, search_text); + prioritized_members.push((priority, display_name, member.clone())); + } + } + + // Sort by priority (lower number = higher priority) + prioritized_members.sort_by_key(|(priority, _, _)| *priority); + + // Convert to the format expected by the rest of the code + prioritized_members + .into_iter() + .map(|(_, display_name, member)| (display_name, member)) + .collect() } - /// Gets whether the current user can notify the entire room (@room mention). - pub fn can_notify_room(&self) -> bool { - self.can_notify_room + /// Add user mention items to the list + /// Returns the number of items added + fn add_user_mention_items( + &mut self, + cx: &mut Cx, + matched_members: Vec<(String, RoomMember)>, + user_items_limit: usize, + is_desktop: bool, + ) -> usize { + let mut items_added = 0; + + for (index, (display_name, member)) in matched_members.into_iter().take(user_items_limit).enumerate() { + let Some(user_list_item_ptr) = self.user_list_item else { continue }; + let item = widget_ref_from_live_ptr(cx, Some(user_list_item_ptr)); + + item.label(cx, ids!(user_info.username)).set_text(cx, &display_name); + + // Use the full user ID string + let user_id_str = member.user_id().as_str(); + item.label(cx, ids!(user_id)).set_text(cx, user_id_str); + + if is_desktop { + let mut item_ref = item.clone(); + script_apply_eval!(cx, item_ref, { + flow: #(Flow::Right{wrap: false, row_align: turtle::RowAlign::Top}), + height: #(DESKTOP_ITEM_HEIGHT), + }); + item.view(cx, ids!(user_info.filler)).set_visible(cx, true); + } else { + let mut item_ref = item.clone(); + script_apply_eval!(cx, item_ref, { + flow: #(Flow::Down), + height: #(MOBILE_ITEM_HEIGHT), + }); + item.view(cx, ids!(user_info.filler)).set_visible(cx, false); + } + + let avatar = item.avatar(cx, ids!(user_info.avatar)); + if let Some(mxc_uri) = member.avatar_url() { + match get_or_fetch_avatar(cx, &mxc_uri.to_owned()) { + AvatarCacheEntry::Loaded(avatar_data) => { + let _ = avatar.show_image(cx, None, |cx, img| { + utils::load_png_or_jpg(&img, cx, &avatar_data) + }); + } + AvatarCacheEntry::Requested | AvatarCacheEntry::Failed => { + avatar.show_text(cx, None, None, &display_name); + } + } + } else { + avatar.show_text(cx, None, None, &display_name); + } + + self.cmd_text_input.add_item(cx, item.clone()); + items_added += 1; + + // Set keyboard focus to the first item + if index == 0 { + // If @room exists, it's index 0, otherwise first user is index 0 + self.cmd_text_input.set_keyboard_focus_index(0); + } + } + + items_added } - /// Handle pill close button click events. - fn handle_pill_events(&mut self, _cx: &mut Cx, _event: &Event) { - // TODO: Implement pill close button event handling + /// Update popup visibility and layout + fn update_popup_visibility(&mut self, cx: &mut Cx, has_items: bool) { + let popup = self.cmd_text_input.view(cx, ids!(popup)); + + if has_items { + popup.set_visible(cx, true); + if self.is_searching { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } + } else if self.is_searching { + // If we're searching but have no items, show "no matches" message + // Keep the popup open so users can correct their search + self.show_no_matches_indicator(cx); + popup.set_visible(cx, true); + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } else { + // Only hide popup if we're not actively searching + popup.set_visible(cx, false); + } } - /// Re-render the pill widgets. - fn render_pills(&mut self, _cx: &mut Cx) { - // TODO: Implement pill rendering + /// Handles item selection from mention popup (either user or @room) + fn on_user_selected(&mut self, cx: &mut Cx, _scope: &mut Scope, selected: WidgetRef) { + // Note: We receive scope as parameter but don't use it in this method + // This is good practice to maintain signature consistency with other methods + // and allow for future scope-based enhancements + + let text_input_ref = self.cmd_text_input.text_input_ref(); + let current_text = text_input_ref.text(); + let head = text_input_ref.borrow().map_or(0, |p| p.cursor().index); + + if let Some(start_idx) = self.current_mention_start_index { + let room_mention_label = selected.label(cx, ids!(user_info.room_mention)); + let room_mention_text = room_mention_label.text(); + let room_user_id_text = selected.label(cx, ids!(room_user_id)).text(); + + let is_room_mention = room_mention_text == "Notify the entire room" && room_user_id_text == "@room"; + + if is_room_mention { + // For @room, insert text directly (no pill) + self.possible_room_mention = true; + let mention_to_insert = "@room ".to_string(); + + // Use utility function to safely replace text + let new_text = utils::safe_replace_by_byte_indices( + ¤t_text, + start_idx, + head, + &mention_to_insert, + ); + + self.cmd_text_input.set_text(cx, &new_text); + // Calculate new cursor position + let new_pos = start_idx + mention_to_insert.len(); + text_input_ref.set_cursor(cx, Cursor { index: new_pos, prefer_next_row: false }, false); + } else { + // User selected a specific user - insert markdown link directly into text + let username = selected.label(cx, ids!(user_info.username)).text(); + let user_id_str = selected.label(cx, ids!(user_id)).text(); + log!("[MentionableTextInput {:p}] User selected: {} ({})", self as *const _, username, user_id_str); + let Ok(user_id): Result = user_id_str.clone().try_into() else { + log!("Failed to parse user_id: {}", user_id_str); + return; + }; + + // Track in possible_mentions for message creation + self.possible_mentions.insert(user_id.clone(), username.clone()); + + // Build the markdown link format: [displayname](https://matrix.to/#/@user:server) + let mention_link = format!("[{}]({}) ", username, user_id.matrix_to_uri()); + + // Replace the @ and search text with the markdown link + let new_text = utils::safe_replace_by_byte_indices( + ¤t_text, + start_idx, + head, + &mention_link, + ); + self.cmd_text_input.set_text(cx, &new_text); + + // Position cursor after the inserted mention link + let new_pos = start_idx + mention_link.len(); + text_input_ref.set_cursor(cx, Cursor { index: new_pos, prefer_next_row: false }, false); + } + } + + self.is_searching = false; + self.current_mention_start_index = None; + self.close_mention_popup(cx); } -} -impl MentionableTextInputRef { - /// Returns a reference to the inner `TextInput` widget. - pub fn text_input_ref(&self) -> TextInputRef { - self.child_by_path(ids!(persistent.center.text_input)).as_text_input() + /// Core text change handler that manages mention context + fn handle_text_change(&mut self, cx: &mut Cx, scope: &mut Scope, text: String) { + log!("[MentionableTextInput {:p}] handle_text_change called, text: '{}'", self as *const _, text); + + // Check if text is empty or contains only whitespace + let trimmed_text = text.trim(); + if trimmed_text.is_empty() { + log!("[MentionableTextInput {:p}] Text is empty, clearing possible_mentions (not pills)", self as *const _); + self.possible_mentions.clear(); + self.possible_room_mention = false; + if self.is_searching { + self.close_mention_popup(cx); + } + return; + } + + let cursor_pos = self.cmd_text_input.text_input_ref().borrow().map_or(0, |p| p.cursor().index); + + // Check if we're currently searching and the @ symbol was deleted + if self.is_searching { + if let Some(start_pos) = self.current_mention_start_index { + // Check if the @ symbol at the start position still exists + if start_pos >= text.len() || text.get(start_pos..start_pos+1).is_some_and(|c| c != "@") { + // The @ symbol was deleted, stop searching + self.close_mention_popup(cx); + return; + } + } + } + + // Look for trigger position for @ menu + if let Some(trigger_pos) = self.find_mention_trigger_position(&text, cursor_pos) { + self.current_mention_start_index = Some(trigger_pos); + self.is_searching = true; + + let search_text = utils::safe_substring_by_byte_indices( + &text, + trigger_pos + 1, + cursor_pos + ).to_lowercase(); + + // Ensure header view is visible to prevent header disappearing during consecutive @mentions + let popup = self.cmd_text_input.view(cx, ids!(popup)); + let header_view = self.cmd_text_input.view(cx, ids!(popup.header_view)); + header_view.set_visible(cx, true); + + self.update_user_list(cx, &search_text, scope); + popup.set_visible(cx, true); + } else if self.is_searching { + self.close_mention_popup(cx); + } } - /// Sets whether the current user can notify the entire room (@room mention). - pub fn set_can_notify_room(&self, can_notify: bool) { - if let Some(mut inner) = self.borrow_mut() { - inner.set_can_notify_room(can_notify); + /// Updates the mention suggestion list based on search + fn update_user_list(&mut self, cx: &mut Cx, search_text: &str, scope: &mut Scope) { + // 1. Get Props from Scope + let room_props = scope.props.get::() + .expect("RoomScreenProps should be available in scope for MentionableTextInput"); + + // 2. Check if members are loading and handle loading state + if self.handle_members_loading_state(cx, &room_props.room_members) { + return; } + + // 3. Get room members (we know they exist because handle_members_loading_state returned false) + let room_members = room_props.room_members.as_ref().unwrap(); + + // Clear old list items, prepare to populate new list + self.cmd_text_input.clear_items(cx); + + if !self.is_searching { + return; + } + + let is_desktop = cx.display_context.is_desktop(); + let max_visible_items: usize = if is_desktop { 10 } else { 5 }; + let mut items_added = 0; + + // 4. Try to add @room mention item + let has_room_item = self.try_search_messages_mention_item(cx, search_text, room_props, is_desktop); + if has_room_item { + items_added += 1; + } + + // 5. Find and sort matching members + let max_matched_members = max_visible_items * 2; // Buffer for better UX + let matched_members = self.find_and_sort_matching_members(search_text, room_members, max_matched_members); + + // 6. Add user mention items + let user_items_limit = max_visible_items.saturating_sub(has_room_item as usize); + let user_items_added = self.add_user_mention_items(cx, matched_members, user_items_limit, is_desktop); + items_added += user_items_added; + + // 7. Update popup visibility based on whether we have items + self.update_popup_visibility(cx, items_added > 0); } - /// Gets whether the current user can notify the entire room (@room mention). - pub fn can_notify_room(&self) -> bool { - self.borrow().is_some_and(|inner| inner.can_notify_room()) + /// Detects valid mention trigger positions in text + fn find_mention_trigger_position(&self, text: &str, cursor_pos: usize) -> Option { + if cursor_pos == 0 { + return None; + } + + // Use utility function to convert byte position to grapheme index + let cursor_grapheme_idx = utils::byte_index_to_grapheme_index(text, cursor_pos); + let text_graphemes: Vec<&str> = text.graphemes(true).collect(); + + // Build byte position mapping to facilitate conversion back to byte positions + let byte_positions = utils::build_grapheme_byte_positions(text); + + // Simple logic: trigger when cursor is immediately after @ symbol + // Only trigger if @ is preceded by whitespace or beginning of text + if cursor_grapheme_idx > 0 && text_graphemes.get(cursor_grapheme_idx - 1) == Some(&"@") { + let is_preceded_by_whitespace_or_start = cursor_grapheme_idx == 1 || + (cursor_grapheme_idx > 1 && text_graphemes.get(cursor_grapheme_idx - 2).is_some_and(|g| g.trim().is_empty())); + if is_preceded_by_whitespace_or_start { + if let Some(&byte_pos) = byte_positions.get(cursor_grapheme_idx - 1) { + return Some(byte_pos); + } + } + } + + // Find the last @ symbol before the cursor for search continuation + // Only continue if we're already in search mode + if self.is_searching { + let last_at_pos = text_graphemes.get(..cursor_grapheme_idx) + .and_then(|slice| slice.iter() + .enumerate() + .filter(|(_, g)| **g == "@") + .map(|(i, _)| i) + .next_back()); + + if let Some(at_idx) = last_at_pos { + // Get the byte position of this @ symbol + let &at_byte_pos = byte_positions.get(at_idx)?; + + // Extract the text after the @ symbol up to the cursor position + let mention_text = text_graphemes.get(at_idx + 1..cursor_grapheme_idx) + .unwrap_or(&[]); + + // Only trigger if this looks like an ongoing mention (contains only alphanumeric and basic chars) + if self.is_valid_mention_text(mention_text) { + return Some(at_byte_pos); + } + } + } + + None } - /// Returns the mentions from selected pills plus any @room mention in the text. - fn get_mentions_from_pills_and_text(&self, text: &str) -> Mentions { - let mut mentions = Mentions::new(); + /// Simple validation for mention text + fn is_valid_mention_text(&self, graphemes: &[&str]) -> bool { + // Allow empty text (for @) + if graphemes.is_empty() { + return true; + } + + // Check if it contains newline characters + !graphemes.iter().any(|g| g.contains('\n')) + } - let Some(inner) = self.borrow() else { - return mentions; - }; + /// Helper function to check if a user matches the search text + /// Checks both display name and Matrix ID for matching + fn user_matches_search(&self, member: &RoomMember, search_text: &str) -> bool { + let search_text_lower = search_text.to_lowercase(); - // Get user IDs from selected pills - let user_ids: BTreeSet = inner.selected_pills - .iter() - .map(|pill| pill.user_id.clone()) - .collect(); + // Check display name + let display_name = member + .display_name() + .map(|n| n.to_string()) + .unwrap_or_else(|| member.user_id().to_string()); - mentions.user_ids = user_ids; - // Check for @room mention in text content - mentions.room = inner.possible_room_mention && text.contains("@room"); - mentions + let display_name_lower = display_name.to_lowercase(); + if display_name_lower.contains(&search_text_lower) { + return true; + } + + // Only match against the localpart (e.g., "mihran" from "@mihran:matrix.org") + // Don't match against the homeserver part to avoid false matches + let localpart = member.user_id().localpart(); + let localpart_lower = localpart.to_lowercase(); + if localpart_lower.contains(&search_text_lower) { + return true; + } + + false } - /// Builds the message text with pill mentions converted to markdown links. - fn build_text_with_pill_mentions(&self, entered_text: &str) -> String { - let Some(inner) = self.borrow() else { - return entered_text.to_string(); - }; + /// Helper function to determine match priority for sorting + /// Lower values = higher priority (better matches shown first) + fn get_match_priority(&self, member: &RoomMember, search_text: &str) -> u8 { + let search_text_lower = search_text.to_lowercase(); + + let display_name = member + .display_name() + .map(|n| n.to_string()) + .unwrap_or_else(|| member.user_id().to_string()); + + let display_name_lower = display_name.to_lowercase(); + let localpart = member.user_id().localpart(); + let localpart_lower = localpart.to_lowercase(); + + // Priority 0: Exact case-sensitive match (highest priority) + if display_name == search_text || localpart == search_text { + return 0; + } + + // Priority 1: Exact match (case-insensitive) + if display_name_lower == search_text_lower || localpart_lower == search_text_lower { + return 1; + } + + // Priority 2: Case-sensitive prefix match + if display_name.starts_with(search_text) || localpart.starts_with(search_text) { + return 2; + } + + // Priority 3: Display name starts with search text (case-insensitive) + if display_name_lower.starts_with(&search_text_lower) { + return 3; + } + + // Priority 4: Localpart starts with search text (case-insensitive) + if localpart_lower.starts_with(&search_text_lower) { + return 4; + } - if inner.selected_pills.is_empty() { - return entered_text.to_string(); + // Priority 5: Display name contains search text at word boundary + if let Some(pos) = display_name_lower.find(&search_text_lower) { + // Check if it's at the start of a word (preceded by space or at start) + if pos == 0 || display_name_lower.chars().nth(pos - 1) == Some(' ') { + return 5; + } } - // Build mention prefix from pills - let mention_prefix: String = inner.selected_pills - .iter() - .map(|pill| format!("[{}]({}) ", pill.display_name, pill.user_id.matrix_to_uri())) - .collect(); + // Priority 6: Localpart contains search text at word boundary + if let Some(pos) = localpart_lower.find(&search_text_lower) { + // Check if it's at the start of a word (preceded by non-alphanumeric or at start) + if pos == 0 || !localpart_lower.chars().nth(pos - 1).unwrap_or('a').is_alphanumeric() { + return 6; + } + } + + // Priority 7: Display name contains search text (anywhere) + if display_name_lower.contains(&search_text_lower) { + return 7; + } - // Prepend pill mentions to the entered text - format!("{}{}", mention_prefix, entered_text) + // Priority 8: Localpart contains search text (anywhere) + if localpart_lower.contains(&search_text_lower) { + return 8; + } + + // Should not reach here if user_matches_search returned true + u8::MAX } - /// Creates a message from the entered text. - /// - /// This mock version handles `/html` and `/plain` prefixes - /// but does not track or extract @mentions (since the mention popup is disabled). + /// Shows the loading indicator when members are being fetched + fn show_loading_indicator(&mut self, cx: &mut Cx) { + // Clear any existing items + self.cmd_text_input.clear_items(cx); + + // Create loading indicator widget + let Some(ptr) = self.loading_indicator else { return }; + let loading_item = widget_ref_from_live_ptr(cx, Some(ptr)); + + // Start the loading animation + loading_item.bouncing_dots(cx, ids!(loading_animation)).start_animation(cx); + + // Add the loading indicator to the popup + self.cmd_text_input.add_item(cx, loading_item); + + // Setup popup dimensions for loading state + let popup = self.cmd_text_input.view(cx, ids!(popup)); + let header_view = self.cmd_text_input.view(cx, ids!(popup.header_view)); + + // Ensure header is visible + header_view.set_visible(cx, true); + + // Don't manually set popup height for loading - let it auto-size based on content + // This avoids conflicts with list: { height: Fill } + popup.set_visible(cx, true); + + // Maintain text input focus + if self.is_searching { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } + } + + /// Shows the no matches indicator when no users match the search + fn show_no_matches_indicator(&mut self, cx: &mut Cx) { + // Clear any existing items + self.cmd_text_input.clear_items(cx); + + // Create no matches indicator widget + let Some(ptr) = self.no_matches_indicator else { return }; + let no_matches_item = widget_ref_from_live_ptr(cx, Some(ptr)); + + // Add the no matches indicator to the popup + self.cmd_text_input.add_item(cx, no_matches_item); + + // Setup popup dimensions for no matches state + let popup = self.cmd_text_input.view(cx, ids!(popup)); + let header_view = self.cmd_text_input.view(cx, ids!(popup.header_view)); + + // Ensure header is visible + header_view.set_visible(cx, true); + let _ = popup; + + // Maintain text input focus so user can continue typing + if self.is_searching { + self.cmd_text_input.text_input_ref().set_key_focus(cx); + } + } + + /// Cleanup helper for closing mention popup + fn close_mention_popup(&mut self, cx: &mut Cx) { + self.current_mention_start_index = None; + self.is_searching = false; + self.members_loading = false; // Reset loading state when closing popup + + // Clear list items to avoid keeping old content when popup is shown again + self.cmd_text_input.clear_items(cx); + + // Get popup and header view references + let popup = self.cmd_text_input.view(cx, ids!(popup)); + let header_view = self.cmd_text_input.view(cx, ids!(popup.header_view)); + + // Force hide header view - necessary when handling deletion operations + // When backspace-deleting mentions, we want to completely hide the header + header_view.set_visible(cx, false); + + // Hide the entire popup + popup.set_visible(cx, false); + let _ = popup; + + // Ensure header view is reset to visible next time it's triggered + // This will happen before update_user_list is called in handle_text_change + + self.cmd_text_input.request_text_input_focus(); + self.redraw(cx); + } + + /// Sets the text content + pub fn set_text(&mut self, cx: &mut Cx, text: &str) { + self.cmd_text_input.text_input_ref().set_text(cx, text); + self.redraw(cx); + } + + /// Sets whether the current user can notify the entire room (@room mention) + pub fn set_can_notify_room(&mut self, can_notify: bool) { + self.can_notify_room = can_notify; + } + + /// Gets whether the current user can notify the entire room (@room mention) + pub fn can_notify_room(&self) -> bool { + self.can_notify_room + } +} + +impl MentionableTextInputRef { + /// Returns a reference to the inner `TextInput` widget. + pub fn text_input_ref(&self) -> TextInputRef { + self.borrow() + .map(|inner| inner.cmd_text_input.text_input_ref()) + .unwrap_or_default() + } + + /// Sets the text content of the input + pub fn set_text(&self, cx: &mut Cx, text: &str) { + if let Some(mut inner) = self.borrow_mut() { + inner.set_text(cx, text); + } + } + + /// Sets whether the current user can notify the entire room (@room mention) + pub fn set_can_notify_room(&self, can_notify: bool) { + if let Some(mut inner) = self.borrow_mut() { + inner.set_can_notify_room(can_notify); + } + } + + /// Gets whether the current user can notify the entire room (@room mention) + pub fn can_notify_room(&self) -> bool { + self.borrow().is_some_and(|inner| inner.can_notify_room()) + } + + /// Processes entered text and creates a message with mentions based on detected message type. + /// This method handles /html, /plain prefixes and defaults to markdown. + /// Pill mentions are converted to markdown links and prepended to the message. pub fn create_message_with_mentions(&self, entered_text: &str) -> RoomMessageEventContent { if let Some(html_text) = entered_text.strip_prefix("/html") { - let full_text = self.build_text_with_pill_mentions(html_text); - let message = RoomMessageEventContent::text_html(&full_text, &full_text); - message.add_mentions(self.get_mentions_from_pills_and_text(&full_text)) + RoomMessageEventContent::text_html(html_text, html_text) } else if let Some(plain_text) = entered_text.strip_prefix("/plain") { + // Plain text messages don't support mentions RoomMessageEventContent::text_plain(plain_text) } else { - let full_text = self.build_text_with_pill_mentions(entered_text); - let message = RoomMessageEventContent::text_markdown(&full_text); - message.add_mentions(self.get_mentions_from_pills_and_text(&full_text)) + RoomMessageEventContent::text_markdown(entered_text) } } - /// Clears all selected pills - pub fn clear_pills(&self, cx: &mut Cx) { + /// Clears all mention tracking state + pub fn clear_pills(&self, _cx: &mut Cx) { if let Some(mut inner) = self.borrow_mut() { - inner.selected_pills.clear(); - inner.pill_widgets.clear(); inner.possible_mentions.clear(); inner.possible_room_mention = false; - inner.render_pills(cx); } } - /// Returns true if there are any selected pills + /// Returns true if there are any tracked mentions + /// Note: This now checks possible_mentions which tracks mentions added during the session pub fn has_pills(&self) -> bool { - self.borrow().is_some_and(|inner| !inner.selected_pills.is_empty()) + self.borrow().is_some_and(|inner| !inner.possible_mentions.is_empty() || inner.possible_room_mention) } + }