Răsfoiți Sursa

app/chatview: select lines with ability to close selection or copy selection

darkfi 1 săptămână în urmă
părinte
comite
46ea04d351

+ 11 - 0
bin/app/src/app/node.rs

@@ -638,6 +638,13 @@ pub fn create_chatview(name: &str) -> SceneNode {
     )
     .unwrap();
 
+    node.add_signal(
+        "select_changed",
+        "Selection presence changed",
+        vec![("selected", "Whether any line is selected", CallArgType::Bool)],
+    )
+    .unwrap();
+
     node.add_method(
         "insert_line",
         vec![
@@ -669,6 +676,10 @@ pub fn create_chatview(name: &str) -> SceneNode {
     )
     .unwrap();
 
+    node.add_method("copy_select", vec![], None).unwrap();
+
+    node.add_method("unselect", vec![], None).unwrap();
+
     node
 }
 

+ 114 - 3
bin/app/src/app/schema/chat.rs

@@ -16,7 +16,7 @@
  * along with this program.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use darkfi_serial::Encodable;
+use darkfi_serial::{Decodable, Encodable};
 #[cfg(feature = "enable-plugin-darkirc")]
 use irc2::Privmsg;
 use sled_overlay::sled;
@@ -211,6 +211,7 @@ pub async fn make(
     cc.add_const_f32("SENDBTN_BOX_1", SENDBTN_BOX[1]);
     cc.add_const_f32("CMD_HELP_HEIGHT", CMD_HELP_HEIGHT);
     cc.add_const_f32("CMD_HELP_GAP", CMD_HELP_GAP);
+    cc.add_const_f32("NETSTATUS_ICON_SIZE", super::NETSTATUS_ICON_SIZE);
 
     // Main view
     let layer_node = create_layer(&(channel.to_string() + "_chat_layer"));
@@ -304,8 +305,8 @@ pub async fn make(
     node.set_property_u32(atom, Role::App, "z_index", 3).unwrap();
 
     let shape = shape::create_back_arrow().scaled(BACKARROW_SCALE);
-    let node = node.setup(|me| VectorArt::new(me, shape, renderer.clone())).await;
-    layer_node.link(node);
+    let back_btn_bg_node = node.setup(|me| VectorArt::new(me, shape, renderer.clone())).await;
+    layer_node.link(back_btn_bg_node.clone());
 
     // Create the back button
     let node = create_button("back_btn");
@@ -621,6 +622,116 @@ pub async fn make(
     });
     layer_node.push_task(listen_file_download);
 
+    // Selection overlay: shown only while the chatview has selected lines. It's
+    // a child of `content` (not the chat layer) with z_index and priority above
+    // the netstatus layer, so its single background box draws over the netstatus
+    // icons and its buttons win click hit-testing. It carries `unselect_btn`
+    // (over `back_btn`) and `copy_btn` (over the reconnect button).
+    let select_layer = create_layer("select_layer");
+    let prop = select_layer.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_f32(atom, Role::App, 3, CHATEDIT_HEIGHT).unwrap();
+    select_layer.set_property_bool(atom, Role::App, "is_visible", false).unwrap();
+    select_layer.set_property_u32(atom, Role::App, "z_index", 100).unwrap();
+    select_layer.set_property_u32(atom, Role::App, "priority", 100).unwrap();
+    let select_layer = select_layer.setup(|me| Layer::new(me, renderer.clone())).await;
+    content.link(select_layer.clone());
+
+    // Single background box covering both buttons (the whole top strip).
+    let node = create_vector_art("select_bg");
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_expr(atom, Role::App, 2, expr::load_var("w")).unwrap();
+    prop.set_f32(atom, Role::App, 3, CHATEDIT_HEIGHT).unwrap();
+    node.set_property_u32(atom, Role::App, "z_index", 0).unwrap();
+    let bg_color = match COLOR_SCHEME {
+        ColorScheme::DarkMode => [0., 0.11, 0.11, 1.],
+        ColorScheme::PaperLight => [1., 1., 1., 1.],
+    };
+    let mut shape = VectorShape::new();
+    /*
+    shape.add_filled_box(
+        expr::const_f32(0.),
+        expr::const_f32(0.),
+        expr::load_var("w"),
+        expr::load_var("h"),
+        bg_color,
+    );
+    */
+    let node = node.setup(|me| VectorArt::new(me, shape, renderer.clone())).await;
+    select_layer.link(node);
+
+    // unselect_btn sits over the back button and calls the chatview's unselect.
+    let node = create_button("unselect_btn");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    prop.set_f32(atom, Role::App, 0, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 2, EMOJI_BG_W).unwrap();
+    prop.set_f32(atom, Role::App, 3, CHATEDIT_HEIGHT).unwrap();
+    {
+        let chatview_node2 = chatview_node.clone();
+        let (slot, recvr) = Slot::new("unselect_clicked");
+        node.register("click", slot).unwrap();
+        let listen_click = ex.spawn(async move {
+            while let Ok(_) = recvr.recv().await {
+                let _ = chatview_node2.call_method("unselect", vec![]).await;
+            }
+        });
+        select_layer.push_task(listen_click);
+    }
+    let node = node.setup(|me| Button::new(me, renderer.clone())).await;
+    select_layer.link(node);
+
+    // copy_btn sits over the reconnect button and calls the chatview's
+    // copy_select (which also deselects, hiding this overlay again).
+    let node = create_button("copy_btn");
+    node.set_property_bool(atom, Role::App, "is_active", true).unwrap();
+    let prop = node.get_property("rect").unwrap();
+    let code = cc.compile("w - NETSTATUS_ICON_SIZE").unwrap();
+    prop.set_expr(atom, Role::App, 0, code).unwrap();
+    prop.set_f32(atom, Role::App, 1, 0.).unwrap();
+    prop.set_f32(atom, Role::App, 2, super::NETSTATUS_ICON_SIZE).unwrap();
+    prop.set_f32(atom, Role::App, 3, super::NETSTATUS_ICON_SIZE).unwrap();
+    {
+        let chatview_node2 = chatview_node.clone();
+        let (slot, recvr) = Slot::new("copy_clicked");
+        node.register("click", slot).unwrap();
+        let listen_click = ex.spawn(async move {
+            while let Ok(_) = recvr.recv().await {
+                chatview_node2.call_method("copy_select", vec![]).await.unwrap();
+            }
+        });
+        select_layer.push_task(listen_click);
+    }
+    let node = node.setup(|me| Button::new(me, renderer.clone())).await;
+    select_layer.link(node);
+
+    // Show/hide the overlay from the chatview's select_changed signal.
+    let select_is_visible = PropertyBool::wrap(&select_layer, Role::App, "is_visible", 0).unwrap();
+    let back_btn_bg_node2 = back_btn_bg_node.clone();
+    let sg_root2 = sg_root.clone();
+    let renderer2 = renderer.clone();
+    let (slot, recvr) = Slot::new("select_changed_slot");
+    chatview_node.register("select_changed", slot).unwrap();
+    let listen_select = ex.spawn(async move {
+        while let Ok(data) = recvr.recv().await {
+            let Ok(selected) = bool::decode(&mut std::io::Cursor::new(&data)) else { continue };
+            let atom = &mut renderer2.make_guard(gfxtag!("select_changed"));
+            select_is_visible.set(atom, selected);
+            back_btn_bg_node2.set_property_bool(atom, Role::App, "is_visible", !selected).unwrap();
+            if let Some(netstatus_layer) = sg_root2.lookup_node("/window/content/netstatus_layer") {
+                netstatus_layer
+                    .set_property_bool(atom, Role::App, "is_visible", !selected)
+                    .unwrap();
+            }
+        }
+    });
+    select_layer.push_task(listen_select);
+
     // Create the editbox bg
     let node = create_vector_art("editbox_bg");
     let prop = node.get_property("rect").unwrap();

+ 4 - 0
bin/app/src/clipboard.rs

@@ -24,6 +24,8 @@ use std::time::Duration;
 #[cfg(target_os = "linux")]
 static X11_CLIPBOARD: Mutex<Option<Arc<x11_clipboard::Clipboard>>> = Mutex::new(None);
 
+macro_rules! t { ($($arg:tt)*) => { trace!(target: "clipboard", $($arg)*); } }
+
 #[cfg(target_os = "linux")]
 fn get_clipboard() -> Option<Arc<x11_clipboard::Clipboard>> {
     let mut clipboard = X11_CLIPBOARD.lock().unwrap();
@@ -54,10 +56,12 @@ pub fn get() -> Option<String> {
 pub fn set(text: &str) {
     #[cfg(target_os = "linux")]
     if let Some(clipboard) = get_clipboard() {
+        //t!("setting X11 clipboard");
         if clipboard
             .store(clipboard.setter.atoms.clipboard, clipboard.setter.atoms.utf8_string, text)
             .is_ok()
         {
+            //t!("set X11 clipboard!");
             return
         }
     }

+ 179 - 8
bin/app/src/ui/chatview/mod.rs

@@ -42,6 +42,7 @@ pub use page::FileMessageStatus;
 use page::MessageBuffer;
 
 use crate::{
+    clipboard,
     gfx::{gfxtag, DrawCall, DrawInstruction, Point, Rectangle, RenderApi, Renderer},
     mesh::{Color, MeshBuilder},
     prop::{
@@ -60,8 +61,17 @@ macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview", $($arg)*); }
 const EPSILON: f32 = 0.001;
 const BIG_EPSILON: f32 = 0.05;
 
-// Disable selecting lines for this release.
-const ENABLE_SELECT: bool = false;
+/// Mouse must move more than this many pixels while held to count as a drag
+/// (which only selects) rather than a click (which toggles).
+const SELECT_DRAG_THRESHOLD: f32 = 2.;
+
+/// Tracks an in-progress mouse selection gesture so we can distinguish a
+/// stationary click (toggles the line) from a drag (only ever selects).
+struct SelectDrag {
+    down_y: f32,
+    was_selected: bool,
+    dragged: bool,
+}
 
 fn is_zero(x: f32) -> bool {
     x.abs() < EPSILON
@@ -232,6 +242,13 @@ pub struct ChatView {
 
     mouse_btn_held: AtomicBool,
 
+    /// In-progress mouse selection gesture (set on left button down).
+    select_drag: SyncMutex<Option<SelectDrag>>,
+
+    /// Last reported selection state, used to emit `select_changed` only on
+    /// transitions between having and not having a selection.
+    select_active: AtomicBool,
+
     /// Triggers the background loading task to wake up.
     /// We use this since there should only ever be a single bg task loading.
     bgload_cv: Arc<CondVar>,
@@ -372,6 +389,10 @@ impl ChatView {
 
             mouse_btn_held: AtomicBool::new(false),
 
+            select_drag: SyncMutex::new(None),
+
+            select_active: AtomicBool::new(false),
+
             bgload_cv,
 
             parent_rect: SyncMutex::new(None),
@@ -489,6 +510,42 @@ impl ChatView {
         true
     }
 
+    async fn process_copy_select_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            d!("Event relayer closed");
+            return false
+        };
+
+        t!("method called: copy_select({method_call:?})");
+        assert!(method_call.send_res.is_none());
+        assert!(method_call.data.is_empty());
+
+        let Some(self_) = me.upgrade() else {
+            panic!("self destroyed before copy_select_method_task was stopped!");
+        };
+
+        self_.handle_copy_select().await;
+        true
+    }
+
+    async fn process_unselect_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
+        let Ok(method_call) = sub.receive().await else {
+            d!("Event relayer closed");
+            return false
+        };
+
+        t!("method called: unselect({method_call:?})");
+        assert!(method_call.send_res.is_none());
+        assert!(method_call.data.is_empty());
+
+        let Some(self_) = me.upgrade() else {
+            panic!("self destroyed before unselect_method_task was stopped!");
+        };
+
+        self_.handle_unselect().await;
+        true
+    }
+
     fn to_msgbuf_pos(&self, pos: Point) -> Point {
         let mut x = pos.x;
         let mut y = pos.y;
@@ -517,9 +574,79 @@ impl ChatView {
         y = rect.h - y + scroll;
 
         let mut msgbuf = self.msgbuf.lock().await;
+        let had = msgbuf.has_selection();
         msgbuf.select_line(y).await;
 
         self.redraw_cached(batch_id, &mut msgbuf).await;
+        let has = msgbuf.has_selection();
+        drop(msgbuf);
+
+        self.notify_select_changed(has, had).await;
+    }
+
+    /// Mark line as deselected
+    #[instrument(target = "ui::chatview")]
+    async fn deselect_line(&self, batch_id: BatchGuardId, mut y: f32) {
+        let rect = self.rect.get();
+        y -= rect.y;
+        let scroll = self.scroll.get();
+        y = rect.h - y + scroll;
+
+        let mut msgbuf = self.msgbuf.lock().await;
+        let had = msgbuf.has_selection();
+        msgbuf.deselect_line(y).await;
+
+        self.redraw_cached(batch_id, &mut msgbuf).await;
+        let has = msgbuf.has_selection();
+        drop(msgbuf);
+
+        self.notify_select_changed(has, had).await;
+    }
+
+    /// Query whether the line under screen y is currently selected.
+    async fn is_line_selected(&self, screen_y: f32) -> bool {
+        let y = self.to_msgbuf_pos(Point::new(0., screen_y)).y;
+        let mut msgbuf = self.msgbuf.lock().await;
+        msgbuf.is_line_selected(y).await
+    }
+
+    /// Emit `select_changed(true/false)` whenever the presence of any selected
+    /// line transitions. `has` is the current state, `had` the previous one.
+    async fn notify_select_changed(&self, has: bool, had: bool) {
+        if has == had {
+            return
+        }
+        self.select_active.store(has, Ordering::Relaxed);
+        let Some(node_ref) = self.node.upgrade() else { return };
+        let mut data = vec![];
+        has.encode(&mut data).unwrap();
+        let _ = node_ref.trigger("select_changed", data).await;
+    }
+
+    /// Copy the currently selected messages' text to the clipboard.
+    async fn handle_copy_select(&self) {
+        let msgbuf = self.msgbuf.lock().await;
+        let text = msgbuf.selected_text();
+        drop(msgbuf);
+        if !text.is_empty() {
+            t!("handle_copy_select() [text={text}]");
+            clipboard::set(&text);
+        }
+        // Also unselect the existing selected text
+        self.handle_unselect().await
+    }
+
+    /// Deselect every selected message and redraw.
+    async fn handle_unselect(&self) {
+        let atom = self.renderer.make_guard(gfxtag!("ChatView::unselect"));
+        let mut msgbuf = self.msgbuf.lock().await;
+        let had = msgbuf.has_selection();
+        msgbuf.unselect_all();
+        self.redraw_cached(atom.batch_id, &mut msgbuf).await;
+        let has = msgbuf.has_selection();
+        drop(msgbuf);
+
+        self.notify_select_changed(has, had).await;
     }
 
     fn end_touch_phase(&self, touch_y: f32) {
@@ -896,7 +1023,7 @@ impl ChatView {
     /// The overlay is emitted inline from `redraw_cached` (so it inherits the
     /// chatview's position), hence we trigger a full redraw on show/hide.
     async fn show_toast(&self, url: &str, anchor: Point) {
-        crate::clipboard::set(url);
+        clipboard::set(url);
 
         let text = self.url_copy_text.get();
         let fg = self.url_copy_fg_color.get();
@@ -1031,6 +1158,18 @@ impl UIObject for ChatView {
             while Self::process_set_file_status_method(&me2, &method_sub).await {}
         });
 
+        let method_sub = node_ref.subscribe_method_call("copy_select").unwrap();
+        let me2 = me.clone();
+        let copy_select_method_task =
+            ex.spawn(
+                async move { while Self::process_copy_select_method(&me2, &method_sub).await {} },
+            );
+
+        let method_sub = node_ref.subscribe_method_call("unselect").unwrap();
+        let me2 = me.clone();
+        let unselect_method_task = ex
+            .spawn(async move { while Self::process_unselect_method(&me2, &method_sub).await {} });
+
         let mut on_modify = OnModify::new(ex, self.node.clone(), me.clone());
 
         async fn reload_view(self_: Arc<ChatView>, batch: BatchGuardPtr) {
@@ -1066,6 +1205,8 @@ impl UIObject for ChatView {
             motion_task,
             bgload_task,
             set_file_status_method_task,
+            copy_select_method_task,
+            unselect_method_task,
         ];
         tasks.append(&mut on_modify.tasks);
 
@@ -1177,9 +1318,16 @@ impl UIObject for ChatView {
 
         let atom = self.renderer.make_guard(gfxtag!("ChatView::handle_mouse_btn_down"));
 
-        if ENABLE_SELECT {
+        // Query whether the clicked line is already selected. We select
+        // immediately only if it wasn't (for instant feedback). If it was
+        // already selected we leave it and let handle_mouse_btn_up decide:
+        // a stationary click toggles it off, but a drag keeps it selected.
+        let was_selected = self.is_line_selected(mouse_pos.y).await;
+        if !was_selected {
             self.select_line(atom.batch_id, mouse_pos.y).await;
         }
+        *self.select_drag.lock() =
+            Some(SelectDrag { down_y: mouse_pos.y, was_selected, dragged: false });
         self.mouse_btn_held.store(true, Ordering::Relaxed);
         true
     }
@@ -1206,6 +1354,17 @@ impl UIObject for ChatView {
         }
 
         self.mouse_btn_held.store(false, Ordering::Relaxed);
+
+        // A stationary click on an already-selected line deselects it. A drag
+        // (or a click on an unselected line) leaves selection as-is.
+        let drag = self.select_drag.lock().take();
+        if let Some(d) = drag {
+            if !d.dragged && d.was_selected {
+                let atom = self.renderer.make_guard(gfxtag!("ChatView::handle_mouse_btn_up"));
+                self.deselect_line(atom.batch_id, mouse_pos.y).await;
+            }
+        }
+
         false
     }
 
@@ -1224,7 +1383,21 @@ impl UIObject for ChatView {
             return false
         }
 
-        if ENABLE_SELECT {
+        // Dragging only ever selects. Once the mouse moves past the
+        // threshold we latch `dragged` so the upcoming mouse-up won't
+        // treat this as a toggling click.
+        let dragged = {
+            let mut drag = self.select_drag.lock();
+            if let Some(d) = drag.as_mut() {
+                if (mouse_pos.y - d.down_y).abs() > SELECT_DRAG_THRESHOLD {
+                    d.dragged = true;
+                }
+                d.dragged
+            } else {
+                false
+            }
+        };
+        if dragged {
             let atom = &mut self.renderer.make_guard(gfxtag!("ChatView::handle_mouse_move"));
             self.select_line(atom.batch_id, mouse_pos.y).await;
         }
@@ -1345,9 +1518,7 @@ impl UIObject for ChatView {
 
                 // We are in selection mode so don't scroll the screen until touch phase ends.
                 if is_select_mode == Some(true) {
-                    if ENABLE_SELECT {
-                        self.select_line(atom.batch_id, touch_y).await;
-                    }
+                    self.select_line(atom.batch_id, touch_y).await;
                     return true
                 }
 

+ 81 - 5
bin/app/src/ui/chatview/page.rs

@@ -237,11 +237,8 @@ impl PrivMessage {
         if self.is_selected {
             let height = self.height(line_height) + msg_spacing;
             let mut mesh = MeshBuilder::new(gfxtag!("chatview_privmsg_sel"));
-            mesh.draw_filled_box(
-                &Rectangle { x: 0., y: -height, w: clip.w, h: height },
-                hi_bg_color,
-            );
-            all_instrs.push(DrawInstruction::Draw(mesh.alloc(renderer).draw_with_textures(vec![])));
+            mesh.draw_filled_box(&Rectangle { x: 0., y: 0., w: clip.w, h: height }, hi_bg_color);
+            all_instrs.push(DrawInstruction::Draw(mesh.alloc(renderer).draw_untextured()));
         }
 
         // Render timestamp
@@ -301,6 +298,14 @@ impl PrivMessage {
         self.is_selected = true;
     }
 
+    fn deselect(&mut self) {
+        self.is_selected = false;
+    }
+
+    fn is_selected(&self) -> bool {
+        self.is_selected
+    }
+
     /// Build the URL hit-rectangles for this message, in message-local coordinates.
     /// Each URL-colored glyph run (`style().brush == url_text_color`) becomes a rect
     /// `(timestamp_width + run.offset, run.baseline - ascent, run.advance,
@@ -970,6 +975,21 @@ impl Message {
         }
     }
 
+    fn deselect(&mut self) {
+        match self {
+            Self::Priv(m) => m.deselect(),
+            Self::Date(_) => {}
+            Self::File(_) => {}
+        }
+    }
+
+    fn is_selected(&self) -> bool {
+        match self {
+            Self::Priv(m) => m.is_selected(),
+            _ => false,
+        }
+    }
+
     fn get_privmsg_mut(&mut self) -> Option<&mut PrivMessage> {
         match self {
             Message::Priv(msg) => Some(msg),
@@ -1510,6 +1530,62 @@ impl MessageBuffer {
         }
     }
 
+    pub async fn deselect_line(&mut self, y: f32) {
+        if let Some((msg, _)) = self.get_line(y).await {
+            if msg.is_date() {
+                return
+            }
+
+            msg.deselect();
+
+            msg.clear_mesh();
+        }
+    }
+
+    pub async fn is_line_selected(&mut self, y: f32) -> bool {
+        if let Some((msg, _)) = self.get_line(y).await {
+            if msg.is_date() {
+                return false
+            }
+            return msg.is_selected()
+        }
+        false
+    }
+
+    /// Whether any message is currently selected.
+    pub fn has_selection(&self) -> bool {
+        self.msgs.iter().any(|msg| msg.is_selected())
+    }
+
+    /// Deselect every selected message.
+    pub fn unselect_all(&mut self) {
+        for msg in &mut self.msgs {
+            if msg.is_selected() {
+                msg.deselect();
+                msg.clear_mesh();
+            }
+        }
+    }
+
+    /// Concatenated text of all selected messages, joined by newlines, in
+    /// display order. NOTICE messages contribute their body; privmsgs
+    /// contribute "<nick> <text>".
+    pub fn selected_text(&self) -> String {
+        let mut lines = vec![];
+        for msg in &self.msgs {
+            if let Message::Priv(p) = msg {
+                if p.is_selected {
+                    if p.nick == "NOTICE" {
+                        lines.push(p.text.clone());
+                    } else {
+                        lines.push(format!("{} {}", p.nick, p.text));
+                    }
+                }
+            }
+        }
+        lines.join("\n")
+    }
+
     pub fn update_file_status(&mut self, url: &Url, status: &FileMessageStatus) {
         for msg in &mut self.msgs {
             if let Some(filemsg) = msg.get_filemsg_mut() {