mod.rs 66 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795
  1. /* This file is part of DarkFi (https://dark.fi)
  2. *
  3. * Copyright (C) 2020-2026 Dyne.org foundation
  4. *
  5. * This program is free software: you can redistribute it and/or modify
  6. * it under the terms of the GNU Affero General Public License as
  7. * published by the Free Software Foundation, either version 3 of the
  8. * License, or (at your option) any later version.
  9. *
  10. * This program is distributed in the hope that it will be useful,
  11. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  13. * GNU Affero General Public License for more details.
  14. *
  15. * You should have received a copy of the GNU Affero General Public License
  16. * along with this program. If not, see <https://www.gnu.org/licenses/>.
  17. */
  18. //! The chatview widget: a virtualized, scrollable chat log with typed
  19. //! interactive messages. See the `app-chatview` OpenSpec change for the
  20. //! behavioral contract this module implements.
  21. //!
  22. //! Module map (one concern per file):
  23. //!
  24. //! * [`buffer`] — record store: ordering, dedup, geometry. Pure data.
  25. //! * [`codec`] — the kvdb wire format.
  26. //! * [`scroll`] — scroll controller: gestures, animation, clamping,
  27. //! compensation, save/restore.
  28. //! * `loader` — kvdb → filter → buffer coverage maintenance.
  29. //! * `msg` — message type contract, registry, and per-type nodes.
  30. //!
  31. //! This file is the scene node: properties, view-wide methods and
  32. //! signals, input dispatch, draw assembly, and the animator. It
  33. //! orchestrates; it owns nothing heavy.
  34. use async_lock::Mutex as AsyncMutex;
  35. use async_trait::async_trait;
  36. use darkfi::system::CondVar;
  37. use darkfi_serial::{Decodable, Encodable, SerialDecodable, SerialEncodable};
  38. use kvdb_overlay::{Database as KvDb, Tree};
  39. use parking_lot::Mutex as SyncMutex;
  40. use rand::{rngs::OsRng, Rng};
  41. use smol::Timer;
  42. use std::{
  43. collections::{HashMap, HashSet},
  44. io::Cursor,
  45. sync::{
  46. atomic::{AtomicBool, Ordering},
  47. Arc, Weak,
  48. },
  49. time::Instant,
  50. };
  51. use miniquad::{KeyCode, KeyMods, MouseButton};
  52. use crate::{
  53. gfx::{gfxtag, DrawCall, DrawInstruction, Point, Rectangle, RenderApi, Renderer},
  54. mesh::{Color, MeshBuilder},
  55. prop::{
  56. PropertyAtomicGuard, PropertyBool, PropertyColor, PropertyFloat32, PropertyRect,
  57. PropertyStr, PropertyUint32, Role,
  58. },
  59. scene::{MethodCallSub, Pimpl, SceneNodeWeak},
  60. text,
  61. util::{clipboard, i18n::I18nBabelFish},
  62. ExecutorPtr,
  63. };
  64. use super::{
  65. DrawUpdate, GestureAction, GestureSet, OnModify, PressedKey, PressedKeysSmoothRepeat,
  66. RedrawTrigger, UIObject,
  67. };
  68. pub mod buffer;
  69. pub mod codec;
  70. pub mod loader;
  71. pub mod msg;
  72. pub mod scroll;
  73. pub use buffer::{MsgBuffer, MsgRecord};
  74. pub use loader::{FilterFn, Loader, Wakeup};
  75. pub use scroll::{Anchor, ScrollController, ScrollState};
  76. macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::chatview", $($arg)*); } }
  77. macro_rules! i { ($($arg:tt)*) => { info!(target: "ui::chatview", $($arg)*); } }
  78. /// Unix-millisecond message timestamps.
  79. pub type Timestamp = u64;
  80. /// A message's unique identity. The 32 bytes come from darkirc's
  81. /// message hash; derived records (date separators) use the zero id with
  82. /// a synthetic timestamp instead.
  83. #[derive(
  84. Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, SerialEncodable, SerialDecodable,
  85. )]
  86. pub struct MessageId(pub [u8; 32]);
  87. impl std::fmt::Display for MessageId {
  88. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  89. for b in &self.0 {
  90. write!(f, "{b:02x}")?
  91. }
  92. Ok(())
  93. }
  94. }
  95. /// Every message type, hardcoded — a fixed enum, no factories. The wire
  96. /// tag IS the discriminant (`#[repr(u8)]`): encode with `as u8`, decode
  97. /// via [`codec::msg_type_from_u8`].
  98. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
  99. #[repr(u8)]
  100. pub enum MsgType {
  101. PrivMsg = 0,
  102. FileMsg = 1,
  103. DateMsg = 2,
  104. }
  105. impl MsgType {
  106. /// Whether records of this type are derived from other records
  107. /// rather than persisted. Derived records never enter the id index:
  108. /// their synthetic ids (e.g. the date separators' zero id) are not
  109. /// unique across records.
  110. pub fn is_derived(&self) -> bool {
  111. matches!(self, Self::DateMsg)
  112. }
  113. }
  114. pub type ChatViewPtr = Arc<ChatView>;
  115. /// The chatview scene node. View-wide concerns only: channel
  116. /// switching, filtering, selection state, scrolling surface, and draw
  117. /// assembly of the visible window. Message lifecycle operations live on
  118. /// the per-type sub-nodes.
  119. pub struct ChatView {
  120. node: SceneNodeWeak,
  121. tasks: SyncMutex<Vec<smol::Task<()>>>,
  122. renderer: Renderer,
  123. redraw: RedrawTrigger,
  124. ex: ExecutorPtr,
  125. /// The chat kvdb; channel trees are opened from it on demand.
  126. kv_db: KvDb,
  127. /// The bound channel and its tree.
  128. channel: SyncMutex<Option<(String, Tree)>>,
  129. buffer: Arc<AsyncMutex<MsgBuffer>>,
  130. controller: SyncMutex<ScrollController>,
  131. /// Wakes the animator task when motion begins.
  132. motion_cv: Arc<CondVar>,
  133. /// The single background loading pipeline.
  134. loader: Arc<Loader>,
  135. /// The per-type message nodes, created right after construction
  136. /// (they need a weak self reference, which only exists then).
  137. types: std::sync::OnceLock<Arc<msg::TypeNodes>>,
  138. /// Per-channel scroll anchors, saved on channel exit.
  139. channel_state: SyncMutex<HashMap<String, Anchor>>,
  140. /// Anchor awaiting enough coverage to resolve on re-entry.
  141. pending_restore: SyncMutex<Option<Anchor>>,
  142. dc_key: u64,
  143. rect: PropertyRect,
  144. z_index: PropertyUint32,
  145. priority: PropertyUint32,
  146. is_at_bottom: PropertyBool,
  147. hi_bg_color: PropertyColor,
  148. wheel_page_frac: PropertyFloat32,
  149. channel_prop: crate::prop::PropertyStr,
  150. /// Selected messages by composite key; view-wide, type-agnostic.
  151. /// The composite (not the bare id) because derived records share
  152. /// synthetic ids.
  153. selected: SyncMutex<HashSet<(Timestamp, MessageId)>>,
  154. /// Last reported selection presence, for `select_changed` edges.
  155. select_active: AtomicBool,
  156. /// Mouse position for wheel hit-testing.
  157. mouse_pos: SyncMutex<Point>,
  158. mouse_btn_held: AtomicBool,
  159. /// In-progress mouse selection gesture.
  160. select_drag: SyncMutex<Option<SelectDrag>>,
  161. /// What the fired long-press chose for this touch.
  162. lp_mode: SyncMutex<LongPressMode>,
  163. /// Active copy-link overlay (None when hidden).
  164. link_toast: SyncMutex<Option<LinkToast>>,
  165. /// Re-arm counter so a stale dismiss task won't clear a newer toast.
  166. toast_version: std::sync::atomic::AtomicU32,
  167. /// Window scale (for building the toast text layout).
  168. window_scale: PropertyFloat32,
  169. /// Geometry the last draw pass saw, for reflow detection.
  170. last_width: SyncMutex<f32>,
  171. last_scale: SyncMutex<f32>,
  172. /// Smooth repeat for held PageUp/PageDown scrolling.
  173. key_repeat: SyncMutex<PressedKeysSmoothRepeat>,
  174. /// Weak self-reference so handlers can spawn detached tasks.
  175. me: Weak<Self>,
  176. }
  177. /// Mouse must move more than this many pixels while held to count as a
  178. /// drag (which only selects) rather than a click (which toggles).
  179. const SELECT_DRAG_THRESHOLD: f32 = 2.;
  180. /// Records of soft margin above/below the visible range kept
  181. /// materialized (the virtualization window's give).
  182. const SOFT_WINDOW_MARGIN: usize = 40;
  183. /// Out-of-window instances kept per type, most recently used first.
  184. const INSTANCE_BUDGET: usize = 150;
  185. /// Tracks an in-progress mouse selection gesture so we can distinguish a
  186. /// stationary click (toggles the line) from a drag (only ever selects).
  187. struct SelectDrag {
  188. down_y: f32,
  189. was_selected: bool,
  190. dragged: bool,
  191. }
  192. /// What the fired long-press chose for this touch.
  193. #[derive(Clone, Copy, PartialEq)]
  194. enum LongPressMode {
  195. /// No long-press fired
  196. None,
  197. /// Line selection started; drags extend it, no scroll inertia
  198. Select,
  199. /// URL copied with a toast; drags scroll, inertia allowed
  200. UrlToast,
  201. }
  202. /// Transient "Copied link" overlay state. Rendered as an
  203. /// `DrawInstruction::Overlay` in its own draw call so it floats above
  204. /// the link and escapes the message clip rect.
  205. struct LinkToast {
  206. text_layout: text::TextLayout,
  207. anchor: Point,
  208. offset: f32,
  209. fg_color: Color,
  210. bg_color: Color,
  211. font_size: f32,
  212. padding: f32,
  213. }
  214. impl LinkToast {
  215. /// The overlay draw-instructions: a filled background box with an
  216. /// fg-colored outline and the label centered inside, drawn above
  217. /// the anchor point (shifted up by `offset`).
  218. fn build_instrs(&self, renderer: &Renderer) -> Vec<DrawInstruction> {
  219. let text_w = self.text_layout.width();
  220. let text_h = self.text_layout.height();
  221. let pad = self.padding;
  222. let box_w = text_w + 2. * pad;
  223. let box_h = self.font_size + 2. * pad;
  224. // Box bottom sits `offset` above the anchor (negative y is up).
  225. let pos = Point::new(self.anchor.x, self.anchor.y - self.offset);
  226. let mut instrs = vec![DrawInstruction::Move(pos)];
  227. // Box: top at -box_h, bottom at 0.
  228. instrs.push(DrawInstruction::Move(Point::new(0., -box_h)));
  229. let bg_rect = Rectangle::new(0., 0., box_w, box_h);
  230. let mut mesh = MeshBuilder::new(gfxtag!("chatview_urltoast_bg"));
  231. mesh.draw_filled_box(&bg_rect, self.bg_color);
  232. mesh.draw_outline(&bg_rect, self.fg_color, 1.);
  233. instrs.push(DrawInstruction::Draw(mesh.alloc(renderer).draw_untextured()));
  234. // Label: inset horizontally by padding, centered vertically.
  235. let text_y = (box_h - text_h) / 2.;
  236. instrs.push(DrawInstruction::Move(Point::new(pad, text_y)));
  237. let mut txt_instrs =
  238. text::render_layout(&self.text_layout, renderer, gfxtag!("chatview_urltoast_txt"));
  239. instrs.append(&mut txt_instrs);
  240. instrs
  241. }
  242. }
  243. impl ChatView {
  244. pub async fn new(
  245. node: SceneNodeWeak,
  246. kv_db: KvDb,
  247. window_scale: PropertyFloat32,
  248. i18n_fish: I18nBabelFish,
  249. renderer: Renderer,
  250. redraw: RedrawTrigger,
  251. ex: ExecutorPtr,
  252. ) -> Pimpl {
  253. let node_ref = &node.upgrade().unwrap();
  254. let rect = PropertyRect::wrap(node_ref, Role::Internal, "rect").unwrap();
  255. let z_index = PropertyUint32::wrap(node_ref, Role::Internal, "z_index", 0).unwrap();
  256. let priority = PropertyUint32::wrap(node_ref, Role::Internal, "priority", 0).unwrap();
  257. let is_at_bottom = PropertyBool::wrap(node_ref, Role::Internal, "is_at_bottom", 0).unwrap();
  258. let buffer = Arc::new(AsyncMutex::new(MsgBuffer::new()));
  259. let loader = Loader::new(buffer.clone(), redraw.clone());
  260. let self_ = Arc::new_cyclic(|me| Self {
  261. node: node.clone(),
  262. tasks: SyncMutex::new(vec![]),
  263. renderer: renderer.clone(),
  264. redraw: redraw.clone(),
  265. ex,
  266. kv_db,
  267. channel: SyncMutex::new(None),
  268. buffer,
  269. controller: SyncMutex::new(ScrollController::new()),
  270. motion_cv: Arc::new(CondVar::new()),
  271. loader,
  272. types: std::sync::OnceLock::new(),
  273. channel_state: SyncMutex::new(HashMap::new()),
  274. pending_restore: SyncMutex::new(None),
  275. dc_key: OsRng.gen(),
  276. rect,
  277. z_index,
  278. priority,
  279. is_at_bottom,
  280. hi_bg_color: PropertyColor::wrap(node_ref, Role::Internal, "hi_bg_color")
  281. .expect("chatview hi_bg_color"),
  282. wheel_page_frac: PropertyFloat32::wrap(node_ref, Role::Internal, "wheel_page_frac", 0)
  283. .expect("chatview wheel_page_frac"),
  284. channel_prop: crate::prop::PropertyStr::wrap(node_ref, Role::Internal, "channel", 0)
  285. .expect("chatview channel"),
  286. selected: SyncMutex::new(HashSet::new()),
  287. select_active: AtomicBool::new(false),
  288. mouse_pos: SyncMutex::new(Point::from([0., 0.])),
  289. mouse_btn_held: AtomicBool::new(false),
  290. select_drag: SyncMutex::new(None),
  291. lp_mode: SyncMutex::new(LongPressMode::None),
  292. link_toast: SyncMutex::new(None),
  293. toast_version: std::sync::atomic::AtomicU32::new(0),
  294. window_scale: window_scale.clone(),
  295. last_width: SyncMutex::new(0.),
  296. last_scale: SyncMutex::new(window_scale.get()),
  297. key_repeat: SyncMutex::new(PressedKeysSmoothRepeat::new(400, 50)),
  298. me: me.clone(),
  299. });
  300. // The type nodes are children of this node; they need a weak
  301. // self, so they can only be created once `self_` exists.
  302. let shared = msg::SharedProps::wrap(&node.upgrade().unwrap(), window_scale);
  303. let types = msg::TypeNodes::new(
  304. &node.upgrade().unwrap(),
  305. shared,
  306. i18n_fish,
  307. self_.loader.clone(),
  308. self_.buffer.clone(),
  309. Arc::downgrade(&self_),
  310. )
  311. .await;
  312. let types = Arc::new(types);
  313. let loader_measure = types.clone();
  314. self_.loader.set_measure(Arc::new(move |rec| loader_measure.measure(rec)));
  315. self_.loader.set_derive(Arc::new(|rec: &MsgRecord| {
  316. let (_, text, _) = codec::decode_privmsg_payload(&rec.payload, rec.ts, &rec.id);
  317. msg::filemsg::derive_filemsg(rec, &text)
  318. }));
  319. if self_.types.set(types).is_err() {
  320. panic!("type nodes set twice")
  321. }
  322. self_.sync_is_at_bottom();
  323. Pimpl::ChatView(self_)
  324. }
  325. /// The type-node registry.
  326. fn types(&self) -> &Arc<msg::TypeNodes> {
  327. self.types.get().expect("type nodes initialized")
  328. }
  329. /// Derive a file message from a just-inserted privmsg whose text
  330. /// carries a fud URL: buffer insert + measure + signal.
  331. pub async fn derive_filemsg(&self, privmsg: &MsgRecord, nick: &str, text: &str) {
  332. let Some(file_rec) = msg::filemsg::derive_filemsg(privmsg, text) else { return };
  333. let url =
  334. msg::filemsg::decode_filemsg_payload(&file_rec.payload, file_rec.ts, &file_rec.id);
  335. let height = self.types().filemsg.measure(&file_rec);
  336. let mut buffer = self.buffer.lock().await;
  337. if !buffer.insert(MsgRecord { height, ..file_rec }) {
  338. return
  339. }
  340. drop(buffer);
  341. if let Some(node) = self.types().filemsg.node().upgrade() {
  342. let mut data = vec![];
  343. darkfi_serial::Encodable::encode(&url, &mut data).unwrap();
  344. let _ = node.trigger("fileurl_detected", data).await;
  345. }
  346. t!("derived filemsg for {nick}: {url}");
  347. }
  348. /// The channel tree name for a channel: `{channel}__chat_tree_v2`.
  349. /// Versioned so the clean-break format never reads old trees.
  350. pub fn tree_name(channel: &str) -> String {
  351. format!("{channel}__chat_tree_v2")
  352. }
  353. /// Update the `is_at_bottom` property when the position crosses the
  354. /// bottom. Externally visible state for the down-arrow layer.
  355. fn sync_is_at_bottom(&self) {
  356. let at_bottom = self.controller.lock().is_at_bottom();
  357. if self.is_at_bottom.get() == at_bottom {
  358. return
  359. }
  360. let atom = &mut self.redraw.make_guard(gfxtag!("chatview is_at_bottom"));
  361. self.is_at_bottom.set(atom, at_bottom);
  362. }
  363. /// Wake the animator: motion may have begun.
  364. fn notify_motion(&self) {
  365. self.motion_cv.notify();
  366. }
  367. /// Convert a screen-space y to content px from the bottom.
  368. async fn content_y(&self, screen_y: f32) -> f32 {
  369. let rect = self.rect.get();
  370. let scroll = self.controller.lock().scroll();
  371. rect.h - (screen_y - rect.y) + scroll
  372. }
  373. /// Mark the line under screen y as selected.
  374. async fn select_line(&self, screen_y: f32) {
  375. let y = self.content_y(screen_y).await;
  376. let mut buffer = self.buffer.lock().await;
  377. let Some(rec) = buffer.record_at_y(y) else { return };
  378. let key = (rec.ts, rec.id);
  379. drop(buffer);
  380. let mut selected = self.selected.lock();
  381. let had = !selected.is_empty();
  382. selected.insert(key);
  383. let has = !selected.is_empty();
  384. drop(selected);
  385. self.redraw.trigger();
  386. self.notify_select_changed(has, had).await;
  387. }
  388. /// Mark the line under screen y as deselected.
  389. async fn deselect_line(&self, screen_y: f32) {
  390. let y = self.content_y(screen_y).await;
  391. let mut buffer = self.buffer.lock().await;
  392. let Some(rec) = buffer.record_at_y(y) else { return };
  393. let key = (rec.ts, rec.id);
  394. drop(buffer);
  395. let mut selected = self.selected.lock();
  396. let had = !selected.is_empty();
  397. selected.remove(&key);
  398. let has = !selected.is_empty();
  399. drop(selected);
  400. self.redraw.trigger();
  401. self.notify_select_changed(has, had).await;
  402. }
  403. /// Query whether the line under screen y is currently selected.
  404. async fn is_line_selected(&self, screen_y: f32) -> bool {
  405. let y = self.content_y(screen_y).await;
  406. let buffer = self.buffer.lock().await;
  407. let Some(rec) = buffer.record_at_y(y) else { return false };
  408. self.selected.lock().contains(&(rec.ts, rec.id))
  409. }
  410. /// Emit `select_changed(true/false)` whenever the presence of any
  411. /// selected line transitions.
  412. async fn notify_select_changed(&self, has: bool, had: bool) {
  413. if has == had {
  414. return
  415. }
  416. self.select_active.store(has, Ordering::Relaxed);
  417. let Some(node_ref) = self.node.upgrade() else { return };
  418. let mut data = vec![];
  419. darkfi_serial::Encodable::encode(&has, &mut data).unwrap();
  420. let _ = node_ref.trigger("select_changed", data).await;
  421. }
  422. /// Copy the selected messages' text to the clipboard in display
  423. /// order, then unselect everything.
  424. async fn handle_copy_select(&self) {
  425. {
  426. let buffer = self.buffer.lock().await;
  427. let selected = self.selected.lock();
  428. let text = copy_selected(&buffer, &selected, self.types());
  429. drop(selected);
  430. drop(buffer);
  431. if !text.is_empty() {
  432. t!("copy_select() [text={text}]");
  433. clipboard::set(&text);
  434. }
  435. }
  436. self.handle_unselect().await;
  437. }
  438. /// Deselect every selected message and redraw.
  439. async fn handle_unselect(&self) {
  440. let had = {
  441. let mut selected = self.selected.lock();
  442. let had = !selected.is_empty();
  443. selected.clear();
  444. had
  445. };
  446. self.redraw.trigger();
  447. self.notify_select_changed(false, had).await;
  448. }
  449. /// Resolve the content hit at a screen position, if any.
  450. async fn content_hit(&self, screen_pos: Point) -> Option<(MessageId, msg::Hit)> {
  451. let content_y = self.content_y(screen_pos.y).await;
  452. let rect = self.rect.get();
  453. let buffer = self.buffer.lock().await;
  454. let rec = buffer.record_at_y(content_y)?;
  455. let id = rec.id;
  456. let top = buffer.pos_of_key(&(rec.ts, rec.id))?;
  457. let local = Point::new(screen_pos.x - rect.x, top - content_y);
  458. let hit = self.types().hit_test(rec, local)?;
  459. Some((id, hit))
  460. }
  461. /// Hit-dispatch through the type registry: opens URLs, emits the
  462. /// type's interaction signals. Returns true when consumed.
  463. async fn dispatch_content_hit(&self, screen_pos: Point) -> bool {
  464. let Some((id, hit)) = self.content_hit(screen_pos).await else { return false };
  465. match hit {
  466. msg::Hit::Url(url) => {
  467. t!("url clicked");
  468. if let Some(node) = self.types().privmsg.node().upgrade() {
  469. let mut data = vec![];
  470. darkfi_serial::Encodable::encode(&id, &mut data).unwrap();
  471. darkfi_serial::Encodable::encode(&url, &mut data).unwrap();
  472. let _ = node.trigger("url_clicked", data).await;
  473. }
  474. Self::open_url(&url);
  475. true
  476. }
  477. msg::Hit::Nick(nick) => {
  478. t!("nick clicked");
  479. if let Some(node) = self.types().privmsg.node().upgrade() {
  480. let mut data = vec![];
  481. darkfi_serial::Encodable::encode(&id, &mut data).unwrap();
  482. darkfi_serial::Encodable::encode(&nick, &mut data).unwrap();
  483. let _ = node.trigger("nick_clicked", data).await;
  484. }
  485. true
  486. }
  487. msg::Hit::File(url) => {
  488. t!("file activated");
  489. self.types().filemsg.request_download(&id, &url).await;
  490. true
  491. }
  492. msg::Hit::Expand => {
  493. // Toggle a capped message's expansion; the height change
  494. // flows into geometry with scroll compensation.
  495. let rec = {
  496. let buffer = self.buffer.lock().await;
  497. buffer.record(&id).cloned()
  498. };
  499. let Some(rec) = rec else { return false };
  500. let new_height = self.types().privmsg.toggle_expand(&rec);
  501. let mut buffer = self.buffer.lock().await;
  502. if let Some(delta) = buffer.set_height_key(&(rec.ts, rec.id), new_height) {
  503. drop(buffer);
  504. let mut ctl = self.controller.lock();
  505. let scroll = ctl.scroll();
  506. ctl.compensate(delta, self.top_for(id, scroll).await);
  507. }
  508. self.redraw.trigger();
  509. true
  510. }
  511. }
  512. }
  513. /// Whether the message with this id sits entirely below the
  514. /// viewport bottom (compensation input).
  515. async fn top_for(&self, id: MessageId, scroll: f32) -> bool {
  516. let buffer = self.buffer.lock().await;
  517. match buffer.pos_of(&id) {
  518. Some(top) => top <= scroll,
  519. None => false,
  520. }
  521. }
  522. fn open_url(url: &str) {
  523. if url.chars().any(|c| c.is_control()) {
  524. error!(target: "ui::chatview", "refusing to open URL with control characters");
  525. return
  526. }
  527. #[cfg(target_os = "android")]
  528. crate::android::open_url(url);
  529. #[cfg(not(target_os = "android"))]
  530. let _ = open::that(url);
  531. }
  532. /// Copy `url` to the clipboard and show the "Copied link" overlay
  533. /// above `anchor` (chatview-local coords) for `url_copy_duration`
  534. /// seconds. Re-arms on repeat.
  535. async fn show_toast(&self, url: &str, anchor: Point) {
  536. clipboard::set(url);
  537. let privmsg = self.types().privmsg.clone();
  538. let label = privmsg.url_copy_text();
  539. let fg = privmsg.url_copy_fg_color();
  540. let bg = privmsg.url_copy_bg_color();
  541. let font_size = privmsg.url_copy_font_size();
  542. let pad = privmsg.url_copy_padding();
  543. let offset = privmsg.url_copy_offset();
  544. let window_scale = self.window_scale.get();
  545. // A real line ratio (the old code passed 0., which collapses the
  546. // line box so height() reports 0 and the label centers wrong).
  547. let text_layout = text::make_layout(&label, fg, font_size, 1.2, window_scale, None, &[]);
  548. let toast = LinkToast {
  549. text_layout,
  550. anchor,
  551. offset,
  552. fg_color: fg,
  553. bg_color: bg,
  554. font_size,
  555. padding: pad,
  556. };
  557. *self.link_toast.lock() = Some(toast);
  558. self.redraw.trigger();
  559. // (Re)arm the dismiss task. A stale task won't clear a newer
  560. // toast (version check).
  561. let version = self.toast_version.fetch_add(1, Ordering::SeqCst) + 1;
  562. let duration = self.types().privmsg.url_copy_duration();
  563. let me = self.me.clone();
  564. let ex = self.ex.clone();
  565. ex.spawn(async move {
  566. darkfi::system::msleep((duration * 1000.) as u64).await;
  567. let Some(self_) = me.upgrade() else { return };
  568. if self_.toast_version.load(Ordering::SeqCst) == version {
  569. *self_.link_toast.lock() = None;
  570. self_.redraw.trigger();
  571. }
  572. })
  573. .detach();
  574. }
  575. /// Tap resolved by the recognizer: content activation, or line
  576. /// toggling when a selection is active.
  577. async fn handle_tap(&self, pos: Point) {
  578. // URLs and nicks consume the tap; otherwise a selection toggles.
  579. if self.dispatch_content_hit(pos).await {
  580. return
  581. }
  582. if self.select_active.load(Ordering::Relaxed) {
  583. if self.is_line_selected(pos.y).await {
  584. self.deselect_line(pos.y).await;
  585. } else {
  586. self.select_line(pos.y).await;
  587. }
  588. }
  589. }
  590. /// Long-press resolved by the recognizer: a URL under the finger is
  591. /// copied with a toast (drags keep scrolling); otherwise line
  592. /// selection starts and drags extend it instead of scrolling.
  593. async fn handle_long_press(&self, pos: Point) {
  594. if let Some((_, msg::Hit::Url(url))) = self.content_hit(pos).await {
  595. *self.lp_mode.lock() = LongPressMode::UrlToast;
  596. self.show_toast(&url, pos - self.rect.get().pos()).await;
  597. return
  598. }
  599. *self.lp_mode.lock() = LongPressMode::Select;
  600. self.select_line(pos.y).await;
  601. }
  602. /// Bind to a channel: save the outgoing channel's scroll anchor,
  603. /// release its in-memory buffer, and open the target channel's
  604. /// tree. The loader refills in the background; the scroll restore
  605. /// resolves when coverage reaches the saved anchor.
  606. pub async fn handle_set_channel(&self, channel: String) {
  607. t!("set_channel({channel})");
  608. let view_h = self.rect.get().h;
  609. // Save where the user left the outgoing channel.
  610. if let Some((old_name, _)) = self.channel.lock().as_ref() {
  611. let buffer = self.buffer.lock().await;
  612. let oldest_visible = {
  613. let scroll = self.controller.lock().scroll();
  614. let end = buffer.visible_range(scroll, view_h).end;
  615. end.checked_sub(1).and_then(|idx| buffer.record_at(idx)).map(|rec| rec.id)
  616. };
  617. let anchor = self
  618. .controller
  619. .lock()
  620. .anchor(view_h, oldest_visible.as_ref(), |id| buffer.pos_of(id));
  621. self.channel_state.lock().insert(old_name.clone(), anchor);
  622. }
  623. self.types().release_all();
  624. self.buffer.lock().await.clear();
  625. let tree_name = Self::tree_name(&channel);
  626. let tree = self
  627. .kv_db
  628. .open_tree_default(&tree_name)
  629. .unwrap_or_else(|e| panic!("cannot open chat tree {tree_name}: {e}"));
  630. *self.channel.lock() = Some((channel.clone(), tree.clone()));
  631. {
  632. let atom = &mut self.redraw.make_guard(gfxtag!("chatview channel"));
  633. self.channel_prop.set(atom, &channel);
  634. }
  635. // Restore the incoming channel's anchor (bottom by default);
  636. // it resolves once the loader's coverage reaches it.
  637. let restore = self.channel_state.lock().get(&channel).cloned();
  638. *self.pending_restore.lock() = restore;
  639. self.controller.lock().scroll_to_bottom();
  640. self.sync_is_at_bottom();
  641. self.loader.bind(channel, tree);
  642. self.redraw.trigger();
  643. }
  644. /// Receive a message from the relay: messages for the bound
  645. /// channel insert live; anything else is persisted to its own
  646. /// channel tree for later (the buffer is not touched — unread
  647. /// indication is the relayer's concern).
  648. pub async fn handle_receive(
  649. &self,
  650. channel: &str,
  651. ts: Timestamp,
  652. id: MessageId,
  653. nick: String,
  654. text: String,
  655. ) {
  656. let active = self.channel.lock().as_ref().map(|(name, _)| name.clone());
  657. match active {
  658. Some(active) if active == channel => {
  659. self.types().privmsg.insert_line(ts, id, nick, text).await;
  660. }
  661. _ => {
  662. // Side-channel store: dedup by composite key, codec write.
  663. let tree_name = Self::tree_name(channel);
  664. let Ok(tree) = self.kv_db.open_tree_default(&tree_name) else {
  665. error!(target: "ui::chatview", "cannot open side-channel tree {tree_name}");
  666. return
  667. };
  668. let payload = codec::encode_privmsg_payload(&nick, &text, true);
  669. let key = codec::encode_key(ts, &id);
  670. match tree.contains_key(&key) {
  671. Ok(false) => {
  672. let val = codec::encode_value(MsgType::PrivMsg, &payload);
  673. tree.insert(&key, &val).expect("cannot persist chat entry");
  674. t!("side-channel store {channel} ts={ts}");
  675. }
  676. _ => {}
  677. }
  678. }
  679. }
  680. }
  681. /// Replace the runtime message filter (a callback cannot travel the
  682. /// scene method bus; this is a native API).
  683. pub fn set_filter(&self, f: Box<dyn Fn(&MsgRecord) -> bool + Send>) {
  684. self.loader.set_filter(f);
  685. }
  686. /// The reflow protocol for width, scale, and styling changes:
  687. /// anchor snapshot, drop rendered state, re-wrap visible-first,
  688. /// one Fenwick rebuild, anchor restore. Height-only rect changes
  689. /// do NOT come here — they just re-clamp via `set_content`.
  690. pub async fn reflow(&self) {
  691. t!("reflow begin");
  692. let view_h = self.rect.get().h;
  693. // 1. Snapshot the view position before invalidating anything.
  694. let anchor = {
  695. let buffer = self.buffer.lock().await;
  696. let scroll = self.controller.lock().scroll();
  697. let oldest_visible = {
  698. let end = buffer.visible_range(scroll, view_h).end;
  699. end.checked_sub(1).and_then(|idx| buffer.record_at(idx)).map(|rec| rec.id)
  700. };
  701. let mut pos_of = |id: &MessageId| buffer.pos_of(id);
  702. self.controller.lock().anchor(view_h, oldest_visible.as_ref(), &mut pos_of)
  703. };
  704. // 2. Drop all rendered state (layouts, meshes, hit rects).
  705. self.types().regen_all();
  706. // 3. Re-wrap every loaded record, visible window first so the
  707. // restored frame shows correct content immediately, then a
  708. // single Fenwick rebuild.
  709. let types = self.types().clone();
  710. {
  711. let mut buffer = self.buffer.lock().await;
  712. let scroll = self.controller.lock().scroll();
  713. let visible = buffer.visible_range(scroll, view_h);
  714. let len = buffer.len();
  715. let mut order: Vec<usize> = vec![];
  716. for idx in visible {
  717. order.push(idx);
  718. }
  719. for idx in 0..len {
  720. if !order.contains(&idx) {
  721. order.push(idx);
  722. }
  723. }
  724. // Collect records first (measure locks the type nodes, the
  725. // buffer guard must not alias through the closure).
  726. let mut recs = vec![];
  727. for idx in order {
  728. if let Some(rec) = buffer.record_at(idx) {
  729. recs.push(rec.clone());
  730. }
  731. }
  732. let heights: Vec<(Timestamp, MessageId, f32)> = {
  733. let mut measured = vec![];
  734. for rec in &recs {
  735. measured.push((rec.ts, rec.id, types.measure(rec)));
  736. }
  737. measured
  738. };
  739. for (ts, id, h) in heights {
  740. if let Some(rec) = buffer.record_mut(&id) {
  741. if rec.ts == ts {
  742. rec.height = h;
  743. }
  744. }
  745. }
  746. buffer.rebuild_fenwick();
  747. t!("reflow remeasured {} records, one fenwick rebuild", recs.len());
  748. // 4. Resolve the anchor against the new geometry.
  749. let total = buffer.total_height();
  750. let mut pos_of = |id: &MessageId| buffer.pos_of(id);
  751. {
  752. let mut ctl = self.controller.lock();
  753. ctl.set_content(total, view_h);
  754. ctl.restore(&anchor, view_h, &mut pos_of);
  755. }
  756. }
  757. self.sync_is_at_bottom();
  758. self.redraw.trigger();
  759. t!("reflow done");
  760. }
  761. /// Try resolving a pending scroll restore against the loaded
  762. /// coverage. Called from the draw path after geometry updates.
  763. fn try_pending_restore(&self, buffer: &MsgBuffer, view_h: f32) -> bool {
  764. let Some(anchor) = self.pending_restore.lock().clone() else { return false };
  765. let Some(msg) = anchor.msg else {
  766. // Bottom shortcut: nothing to wait for.
  767. *self.pending_restore.lock() = None;
  768. return false
  769. };
  770. let Some(pos) = buffer.pos_of(&msg) else { return false };
  771. // Wait until the loaded region actually spans the anchor.
  772. let target = pos + anchor.dy - view_h;
  773. if target > (buffer.total_height() - view_h).max(0.) {
  774. return false
  775. }
  776. let mut ctl = self.controller.lock();
  777. ctl.restore(&anchor, view_h, |id| buffer.pos_of(id));
  778. drop(ctl);
  779. *self.pending_restore.lock() = None;
  780. self.sync_is_at_bottom();
  781. true
  782. }
  783. /// The `(ts, id)` pairs of loaded records in display order.
  784. async fn handle_get_line_ids(&self) -> Vec<(Timestamp, MessageId)> {
  785. let buffer = self.buffer.lock().await;
  786. let mut lines = vec![];
  787. for rec in buffer.iter_display_order() {
  788. lines.push((rec.ts, rec.id));
  789. }
  790. lines
  791. }
  792. /// Delete a loaded message by id: buffer + channel tree. A testing
  793. /// affordance, not a user-facing feature.
  794. async fn handle_delete_line(&self, id: MessageId) -> bool {
  795. let mut buffer = self.buffer.lock().await;
  796. let Some(ts) = buffer.record(&id).map(|rec| rec.ts) else { return false };
  797. if !buffer.remove(&id) {
  798. return false
  799. }
  800. drop(buffer);
  801. let channel = self.channel.lock();
  802. if let Some((_, tree)) = channel.as_ref() {
  803. let key = codec::encode_key(ts, &id);
  804. tree.remove(&key).unwrap();
  805. }
  806. self.redraw.trigger();
  807. true
  808. }
  809. /// The down-arrow: teleport to the live bottom, cancel all motion.
  810. async fn handle_scroll_to_bottom(&self) {
  811. self.controller.lock().scroll_to_bottom();
  812. self.sync_is_at_bottom();
  813. self.redraw.trigger();
  814. }
  815. // Method-call handlers, one per scene method.
  816. async fn process_set_channel_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
  817. let Ok(method_call) = sub.receive().await else {
  818. t!("set_channel relayer closed");
  819. return false
  820. };
  821. assert!(method_call.send_res.is_none());
  822. let Ok(channel) = String::decode(&mut Cursor::new(&method_call.data)) else {
  823. t!("set_channel() method invalid arg data");
  824. return true
  825. };
  826. let Some(self_) = me.upgrade() else {
  827. panic!("self destroyed before set_channel task stopped")
  828. };
  829. self_.handle_set_channel(channel).await;
  830. true
  831. }
  832. async fn process_get_line_ids_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
  833. let Ok(method_call) = sub.receive().await else {
  834. t!("get_line_ids relayer closed");
  835. return false
  836. };
  837. let Some(self_) = me.upgrade() else {
  838. panic!("self destroyed before get_line_ids task stopped")
  839. };
  840. let lines = self_.handle_get_line_ids().await;
  841. let mut data = vec![];
  842. for (ts, id) in lines {
  843. ts.encode(&mut data).unwrap();
  844. id.encode(&mut data).unwrap();
  845. }
  846. if let Some(send_res) = method_call.send_res {
  847. let _ = send_res.send(data).await;
  848. }
  849. true
  850. }
  851. async fn process_delete_line_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
  852. let Ok(method_call) = sub.receive().await else {
  853. t!("delete_line relayer closed");
  854. return false
  855. };
  856. assert!(method_call.send_res.is_none());
  857. let Ok(id) = MessageId::decode(&mut Cursor::new(&method_call.data)) else {
  858. t!("delete_line() method invalid arg data");
  859. return true
  860. };
  861. let Some(self_) = me.upgrade() else {
  862. panic!("self destroyed before delete_line task stopped")
  863. };
  864. self_.handle_delete_line(id).await;
  865. true
  866. }
  867. async fn process_copy_select_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
  868. let Ok(method_call) = sub.receive().await else {
  869. t!("copy_select relayer closed");
  870. return false
  871. };
  872. assert!(method_call.send_res.is_none());
  873. assert!(method_call.data.is_empty());
  874. let Some(self_) = me.upgrade() else {
  875. panic!("self destroyed before copy_select task stopped")
  876. };
  877. self_.handle_copy_select().await;
  878. true
  879. }
  880. async fn process_unselect_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
  881. let Ok(method_call) = sub.receive().await else {
  882. t!("unselect relayer closed");
  883. return false
  884. };
  885. assert!(method_call.send_res.is_none());
  886. assert!(method_call.data.is_empty());
  887. let Some(self_) = me.upgrade() else {
  888. panic!("self destroyed before unselect task stopped")
  889. };
  890. self_.handle_unselect().await;
  891. true
  892. }
  893. async fn process_scroll_to_bottom_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
  894. let Ok(method_call) = sub.receive().await else {
  895. t!("scroll_to_bottom relayer closed");
  896. return false
  897. };
  898. assert!(method_call.send_res.is_none());
  899. assert!(method_call.data.is_empty());
  900. let Some(self_) = me.upgrade() else {
  901. panic!("self destroyed before scroll_to_bottom task stopped")
  902. };
  903. self_.handle_scroll_to_bottom().await;
  904. true
  905. }
  906. async fn process_insert_line_method(
  907. me: &Weak<Self>,
  908. sub: &MethodCallSub,
  909. confirmed: bool,
  910. ) -> bool {
  911. let Ok(method_call) = sub.receive().await else {
  912. t!("insert_line relayer closed");
  913. return false
  914. };
  915. assert!(method_call.send_res.is_none());
  916. let Some((ts, id, nick, text)) = msg::privmsg::decode_insert_data(&method_call.data) else {
  917. t!("insert_line() method invalid arg data");
  918. return true
  919. };
  920. let Some(self_) = me.upgrade() else {
  921. panic!("self destroyed before insert_line task stopped")
  922. };
  923. let privmsg = self_.types().privmsg.clone();
  924. if confirmed {
  925. privmsg.insert_line(ts, id, nick, text).await;
  926. } else {
  927. privmsg.insert_unconf_line(ts, id, nick, text).await;
  928. }
  929. true
  930. }
  931. async fn process_receive_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
  932. let Ok(method_call) = sub.receive().await else {
  933. t!("receive relayer closed");
  934. return false
  935. };
  936. assert!(method_call.send_res.is_none());
  937. let mut cur = Cursor::new(&method_call.data);
  938. let (channel, ts, id, nick, text) = match (
  939. String::decode(&mut cur),
  940. Timestamp::decode(&mut cur),
  941. MessageId::decode(&mut cur),
  942. String::decode(&mut cur),
  943. String::decode(&mut cur),
  944. ) {
  945. (Ok(channel), Ok(ts), Ok(id), Ok(nick), Ok(text)) => (channel, ts, id, nick, text),
  946. _ => {
  947. t!("receive() method invalid arg data");
  948. return true
  949. }
  950. };
  951. let Some(self_) = me.upgrade() else {
  952. panic!("self destroyed before receive task stopped")
  953. };
  954. self_.handle_receive(&channel, ts, id, nick, text).await;
  955. true
  956. }
  957. async fn process_set_file_status_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
  958. let Ok(method_call) = sub.receive().await else {
  959. t!("set_file_status relayer closed");
  960. return false
  961. };
  962. assert!(method_call.send_res.is_none());
  963. let mut cur = Cursor::new(&method_call.data);
  964. let (url, status) =
  965. match (url::Url::decode(&mut cur), msg::filemsg::FileMsgStatus::decode(&mut cur)) {
  966. (Ok(url), Ok(status)) => (url, status),
  967. _ => {
  968. t!("set_file_status() method invalid arg data");
  969. return true
  970. }
  971. };
  972. let Some(self_) = me.upgrade() else {
  973. panic!("self destroyed before set_file_status task stopped")
  974. };
  975. self_.types().filemsg.set_file_status(&url, status).await;
  976. true
  977. }
  978. async fn process_confirm_method(me: &Weak<Self>, sub: &MethodCallSub) -> bool {
  979. let Ok(method_call) = sub.receive().await else {
  980. t!("confirm relayer closed");
  981. return false
  982. };
  983. assert!(method_call.send_res.is_none());
  984. let Ok(id) = MessageId::decode(&mut Cursor::new(&method_call.data)) else {
  985. t!("confirm() method invalid arg data");
  986. return true
  987. };
  988. let Some(self_) = me.upgrade() else {
  989. panic!("self destroyed before confirm task stopped")
  990. };
  991. self_.types().privmsg.confirm(id).await;
  992. true
  993. }
  994. /// The animator: advances Glide/Anim on a deadline cadence, driving
  995. /// redraws (and `is_at_bottom`) while any motion is in flight.
  996. async fn run_animator(me: &Weak<Self>, cv: Arc<CondVar>) {
  997. loop {
  998. cv.wait().await;
  999. let Some(self_) = me.upgrade() else { break };
  1000. loop {
  1001. let deadline = {
  1002. let ctl = self_.controller.lock();
  1003. let now = Instant::now();
  1004. let Some(deadline) = ctl.next_deadline(now) else { break };
  1005. deadline
  1006. };
  1007. let now = Instant::now();
  1008. if deadline > now {
  1009. Timer::after(deadline - now).await;
  1010. }
  1011. let advanced = self_.controller.lock().tick(Instant::now()).is_some();
  1012. if advanced {
  1013. self_.sync_is_at_bottom();
  1014. self_.redraw.trigger();
  1015. }
  1016. }
  1017. cv.reset();
  1018. }
  1019. }
  1020. }
  1021. #[async_trait]
  1022. impl UIObject for ChatView {
  1023. fn priority(&self) -> u32 {
  1024. self.priority.get()
  1025. }
  1026. async fn start(self: Arc<Self>, ex: ExecutorPtr) {
  1027. let node_ref = self.node.upgrade().unwrap();
  1028. let mut tasks = vec![];
  1029. let sub = node_ref.subscribe_method_call("set_channel").unwrap();
  1030. let me2 = self.me.clone();
  1031. tasks.push(
  1032. ex.spawn(async move { while Self::process_set_channel_method(&me2, &sub).await {} }),
  1033. );
  1034. let sub = node_ref.subscribe_method_call("get_line_ids").unwrap();
  1035. let me2 = self.me.clone();
  1036. tasks.push(
  1037. ex.spawn(async move { while Self::process_get_line_ids_method(&me2, &sub).await {} }),
  1038. );
  1039. let sub = node_ref.subscribe_method_call("delete_line").unwrap();
  1040. let me2 = self.me.clone();
  1041. tasks.push(
  1042. ex.spawn(async move { while Self::process_delete_line_method(&me2, &sub).await {} }),
  1043. );
  1044. let sub = node_ref.subscribe_method_call("copy_select").unwrap();
  1045. let me2 = self.me.clone();
  1046. tasks.push(
  1047. ex.spawn(async move { while Self::process_copy_select_method(&me2, &sub).await {} }),
  1048. );
  1049. let sub = node_ref.subscribe_method_call("unselect").unwrap();
  1050. let me2 = self.me.clone();
  1051. tasks.push(
  1052. ex.spawn(async move { while Self::process_unselect_method(&me2, &sub).await {} }),
  1053. );
  1054. let sub = node_ref.subscribe_method_call("scroll_to_bottom").unwrap();
  1055. let me2 = self.me.clone();
  1056. tasks.push(ex.spawn(async move {
  1057. while Self::process_scroll_to_bottom_method(&me2, &sub).await {}
  1058. }));
  1059. let sub = node_ref.subscribe_method_call("receive").unwrap();
  1060. let me2 = self.me.clone();
  1061. tasks
  1062. .push(ex.spawn(async move { while Self::process_receive_method(&me2, &sub).await {} }));
  1063. let animator = {
  1064. let me = self.me.clone();
  1065. let cv = self.motion_cv.clone();
  1066. ex.spawn(async move { Self::run_animator(&me, cv).await })
  1067. };
  1068. tasks.push(animator);
  1069. // The single background loading pipeline.
  1070. let loader_task = {
  1071. let loader = self.loader.clone();
  1072. ex.spawn(async move { loader.run().await })
  1073. };
  1074. tasks.push(loader_task);
  1075. // Message lifecycle methods live on the type sub-nodes.
  1076. let privmsg_node = self.types().privmsg.node().upgrade().unwrap();
  1077. let sub = privmsg_node.subscribe_method_call("insert_line").unwrap();
  1078. let me2 = self.me.clone();
  1079. tasks.push(ex.spawn(async move {
  1080. while Self::process_insert_line_method(&me2, &sub, true).await {}
  1081. }));
  1082. let sub = privmsg_node.subscribe_method_call("insert_unconf_line").unwrap();
  1083. let me2 = self.me.clone();
  1084. tasks.push(ex.spawn(async move {
  1085. while Self::process_insert_line_method(&me2, &sub, false).await {}
  1086. }));
  1087. let sub = privmsg_node.subscribe_method_call("confirm").unwrap();
  1088. let me2 = self.me.clone();
  1089. tasks
  1090. .push(ex.spawn(async move { while Self::process_confirm_method(&me2, &sub).await {} }));
  1091. // File status updates arrive on the filemsg type node.
  1092. let filemsg_node = self.types().filemsg.node().upgrade().unwrap();
  1093. let sub = filemsg_node.subscribe_method_call("set_file_status").unwrap();
  1094. let me2 = self.me.clone();
  1095. tasks.push(
  1096. ex.spawn(
  1097. async move { while Self::process_set_file_status_method(&me2, &sub).await {} },
  1098. ),
  1099. );
  1100. let mut on_modify = OnModify::new(ex, self.node.clone(), self.me.clone());
  1101. on_modify.when_change_external(self.rect.prop(), |self_, _| async move {
  1102. self_.redraw.trigger();
  1103. });
  1104. i!("chatview start: {} tasks spawned", tasks.len());
  1105. // Styling changes re-wrap: the reflow protocol. Every shared
  1106. // styling property that shapes text (and the privmsg node's
  1107. // own cap) triggers it; color-only changes ride along since
  1108. // layouts embed their colors.
  1109. for name in [
  1110. "font_size",
  1111. "timestamp_font_size",
  1112. "timestamp_width",
  1113. "line_height",
  1114. "message_spacing",
  1115. "baseline",
  1116. "timestamp_color",
  1117. "text_color",
  1118. ] {
  1119. let Some(prop) = node_ref.get_property(name) else { continue };
  1120. on_modify.when_change_external(prop, |self_, _| async move {
  1121. self_.reflow().await;
  1122. });
  1123. }
  1124. for name in ["cap_max_height", "nick_colors", "action_text_color", "url_text_color"] {
  1125. let Some(prop) = self.types().privmsg.node().upgrade().unwrap().get_property(name)
  1126. else {
  1127. continue
  1128. };
  1129. on_modify.when_change_external(prop, |self_, _| async move {
  1130. self_.reflow().await;
  1131. });
  1132. }
  1133. for name in ["font_size", "color"] {
  1134. let Some(prop) = self.types().datemsg.node().upgrade().unwrap().get_property(name)
  1135. else {
  1136. continue
  1137. };
  1138. on_modify.when_change_external(prop, |self_, _| async move {
  1139. self_.reflow().await;
  1140. });
  1141. }
  1142. tasks.append(&mut on_modify.tasks);
  1143. *self.tasks.lock() = tasks;
  1144. }
  1145. fn stop(&self) {
  1146. self.tasks.lock().clear();
  1147. self.motion_cv.notify();
  1148. self.buffer.lock_blocking().clear();
  1149. }
  1150. async fn draw(
  1151. &self,
  1152. parent_rect: Rectangle,
  1153. atom: &mut PropertyAtomicGuard,
  1154. ) -> Option<DrawUpdate> {
  1155. let prev_rect = self.rect.get();
  1156. self.rect.eval(atom, &parent_rect).ok()?;
  1157. let rect = self.rect.get();
  1158. let rect_changed = rect != prev_rect;
  1159. let mut buffer = self.buffer.lock().await;
  1160. let total = buffer.total_height();
  1161. let scroll = {
  1162. let mut ctl = self.controller.lock();
  1163. ctl.set_content(total, rect.h);
  1164. ctl.scroll()
  1165. };
  1166. // Deferred scroll restore: resolves once coverage reaches the
  1167. // saved anchor (returns true when it just resolved).
  1168. if self.try_pending_restore(&buffer, rect.h) {
  1169. t!("scroll restore resolved");
  1170. }
  1171. // The visible window: geometry is O(log n); only the visible
  1172. // records get draw work.
  1173. let range = buffer.visible_range(scroll, rect.h);
  1174. t!("draw scroll={scroll} total={total} visible={range:?}");
  1175. let types = self.types().clone();
  1176. let selected = self.selected.lock().clone();
  1177. let hi_bg_color: Color = self.hi_bg_color.get();
  1178. let buffer_len = buffer.len();
  1179. let range_start = range.start;
  1180. let range_end = range.end;
  1181. let mut instrs = vec![DrawInstruction::ApplyView(rect)];
  1182. let mut clipped_calls: Vec<(u64, DrawCall)> = vec![];
  1183. let mut geometry_changed = false;
  1184. for idx in range {
  1185. let Some(rec_key) = buffer.record_at(idx).map(|rec| (rec.ts, rec.id)) else { continue };
  1186. let rec_id = rec_key.1;
  1187. let Some(top) = buffer.pos_of_key(&rec_key) else { continue };
  1188. let is_selected = selected.contains(&rec_key);
  1189. // Materialize and measure; a height that drifted from the
  1190. // record's stored value (the layout re-wrapped at a
  1191. // different width or styling) flows back into geometry
  1192. // with scroll compensation.
  1193. let height = {
  1194. let Some(rec) = buffer.record_at(idx) else { continue };
  1195. types.ensure_materialized(rec)
  1196. };
  1197. let stored = buffer.index_get_height(&rec_key).unwrap_or(0.);
  1198. if (height - stored).abs() > 0.05 {
  1199. if let Some(delta) = buffer.set_height_key(&rec_key, height) {
  1200. let mut ctl = self.controller.lock();
  1201. ctl.compensate(delta, top <= scroll);
  1202. drop(ctl);
  1203. geometry_changed = true;
  1204. t!("height drift id={rec_id} {stored} -> {height}");
  1205. }
  1206. }
  1207. let outcome = {
  1208. let Some(rec) = buffer.record_at(idx) else { continue };
  1209. types.draw(rec, &self.renderer)
  1210. };
  1211. let off_y = scroll + rect.h - top;
  1212. // Chatview-drawn selection highlight behind the type's
  1213. // instructions: types stay ignorant of selection.
  1214. if is_selected {
  1215. instrs.push(DrawInstruction::SetPos(Point::new(0., off_y)));
  1216. let mut mesh = MeshBuilder::new(gfxtag!("chatview_selbg"));
  1217. mesh.draw_filled_box(&Rectangle::new(0., 0., rect.w, height), hi_bg_color);
  1218. instrs.push(DrawInstruction::Draw(mesh.alloc(&self.renderer).draw_untextured()));
  1219. }
  1220. match outcome {
  1221. msg::DrawOutcome::Inline(minstrs) => {
  1222. instrs.push(DrawInstruction::SetPos(Point::new(0., off_y)));
  1223. instrs.extend(minstrs);
  1224. }
  1225. msg::DrawOutcome::Clipped { instrs: minstrs, clip_h } => {
  1226. // Collapsed long message: its own sibling draw call
  1227. // so the clip dies with the call's frame.
  1228. let mut cap_instrs = vec![
  1229. DrawInstruction::ApplyView(rect),
  1230. DrawInstruction::SetPos(Point::new(0., off_y)),
  1231. DrawInstruction::ApplyView(Rectangle::new(0., 0., rect.w, clip_h)),
  1232. ];
  1233. cap_instrs.extend(minstrs);
  1234. clipped_calls.push((
  1235. self.dc_key ^ (clipped_calls.len() as u64 + 1),
  1236. DrawCall::new(cap_instrs, vec![], self.z_index.get(), "chatview_cap"),
  1237. ));
  1238. }
  1239. }
  1240. }
  1241. drop(buffer);
  1242. if geometry_changed {
  1243. // One more pass with the corrected geometry.
  1244. self.redraw.trigger();
  1245. }
  1246. // Feed the loader: the coverage it must maintain and the wakes
  1247. // for approaching the top of coverage and for rect changes.
  1248. self.loader.update_viewport(scroll, rect.h);
  1249. if total - (scroll + rect.h) < rect.h {
  1250. self.loader.wake(Wakeup::NearTop);
  1251. }
  1252. if rect_changed {
  1253. self.loader.wake(Wakeup::RectChange);
  1254. t!("rect changed to {rect:?}");
  1255. }
  1256. if let Some(toast) = self.link_toast.lock().as_ref() {
  1257. // Overlay defers with the cursor at emit time; reset it so
  1258. // the toast's anchor resolves in view-local coordinates
  1259. // instead of wherever the last message was drawn.
  1260. instrs.push(DrawInstruction::SetPos(Point::new(0., 0.)));
  1261. instrs.push(DrawInstruction::Overlay(toast.build_instrs(&self.renderer)));
  1262. }
  1263. let mut draw_calls =
  1264. vec![(self.dc_key, DrawCall::new(instrs, vec![], self.z_index.get(), "chatview"))];
  1265. draw_calls.extend(clipped_calls);
  1266. Some(DrawUpdate { key: self.dc_key, draw_calls })
  1267. }
  1268. async fn handle_key_down(&self, key: KeyCode, _mods: KeyMods, repeat: bool) -> bool {
  1269. let dir = match key {
  1270. KeyCode::PageUp => 1.,
  1271. KeyCode::PageDown => -1.,
  1272. _ => return false,
  1273. };
  1274. // Held PageUp/PageDown scrolls smoothly: the initial press acts
  1275. // immediately and OS repeats are throttled to the repeat
  1276. // cadence; each tick coalesces into the in-flight page
  1277. // animation's target.
  1278. let actions = {
  1279. let mut repeater = self.key_repeat.lock();
  1280. repeater.key_down(PressedKey::Key(key), repeat)
  1281. };
  1282. if actions == 0 {
  1283. return true;
  1284. }
  1285. let rect = self.rect.get();
  1286. for _ in 0..actions {
  1287. let mut ctl = self.controller.lock();
  1288. ctl.page_tick(dir, rect.h / 2.);
  1289. }
  1290. self.notify_motion();
  1291. true
  1292. }
  1293. async fn handle_mouse_btn_down(&self, btn: MouseButton, mouse_pos: Point) -> bool {
  1294. let rect = self.rect.get();
  1295. if !rect.contains(mouse_pos) {
  1296. return false
  1297. }
  1298. // Right-click on a URL: copy it and show the "Copied link" toast.
  1299. if btn == MouseButton::Right {
  1300. if let Some((_, msg::Hit::Url(url))) = self.content_hit(mouse_pos).await {
  1301. self.show_toast(&url, mouse_pos - rect.pos()).await;
  1302. return true
  1303. }
  1304. return false
  1305. }
  1306. if btn != MouseButton::Left {
  1307. return false
  1308. }
  1309. // A left press on a URL or nick consumes the press so no
  1310. // selection starts; the activation itself happens on mouse-up.
  1311. if self.content_hit(mouse_pos).await.is_some() {
  1312. return true
  1313. }
  1314. // Query whether the clicked line is already selected. We select
  1315. // immediately only if it wasn't (for instant feedback). If it
  1316. // was already selected we leave it and let mouse-up decide: a
  1317. // stationary click toggles it off, but a drag keeps it selected.
  1318. let was_selected = self.is_line_selected(mouse_pos.y).await;
  1319. if !was_selected {
  1320. self.select_line(mouse_pos.y).await;
  1321. }
  1322. *self.select_drag.lock() =
  1323. Some(SelectDrag { down_y: mouse_pos.y, was_selected, dragged: false });
  1324. self.mouse_btn_held.store(true, Ordering::Relaxed);
  1325. true
  1326. }
  1327. async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool {
  1328. if btn != MouseButton::Left {
  1329. return false
  1330. }
  1331. self.mouse_btn_held.store(false, Ordering::Relaxed);
  1332. // URL/nick activation happens on mouse-up (the press was
  1333. // already consumed so no selection gesture is in flight).
  1334. if self.rect.get().contains(mouse_pos) && self.content_hit(mouse_pos).await.is_some() {
  1335. return self.dispatch_content_hit(mouse_pos).await
  1336. }
  1337. // A stationary click on an already-selected line deselects it. A
  1338. // drag (or a click on an unselected line) leaves selection as-is.
  1339. let drag = self.select_drag.lock().take();
  1340. if let Some(d) = drag {
  1341. if !d.dragged && d.was_selected && self.rect.get().contains(mouse_pos) {
  1342. self.deselect_line(mouse_pos.y).await;
  1343. }
  1344. }
  1345. false
  1346. }
  1347. async fn handle_mouse_move(&self, mouse_pos: Point) -> bool {
  1348. // Stored for use in handle_mouse_wheel()
  1349. *self.mouse_pos.lock() = mouse_pos.clone();
  1350. if !self.mouse_btn_held.load(Ordering::Relaxed) {
  1351. return false
  1352. }
  1353. if !self.rect.get().contains(mouse_pos) {
  1354. return false
  1355. }
  1356. // Dragging only ever selects. Once the mouse moves past the
  1357. // threshold we latch `dragged` so the upcoming mouse-up won't
  1358. // treat this as a toggling click.
  1359. let dragged = {
  1360. let mut drag = self.select_drag.lock();
  1361. if let Some(d) = drag.as_mut() {
  1362. if (mouse_pos.y - d.down_y).abs() > SELECT_DRAG_THRESHOLD {
  1363. d.dragged = true;
  1364. }
  1365. d.dragged
  1366. } else {
  1367. false
  1368. }
  1369. };
  1370. if dragged {
  1371. self.select_line(mouse_pos.y).await;
  1372. }
  1373. false
  1374. }
  1375. async fn handle_mouse_wheel(&self, wheel_pos: Point) -> bool {
  1376. let rect = self.rect.get();
  1377. let mouse_pos = self.mouse_pos.lock().clone();
  1378. if !rect.contains(mouse_pos) {
  1379. return false
  1380. }
  1381. let mut ctl = self.controller.lock();
  1382. // The raw delta is the tick count: mouse notches deliver ±1.0,
  1383. // while trackpads stream small fractional axis values that must
  1384. // scale proportionally. wheel_page_frac sets the viewport
  1385. // fraction scrolled per notch (0.5 = half a page).
  1386. ctl.page_tick(wheel_pos.y, rect.h * self.wheel_page_frac.get());
  1387. drop(ctl);
  1388. self.notify_motion();
  1389. true
  1390. }
  1391. fn gesture_set(&self) -> GestureSet {
  1392. GestureSet::CHATVIEW
  1393. }
  1394. fn gesture_hit_test(&self, pos: Point) -> bool {
  1395. self.rect.get().contains(pos)
  1396. }
  1397. async fn handle_gesture(&self, gesture: GestureAction) -> bool {
  1398. match gesture {
  1399. GestureAction::Down { .. } => {
  1400. // A new touch resets the long-press mode and cancels any
  1401. // in-flight motion, before the touch even passes the slop.
  1402. *self.lp_mode.lock() = LongPressMode::None;
  1403. self.controller.lock().touch_down();
  1404. true
  1405. }
  1406. GestureAction::DragStart { start } => {
  1407. // A grab owns the scroll 1:1.
  1408. self.controller.lock().drag_start(start.y);
  1409. true
  1410. }
  1411. GestureAction::DragMove { curr, .. } => {
  1412. // Extending a long-press selection instead of scrolling.
  1413. if *self.lp_mode.lock() == LongPressMode::Select {
  1414. self.select_line(curr.y).await;
  1415. return true
  1416. }
  1417. self.controller.lock().drag_move(curr.y);
  1418. self.sync_is_at_bottom();
  1419. self.redraw.trigger();
  1420. true
  1421. }
  1422. GestureAction::DragEnd { vel, .. } => {
  1423. // No inertia while a selection is being extended.
  1424. if *self.lp_mode.lock() == LongPressMode::Select {
  1425. return true
  1426. }
  1427. // Flick on the session's release velocity.
  1428. self.controller.lock().drag_end(vel.y);
  1429. self.notify_motion();
  1430. true
  1431. }
  1432. GestureAction::Up { .. } => true,
  1433. GestureAction::Tap { pos } => {
  1434. self.handle_tap(pos).await;
  1435. true
  1436. }
  1437. GestureAction::LongPress { pos } => {
  1438. self.handle_long_press(pos).await;
  1439. true
  1440. }
  1441. }
  1442. }
  1443. }
  1444. /// Copy text for the selected records in display order, joined by
  1445. /// newlines; each record contributes the text its type defines.
  1446. pub(crate) fn copy_selected(
  1447. buffer: &MsgBuffer,
  1448. selected: &HashSet<(Timestamp, MessageId)>,
  1449. types: &msg::TypeNodes,
  1450. ) -> String {
  1451. let mut lines = vec![];
  1452. for rec in buffer.iter_display_order() {
  1453. if selected.contains(&(rec.ts, rec.id)) {
  1454. if let Some(text) = types.copy_text(rec) {
  1455. lines.push(text);
  1456. }
  1457. }
  1458. }
  1459. lines.join("\n")
  1460. }
  1461. #[cfg(test)]
  1462. mod tests {
  1463. use super::*;
  1464. use crate::app::node::create_chatview;
  1465. /// Every property the chatview wraps must exist on the factory's
  1466. /// node — catches factory/impl drift that would panic at runtime
  1467. /// (a missing prop only explodes when the screen is built).
  1468. #[test]
  1469. fn factory_declares_all_wrapped_properties() {
  1470. let node = create_chatview("chatview");
  1471. let node = node.setup_null();
  1472. for name in [
  1473. "channel",
  1474. "rect",
  1475. "font_size",
  1476. "timestamp_font_size",
  1477. "timestamp_width",
  1478. "line_height",
  1479. "message_spacing",
  1480. "baseline",
  1481. "timestamp_color",
  1482. "text_color",
  1483. "hi_bg_color",
  1484. "is_at_bottom",
  1485. "wheel_page_frac",
  1486. "z_index",
  1487. "priority",
  1488. ] {
  1489. assert!(node.get_property(name).is_some(), "chatview factory missing '{name}'");
  1490. }
  1491. }
  1492. use crate::{
  1493. prop::{Property, PropertyAtomicGuard, PropertySubType, PropertyType},
  1494. scene::{SceneNode as TestSceneNode, SceneNodeType as TestSceneNodeType},
  1495. ui::RedrawTrigger as TestRedrawTrigger,
  1496. };
  1497. /// Selection copy text follows display order (newest first), each
  1498. /// record contributing its type's copy text, joined by newlines —
  1499. /// including derived date separators.
  1500. #[test]
  1501. fn copy_selected_follows_display_order() {
  1502. let chat = create_chatview("chatview");
  1503. let chat = chat.setup_null();
  1504. let atom = &mut PropertyAtomicGuard::none();
  1505. chat.set_property_f32(atom, Role::App, "font_size", 14.).unwrap();
  1506. chat.set_property_f32(atom, Role::App, "timestamp_font_size", 10.).unwrap();
  1507. chat.set_property_f32(atom, Role::App, "timestamp_width", 50.).unwrap();
  1508. chat.set_property_f32(atom, Role::App, "line_height", 20.).unwrap();
  1509. chat.set_property_f32(atom, Role::App, "message_spacing", 4.).unwrap();
  1510. let prop = chat.get_property("rect").unwrap();
  1511. prop.set_f32(atom, Role::App, 0, 0.).unwrap();
  1512. prop.set_f32(atom, Role::App, 1, 0.).unwrap();
  1513. prop.set_f32(atom, Role::App, 2, 800.).unwrap();
  1514. prop.set_f32(atom, Role::App, 3, 600.).unwrap();
  1515. let prop = chat.get_property("text_color").unwrap();
  1516. for (i, c) in [1., 1., 1., 1.].iter().enumerate() {
  1517. prop.set_f32(atom, Role::App, i, *c).unwrap();
  1518. }
  1519. let prop = chat.get_property("timestamp_color").unwrap();
  1520. for (i, c) in [0.5, 0.5, 0.5, 1.].iter().enumerate() {
  1521. prop.set_f32(atom, Role::App, i, *c).unwrap();
  1522. }
  1523. let mut wscale = TestSceneNode::new("w", TestSceneNodeType::Object);
  1524. wscale
  1525. .add_property(Property::new("scale", PropertyType::Float32, PropertySubType::Null))
  1526. .unwrap();
  1527. let wscale = wscale.setup_null();
  1528. wscale.set_property_f32(atom, Role::App, "scale", 1.).unwrap();
  1529. let window_scale = PropertyFloat32::wrap(&wscale, Role::Internal, "scale", 0).unwrap();
  1530. let shared = msg::SharedProps::wrap(&chat, window_scale);
  1531. let mut raw = MsgBuffer::new();
  1532. let buffer = Arc::new(AsyncMutex::new(raw));
  1533. let (redraw, _rx) = TestRedrawTrigger::new();
  1534. let loader = Loader::new(buffer.clone(), redraw);
  1535. let chat_weak: Weak<ChatView> = Weak::new();
  1536. let types = smol::block_on(async {
  1537. let i18n = crate::util::i18n::I18nBabelFish::new(String::new(), "en-US");
  1538. msg::TypeNodes::new(&chat, shared, i18n, loader, buffer.clone(), chat_weak).await
  1539. });
  1540. // Two days of privmsgs; the buffer derives the separator.
  1541. let ts = |day: i64, h: u32| {
  1542. use chrono::TimeZone;
  1543. let date =
  1544. chrono::NaiveDate::from_ymd_opt(2026, 8, 30).unwrap() + chrono::Duration::days(day);
  1545. let dt = date.and_hms_opt(h, 0, 0).unwrap();
  1546. chrono::Local.from_local_datetime(&dt).unwrap().timestamp_millis() as Timestamp
  1547. };
  1548. let mut buffer = smol::block_on(buffer.lock());
  1549. let mut selected = HashSet::new();
  1550. let mut ids = vec![];
  1551. for (ts, idb, text) in
  1552. [(ts(0, 10), b'a', "oldest"), (ts(0, 11), b'b', "middle"), (ts(1, 9), b'c', "newest")]
  1553. {
  1554. let mut id = [0u8; 32];
  1555. id[0] = idb;
  1556. let id = MessageId(id);
  1557. ids.push(id);
  1558. let payload = codec::encode_privmsg_payload("alice", text, true);
  1559. let rec = MsgRecord { ts, id, msg_type: MsgType::PrivMsg, payload, height: 0. };
  1560. types.measure(&rec);
  1561. assert!(buffer.insert(MsgRecord { height: 20., ..rec }));
  1562. }
  1563. // The derived separator for day 1 sits between the days.
  1564. let sep = buffer
  1565. .iter_display_order()
  1566. .find(|rec| rec.msg_type.is_derived())
  1567. .expect("separator derived")
  1568. .clone();
  1569. types.measure(&sep);
  1570. selected.insert((ts(0, 10), ids[0]));
  1571. selected.insert((ts(1, 9), ids[2]));
  1572. selected.insert((sep.ts, sep.id));
  1573. // Display order (newest first): c, [sep], b, a — selected c,
  1574. // sep, a contribute in that order.
  1575. let text = copy_selected(&buffer, &selected, &types);
  1576. let label = msg::datemsg::datestr(sep.ts);
  1577. assert_eq!(text, format!("alice newest\n{label}\nalice oldest"));
  1578. // Nothing selected: nothing copied.
  1579. selected.clear();
  1580. assert_eq!(copy_selected(&buffer, &selected, &types), "");
  1581. }
  1582. }
  1583. impl Drop for ChatView {
  1584. fn drop(&mut self) {
  1585. self.renderer.replace_draw_calls(vec![(self.dc_key, Default::default())]);
  1586. }
  1587. }
  1588. impl std::fmt::Debug for ChatView {
  1589. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
  1590. write!(f, "{:?}", self.node.upgrade().unwrap())
  1591. }
  1592. }