/* This file is part of DarkFi (https://dark.fi) * * Copyright (C) 2020-2025 Dyne.org foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ use async_lock::Mutex as AsyncMutex; use async_trait::async_trait; use atomic_float::AtomicF32; use darkfi::system::msleep; use miniquad::{window, KeyCode, KeyMods, MouseButton, TouchPhase}; use rand::{rngs::OsRng, Rng}; use std::{ collections::HashMap, sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex as SyncMutex, OnceLock, Weak, }, time::Instant, }; use crate::{ error::Result, gfx::{ GfxDrawCall, GfxDrawInstruction, GfxDrawMesh, GfxTextureId, GraphicsEventPublisherPtr, Point, Rectangle, RenderApi, Vertex, }, mesh::{MeshBuilder, MeshInfo, COLOR_BLUE, COLOR_WHITE}, prop::{ PropertyAtomicGuard, PropertyBool, PropertyColor, PropertyFloat32, PropertyPtr, PropertyRect, PropertyStr, PropertyUint32, Role, }, pubsub::Subscription, scene::{Pimpl, SceneNodePtr, SceneNodeWeak}, text::{self, Glyph, GlyphPositionIter, TextShaperPtr}, util::{enumerate_ref, is_whitespace, unixtime, zip3}, ExecutorPtr, }; use super::{DrawUpdate, OnModify, UIObject}; pub mod editable; use editable::{Editable, Selection, TextIdx, TextPos}; pub mod repeat; use repeat::{PressedKey, PressedKeysSmoothRepeat}; macro_rules! d { ($($arg:tt)*) => { debug!(target: "ui::editbox", $($arg)*); } } macro_rules! t { ($($arg:tt)*) => { trace!(target: "ui::editbox", $($arg)*); } } // EOL whitespace is given a nudge since it has a width of 0 after text shaping const CURSOR_EOL_WS_NUDGE: f32 = 0.8; // EOL chars are more aesthetic when given a smallish nudge const CURSOR_EOL_NUDGE: f32 = 0.2; pub fn eol_nudge(font_size: f32, glyphs: &Vec) -> f32 { if is_whitespace(&glyphs.last().unwrap().substr) { (font_size * CURSOR_EOL_WS_NUDGE).round() } else { (font_size * CURSOR_EOL_NUDGE).round() } } #[derive(Clone)] struct TouchStartInfo { pos: Point, instant: std::time::Instant, } impl TouchStartInfo { fn new(pos: Point) -> Self { Self { pos, instant: std::time::Instant::now() } } } #[derive(Clone)] enum TouchStateAction { Inactive, Started { pos: Point, instant: std::time::Instant }, StartSelect, Select, DragSelectHandle { side: isize }, ScrollHoriz { start_pos: Point, scroll_start: f32 }, } struct TouchInfo { state: TouchStateAction, start: Option, scroll: PropertyFloat32, } impl TouchInfo { fn new(scroll: PropertyFloat32) -> Self { Self { state: TouchStateAction::Inactive, start: None, scroll } } fn start(&mut self, pos: Point) { d!("TouchStateAction::Started"); self.state = TouchStateAction::Started { pos, instant: std::time::Instant::now() }; } fn stop(&mut self) -> TouchStateAction { std::mem::replace(&mut self.state, TouchStateAction::Inactive) } fn update(&mut self, pos: &Point) { match &self.state { TouchStateAction::Started { pos: start_pos, instant } => { let travel_dist = pos.dist_sq(*start_pos); let x_dist = pos.x - start_pos.x; let elapsed = instant.elapsed().as_millis(); if travel_dist < 5. { if elapsed > 1000 { d!("TouchStateAction::StartSelect"); self.state = TouchStateAction::StartSelect; } } else if x_dist.abs() > 5. { d!("TouchStateAction::ScrollHoriz"); let scroll_start = self.scroll.get(); self.state = TouchStateAction::ScrollHoriz { start_pos: *start_pos, scroll_start }; } } _ => {} } } } pub type EditBoxPtr = Arc; pub struct EditBox { node: SceneNodeWeak, tasks: OnceLock>>, render_api: RenderApi, text_shaper: TextShaperPtr, key_repeat: SyncMutex, glyphs: SyncMutex>, /// DC key for the text text_dc_key: u64, cursor_mesh: SyncMutex>, /// DC key for the cursor. Allows updating cursor independently. cursor_dc_key: u64, is_active: PropertyBool, is_focused: PropertyBool, rect: PropertyRect, baseline: PropertyFloat32, scroll: PropertyFloat32, scroll_speed: PropertyFloat32, cursor_pos: PropertyUint32, font_size: PropertyFloat32, text: PropertyStr, text_color: PropertyColor, text_hi_color: PropertyColor, cursor_color: PropertyColor, cursor_width: PropertyFloat32, cursor_ascent: PropertyFloat32, cursor_descent: PropertyFloat32, cursor_blink_time: PropertyUint32, cursor_idle_time: PropertyUint32, hi_bg_color: PropertyColor, select_ascent: PropertyFloat32, select_descent: PropertyFloat32, selected: PropertyPtr, z_index: PropertyUint32, priority: PropertyUint32, debug: PropertyBool, editable: SyncMutex, select: SyncMutex>, mouse_btn_held: AtomicBool, cursor_is_visible: AtomicBool, blink_is_paused: AtomicBool, /// Used to explicitly hide the cursor. Must be manually re-enabled. hide_cursor: AtomicBool, touch_info: SyncMutex, is_phone_select: AtomicBool, old_window_scale: AtomicF32, window_scale: PropertyFloat32, parent_rect: SyncMutex>, is_mouse_hover: AtomicBool, } impl EditBox { pub async fn new( node: SceneNodeWeak, window_scale: PropertyFloat32, render_api: RenderApi, text_shaper: TextShaperPtr, ex: ExecutorPtr, ) -> Pimpl { t!("EditBox::new()"); let node_ref = &node.upgrade().unwrap(); let is_active = PropertyBool::wrap(node_ref, Role::Internal, "is_active", 0).unwrap(); let is_focused = PropertyBool::wrap(node_ref, Role::Internal, "is_focused", 0).unwrap(); let rect = PropertyRect::wrap(node_ref, Role::Internal, "rect").unwrap(); let baseline = PropertyFloat32::wrap(node_ref, Role::Internal, "baseline", 0).unwrap(); let scroll = PropertyFloat32::wrap(node_ref, Role::Internal, "scroll", 0).unwrap(); let scroll_speed = PropertyFloat32::wrap(node_ref, Role::Internal, "scroll_speed", 0).unwrap(); let cursor_pos = PropertyUint32::wrap(node_ref, Role::Internal, "cursor_pos", 0).unwrap(); let font_size = PropertyFloat32::wrap(node_ref, Role::Internal, "font_size", 0).unwrap(); let text = PropertyStr::wrap(node_ref, Role::Internal, "text", 0).unwrap(); let text_color = PropertyColor::wrap(node_ref, Role::Internal, "text_color").unwrap(); let text_hi_color = PropertyColor::wrap(node_ref, Role::Internal, "text_hi_color").unwrap(); let cursor_color = PropertyColor::wrap(node_ref, Role::Internal, "cursor_color").unwrap(); let cursor_width = PropertyFloat32::wrap(node_ref, Role::Internal, "cursor_width", 0).unwrap(); let cursor_ascent = PropertyFloat32::wrap(node_ref, Role::Internal, "cursor_ascent", 0).unwrap(); let cursor_descent = PropertyFloat32::wrap(node_ref, Role::Internal, "cursor_descent", 0).unwrap(); let hi_bg_color = PropertyColor::wrap(node_ref, Role::Internal, "hi_bg_color").unwrap(); let select_ascent = PropertyFloat32::wrap(node_ref, Role::Internal, "select_ascent", 0).unwrap(); let select_descent = PropertyFloat32::wrap(node_ref, Role::Internal, "select_descent", 0).unwrap(); let selected = node_ref.get_property("selected").unwrap(); let cursor_blink_time = PropertyUint32::wrap(node_ref, Role::Internal, "cursor_blink_time", 0).unwrap(); let cursor_idle_time = PropertyUint32::wrap(node_ref, Role::Internal, "cursor_idle_time", 0).unwrap(); let z_index = PropertyUint32::wrap(node_ref, Role::Internal, "z_index", 0).unwrap(); let priority = PropertyUint32::wrap(node_ref, Role::Internal, "priority", 0).unwrap(); let debug = PropertyBool::wrap(node_ref, Role::Internal, "debug", 0).unwrap(); let node_name = node_ref.name.clone(); let node_id = node_ref.id; // Must do this whenever the text changes let glyphs = text_shaper.shape(text.get(), font_size.get(), window_scale.get()); let self_ = Arc::new(Self { node, tasks: OnceLock::new(), render_api, text_shaper: text_shaper.clone(), key_repeat: SyncMutex::new(PressedKeysSmoothRepeat::new(400, 50)), glyphs: SyncMutex::new(glyphs), text_dc_key: OsRng.gen(), cursor_mesh: SyncMutex::new(None), cursor_dc_key: OsRng.gen(), is_active, is_focused, rect, baseline: baseline.clone(), scroll: scroll.clone(), scroll_speed, cursor_pos, font_size: font_size.clone(), text, text_color, text_hi_color, cursor_color, cursor_width, cursor_ascent, cursor_descent, cursor_blink_time, cursor_idle_time, hi_bg_color, select_ascent, select_descent, selected, z_index, priority, debug, editable: SyncMutex::new(Editable::new( text_shaper, font_size, window_scale.clone(), baseline, )), select: SyncMutex::new(vec![]), mouse_btn_held: AtomicBool::new(false), cursor_is_visible: AtomicBool::new(true), blink_is_paused: AtomicBool::new(false), hide_cursor: AtomicBool::new(false), touch_info: SyncMutex::new(TouchInfo::new(scroll)), is_phone_select: AtomicBool::new(false), old_window_scale: AtomicF32::new(window_scale.get()), window_scale, parent_rect: SyncMutex::new(None), is_mouse_hover: AtomicBool::new(false), }); Pimpl::EditBox(self_) } fn node(&self) -> SceneNodePtr { self.node.upgrade().unwrap() } /// Called whenever the text or any text property changes. fn regen_text_mesh(&self, mut clip: Rectangle) -> GfxDrawMesh { clip.x = 0.; clip.y = 0.; let is_focused = self.is_focused.get(); let text = self.text.get(); let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let text_color = self.text_color.get(); let text_hi_color = self.text_hi_color.get(); let baseline = self.baseline.get(); let scroll = self.scroll.get(); let cursor_pos = self.cursor_pos.get() as usize; let cursor_color = self.cursor_color.get(); let debug = self.debug.get(); //debug!(target: "ui::editbox", "Rendering text '{text}' clip={clip:?}"); //debug!(target: "ui::editbox", " cursor_pos={cursor_pos}, is_focused={is_focused}"); let rendered = self.editable.lock().unwrap().render(); let atlas = text::make_texture_atlas(&self.render_api, &rendered.glyphs); let mut mesh = MeshBuilder::with_clip(clip.clone()); let selections = self.select.lock().unwrap().clone(); // Just an assert if self.is_phone_select.load(Ordering::Relaxed) { assert!(selections.len() <= 1); } for select in &selections { self.draw_selected(&mut mesh, select, &rendered.glyphs, clip.h); } let select_marks = self.mark_selected_glyphs(&rendered.glyphs, selections); if rendered.has_underline() { self.draw_underline( &mut mesh, &rendered.glyphs, clip.h, rendered.under_start, rendered.under_end, ); } let glyph_pos_iter = GlyphPositionIter::new(font_size, window_scale, &rendered.glyphs, baseline); for (glyph_idx, mut glyph_rect, glyph, is_selected) in zip3(glyph_pos_iter, rendered.glyphs.iter(), select_marks.iter()) { let uv_rect = atlas.fetch_uv(glyph.glyph_id).expect("missing glyph UV rect"); glyph_rect.x -= scroll; //mesh.draw_outline(&glyph_rect, COLOR_BLUE, 2.); let mut color = text_color.clone(); if *is_selected { color = text_hi_color.clone(); } if glyph.sprite.has_color { color = COLOR_WHITE; } mesh.draw_box(&glyph_rect, color, uv_rect); } if debug { mesh.draw_outline(&clip, COLOR_BLUE, 1.); } mesh.alloc(&self.render_api).draw_with_texture(atlas.texture) } fn regen_cursor_mesh(&self) -> GfxDrawMesh { let cursor_width = self.cursor_width.get(); let cursor_ascent = self.cursor_ascent.get(); let cursor_descent = self.cursor_descent.get(); let baseline = self.baseline.get(); let cursor_rect = Rectangle { x: 0., y: baseline - cursor_ascent, w: cursor_width, h: cursor_ascent + cursor_descent, }; let cursor_color = self.cursor_color.get(); let mut mesh = MeshBuilder::new(); mesh.draw_filled_box(&cursor_rect, cursor_color); mesh.alloc(&self.render_api).draw_untextured() } fn cursor_px_offset(&self) -> f32 { assert!(self.is_focused.get()); let (cursor_pos, glyphs) = { let editable = self.editable.lock().unwrap(); let rendered = editable.render(); let cursor_pos = editable.get_cursor_pos(&rendered); (cursor_pos, rendered.glyphs) }; let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let baseline = self.baseline.get(); let scroll = self.scroll.get(); // Add composer glyphs too let glyph_pos_iter = GlyphPositionIter::new(font_size, window_scale, &glyphs, baseline); // Used for drawing the cursor when it's at the end of the line. let mut rhs = 0.; if cursor_pos == 0 { return 0. } for (glyph_idx, (mut glyph_rect, glyph)) in glyph_pos_iter.zip(glyphs.iter()).enumerate() { glyph_rect.x -= scroll; if cursor_pos == glyph_idx { return glyph_rect.x } rhs = glyph_rect.rhs(); } assert!(cursor_pos == glyphs.len()); rhs += eol_nudge(font_size, &glyphs); rhs } fn mark_selected_glyphs(&self, glyphs: &Vec, selections: Vec) -> Vec { let mut marks: Vec<_> = glyphs.iter().map(|_| false).collect(); for select in selections { let start = std::cmp::min(select.start, select.end); let end = std::cmp::max(select.start, select.end); for i in start..end { marks[i] = true; } } marks } fn draw_selected( &self, mesh: &mut MeshBuilder, select: &Selection, glyphs: &Vec, clip_h: f32, ) { if select.start == select.end { return } let start = std::cmp::min(select.start, select.end); let end = std::cmp::max(select.start, select.end); let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let baseline = self.baseline.get(); let scroll = self.scroll.get(); let hi_bg_color = self.hi_bg_color.get(); let glyph_pos_iter = GlyphPositionIter::new(font_size, window_scale, &glyphs, baseline); let is_phone_select = self.is_phone_select.load(Ordering::Relaxed); let mut start_x = 0.; let mut end_x = 0.; // When cursor lands at the end of the line let mut rhs = 0.; for (glyph_idx, mut glyph_rect) in glyph_pos_iter.enumerate() { glyph_rect.x -= scroll; if glyph_idx == start { start_x = glyph_rect.x; } if glyph_idx == end { end_x = glyph_rect.x; } rhs = glyph_rect.rhs(); } if start == 0 { start_x = scroll; } if end == glyphs.len() { rhs += eol_nudge(font_size, &glyphs); end_x = rhs; } let select_ascent = self.select_ascent.get(); let select_descent = self.select_descent.get(); let select_rect = Rectangle { x: start_x, y: baseline - select_ascent, w: end_x - start_x, h: select_ascent + select_descent, }; mesh.draw_box(&select_rect, hi_bg_color, &Rectangle::zero()); if is_phone_select { self.draw_phone_select_handle(mesh, start_x, -1.); self.draw_phone_select_handle(mesh, end_x, 1.); } } fn draw_phone_select_handle(&self, mesh: &mut MeshBuilder, x: f32, side: f32) { t!("draw_phone_select_handle(..., {x}, {side})"); let baseline = self.baseline.get(); let select_ascent = self.select_ascent.get(); let select_descent = self.select_descent.get(); let color = self.text_hi_color.get(); // Transparent for fade let mut color_trans = color.clone(); color_trans[3] = 0.; // Vertical line downwards. We use this instead of draw_box() so we have a fade. let verts = vec![ Vertex { pos: [x - side * 1., baseline - select_ascent], color: color_trans, uv: [0., 0.], }, Vertex { pos: [x + side * 4., baseline - select_ascent], color: color_trans, uv: [0., 0.], }, Vertex { pos: [x - side * 1., baseline + select_descent + 5.], color, uv: [0., 0.] }, Vertex { pos: [x + side * 4., baseline + select_descent + 5.], color, uv: [0., 0.] }, ]; let indices = vec![0, 2, 1, 1, 2, 3]; mesh.append(verts, indices); let y = baseline + select_descent; // The arrow itself. // Go anti-clockwise let verts = vec![ Vertex { pos: [x, y], color, uv: [0., 0.] }, Vertex { pos: [x, y + 50.], color, uv: [0., 0.] }, Vertex { pos: [x + side * 30., y + 50.], color, uv: [0., 0.] }, Vertex { pos: [x + side * 50., y + 25.], color, uv: [0., 0.] }, ]; let indices = vec![0, 1, 2, 0, 2, 3]; mesh.append(verts, indices); } fn draw_underline( &self, mesh: &mut MeshBuilder, glyphs: &Vec, clip_h: f32, under_start: usize, under_end: usize, ) { assert!(under_start < under_end); let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let baseline = self.baseline.get(); let scroll = self.scroll.get(); let text_color = self.text_color.get(); let glyph_pos_iter = GlyphPositionIter::new(font_size, window_scale, &glyphs, baseline); let mut start_x = 0.; let mut end_x = 0.; // When cursor lands at the end of the line let mut rhs = 0.; for (glyph_idx, mut glyph_rect) in glyph_pos_iter.enumerate() { glyph_rect.x -= scroll; if glyph_idx == under_start { start_x = glyph_rect.x; } if glyph_idx == under_end { end_x = glyph_rect.x; } rhs = glyph_rect.rhs(); } if under_start == 0 { start_x = scroll; } if under_end == glyphs.len() { end_x = rhs; } // We don't need to do manual clipping since MeshBuilder should do that let underline_rect = Rectangle { x: start_x, y: baseline + 6., w: end_x - start_x, h: 4. }; mesh.draw_box(&underline_rect, text_color, &Rectangle::zero()); } async fn change_focus(self: Arc) { if !self.is_active.get() { return } d!("Focus changed"); // Cursor visibility will change so just redraw everything lol self.redraw().await; } async fn insert_char(&self, key: char) { let text = { let mut editable = self.editable.lock().unwrap(); let mut tmp = [0; 4]; let key_str = key.encode_utf8(&mut tmp); editable.compose(key_str, true); editable.get_text() }; let atom = &mut PropertyAtomicGuard::new(); self.text.set(atom, text); self.pause_blinking(); self.redraw().await; } async fn handle_shortcut(&self, key: char, mods: &KeyMods, atom: &mut PropertyAtomicGuard) { t!("handle_shortcut({:?}, {:?})", key, mods); match key { 'c' => { if mods.ctrl { self.copy_highlighted().unwrap(); } } 'v' => { if mods.ctrl { if let Some(text) = window::clipboard_get() { self.paste_text(text, atom).await; } } } _ => {} } } async fn handle_key(&self, key: &KeyCode, mods: &KeyMods, atom: &mut PropertyAtomicGuard) { t!("handle_key({:?}, {:?})", key, mods); match key { KeyCode::Left => { self.adjust_cursor(mods.shift, |editable| editable.move_cursor(-1)); self.pause_blinking(); //self.apply_cursor_scrolling(); self.redraw().await; } KeyCode::Right => { self.adjust_cursor(mods.shift, |editable| editable.move_cursor(1)); self.pause_blinking(); //self.apply_cursor_scrolling(); self.redraw().await; } KeyCode::Kp0 => self.insert_char('0').await, KeyCode::Kp1 => self.insert_char('1').await, KeyCode::Kp2 => self.insert_char('2').await, KeyCode::Kp3 => self.insert_char('3').await, KeyCode::Kp4 => self.insert_char('4').await, KeyCode::Kp5 => self.insert_char('5').await, KeyCode::Kp6 => self.insert_char('6').await, KeyCode::Kp7 => self.insert_char('7').await, KeyCode::Kp8 => self.insert_char('8').await, KeyCode::Kp9 => self.insert_char('9').await, KeyCode::KpDecimal => self.insert_char('.').await, KeyCode::Enter | KeyCode::KpEnter => { let node = self.node.upgrade().unwrap(); node.trigger("enter_pressed", vec![]).await.unwrap(); } KeyCode::Delete => { self.delete(0, 1); self.clamp_scroll(atom); self.pause_blinking(); self.text.set(atom, self.editable.lock().unwrap().get_text()); self.redraw().await; } KeyCode::Backspace => { self.delete(1, 0); self.clamp_scroll(atom); self.pause_blinking(); self.text.set(atom, self.editable.lock().unwrap().get_text()); self.redraw().await; } KeyCode::Home => { self.adjust_cursor(mods.shift, |editable| editable.move_start()); self.pause_blinking(); //self.apply_cursor_scrolling(); self.redraw().await; } KeyCode::End => { self.adjust_cursor(mods.shift, |editable| editable.move_end()); self.pause_blinking(); //self.apply_cursor_scrolling(); self.redraw().await; } _ => {} } } fn delete(&self, before: usize, after: usize) { let mut editable = self.editable.lock().unwrap(); let selection = std::mem::take(&mut *self.select.lock().unwrap()); if selection.is_empty() { editable.delete(before, after); return } let sel = selection.first().unwrap(); let cursor_pos = std::cmp::min(sel.start, sel.end); let render = editable.render(); // Same code in chatedit's TextWrap::delete_selected() let mut before_text = String::new(); let mut after_text = String::new(); 'next: for (pos, glyph) in render.glyphs.iter().enumerate() { for select in &selection { let start = std::cmp::min(select.start, select.end); let end = std::cmp::max(select.start, select.end); if start <= pos && pos < end { continue 'next } } if pos <= cursor_pos { before_text += &glyph.substr; } else { after_text += &glyph.substr; } } editable.set_text(before_text, after_text); self.is_phone_select.store(false, Ordering::Relaxed); // Reshow cursor (if hidden) self.hide_cursor.store(false, Ordering::Relaxed); } fn adjust_cursor(&self, has_shift: bool, move_cursor: impl Fn(&mut Editable)) { let mut editable = self.editable.lock().unwrap(); let rendered = editable.render(); let prev_cursor_pos = editable.get_cursor_pos(&rendered); move_cursor(&mut editable); let cursor_pos = editable.get_cursor_pos(&rendered); drop(editable); let mut select = self.select.lock().unwrap(); // Start selection if shift is held if has_shift { // Create a new selection if select.is_empty() { select.push(Selection::new(prev_cursor_pos, cursor_pos)); } // Update the selection select.last_mut().unwrap().end = cursor_pos; } else { select.clear(); } } /// This will select the entire word rather than move the cursor to that location fn start_touch_select(&self, x: f32) { let rect = self.rect.get(); let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let baseline = self.baseline.get(); { let mut editable = self.editable.lock().unwrap(); editable.end_compose(); let rendered = editable.render(); let x = x - rect.x + self.scroll.get(); let cpos = rendered.x_to_pos(x, font_size, window_scale, baseline); // Find word start let mut cpos_start = cpos; while cpos_start > 0 { // Is the glyph before this pos just whitespace? let glyph_str = &rendered.glyphs[cpos_start - 1].substr; if is_whitespace(glyph_str) { break } cpos_start -= 1; } // Find word end let mut cpos_end = cpos; while cpos_end < rendered.glyphs.len() { cpos_end += 1; let glyph_str = &rendered.glyphs[cpos_end].substr; if is_whitespace(glyph_str) { break } } let cidx_start = rendered.pos_to_idx(cpos_start); let cidx_end = rendered.pos_to_idx(cpos_end); // begin selection let mut select = self.select.lock().unwrap(); select.clear(); select.push(Selection::new(cpos_start, cpos_end)); } self.is_phone_select.store(true, Ordering::Relaxed); // redraw() will now hide the cursor self.hide_cursor.store(true, Ordering::Relaxed); } fn glyphs_to_string(glyphs: &Vec) -> String { let mut text = String::new(); for (i, glyph) in glyphs.iter().enumerate() { text.push_str(&glyph.substr); } text } fn delete_highlighted(&self, atom: &mut PropertyAtomicGuard) { assert!(!self.selected.is_null(0).unwrap()); assert!(!self.selected.is_null(1).unwrap()); let start = self.selected.get_u32(0).unwrap() as usize; let end = self.selected.get_u32(1).unwrap() as usize; let sel_start = std::cmp::min(start, end); let sel_end = std::cmp::max(start, end); let mut glyphs = self.glyphs.lock().unwrap().clone(); glyphs.drain(sel_start..sel_end); let text = Self::glyphs_to_string(&glyphs); t!("delete_highlighted() text='{text}', cursor_pos={sel_start}"); self.text.set(atom, text); self.selected.clone().set_null(atom, Role::Internal, 0).unwrap(); self.selected.clone().set_null(atom, Role::Internal, 1).unwrap(); self.cursor_pos.set(atom, sel_start as u32); } fn copy_highlighted(&self) -> Result<()> { let start = self.selected.get_u32(0)? as usize; let end = self.selected.get_u32(1)? as usize; let sel_start = std::cmp::min(start, end); let sel_end = std::cmp::max(start, end); let mut text = String::new(); let glyphs = self.glyphs.lock().unwrap().clone(); for (glyph_idx, glyph) in glyphs.iter().enumerate() { if sel_start <= glyph_idx && glyph_idx < sel_end { text.push_str(&glyph.substr); } } info!(target: "ui::editbox", "Copied '{}'", text); window::clipboard_set(&text); Ok(()) } async fn paste_text(&self, key: String, atom: &mut PropertyAtomicGuard) { let mut text = String::new(); let cursor_pos = self.cursor_pos.get(); if cursor_pos == 0 { text = key.clone(); } let glyphs = self.glyphs.lock().unwrap().clone(); for (glyph_idx, glyph) in glyphs.iter().enumerate() { text.push_str(&glyph.substr); if cursor_pos == glyph_idx as u32 + 1 { text.push_str(&key); } } self.text.set(atom, text); // Not always true lol self.cursor_pos.set(atom, cursor_pos + 1); self.apply_cursor_scrolling(atom); self.redraw().await; } async fn handle_touch_start(&self, pos: Point) -> bool { t!("handle_touch_start({pos:?})"); let mut touch_info = self.touch_info.lock().unwrap(); if self.try_handle_drag(&mut touch_info, pos) { return true } let rect = self.rect.get(); if !rect.contains(pos) {} touch_info.start(pos); true } fn try_handle_drag(&self, touch_info: &mut TouchInfo, pos: Point) -> bool { let selections = self.select.lock().unwrap().clone(); if self.is_phone_select.load(Ordering::Relaxed) && selections.len() == 1 { let select = selections.first().unwrap(); let rendered = { let editable = self.editable.lock().unwrap(); editable.render() }; let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let baseline = self.baseline.get(); // Get left handle centerpoint let x1 = rendered.pos_to_xw(select.start, font_size, window_scale, baseline).0; // Get right handle centerpoint let x2 = { let (x, w) = rendered.pos_to_xw(select.end, font_size, window_scale, baseline); x + w }; // Are we within range of either one? let select_descent = self.select_descent.get(); let scroll = self.scroll.get(); let y = baseline + select_descent + 25.; let p1 = Point::new(x1 - scroll, y); let p2 = Point::new(x2 - scroll, y); t!("handle center points = ({p1:?}, {p2:?})"); const TOUCH_RADIUS_SQ: f32 = 10_000.; // Make pos relative to the rect let pos_rel = pos - self.rect.get().pos(); if p1.dist_sq(pos_rel) <= TOUCH_RADIUS_SQ { d!("TouchStateAction::DragSelectHandle [side=-1]"); // Set touch_state status to enable begin dragging them touch_info.state = TouchStateAction::DragSelectHandle { side: -1 }; return true } if p2.dist_sq(pos_rel) <= TOUCH_RADIUS_SQ { d!("TouchStateAction::DragSelectHandle [side=1]"); // Set touch_state status to enable begin dragging them touch_info.state = TouchStateAction::DragSelectHandle { side: 1 }; return true } } false } async fn handle_touch_move(&self, pos: Point) -> bool { t!("handle_touch_move({pos:?})"); let atom = &mut PropertyAtomicGuard::new(); let touch_state = { let mut touch_info = self.touch_info.lock().unwrap(); touch_info.update(&pos); touch_info.state.clone() }; match &touch_state { TouchStateAction::Inactive => return false, TouchStateAction::StartSelect => { let x = pos.x; self.start_touch_select(x); self.redraw().await; d!("TouchStateAction::Select"); self.touch_info.lock().unwrap().state = TouchStateAction::Select; } TouchStateAction::DragSelectHandle { side } => { { assert!(*side == -1 || *side == 1); assert!(self.is_phone_select.load(Ordering::Relaxed)); let mut selections = self.select.lock().unwrap(); assert_eq!(selections.len(), 1); let select = selections.first_mut().unwrap(); let rendered = { let editable = self.editable.lock().unwrap(); editable.render() }; let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let baseline = self.baseline.get(); let pos_x = pos.x + self.scroll.get(); let mut pos = rendered.x_to_pos(pos_x, font_size, window_scale, baseline); if *side == -1 { let select_other_pos = &mut select.end; if pos >= *select_other_pos { pos = *select_other_pos - 1; } select.start = pos; } else { let select_other_pos = &mut select.start; if pos <= *select_other_pos { pos = *select_other_pos + 1; } select.end = pos; } } self.redraw().await; } TouchStateAction::ScrollHoriz { start_pos, scroll_start } => { let x_dist = start_pos.x - pos.x; let mut scroll = scroll_start + x_dist; scroll = scroll.clamp(0., self.max_cursor_scroll()); self.scroll.set(atom, scroll); self.redraw().await; } _ => {} } true } async fn handle_touch_end(&self, pos: Point) -> bool { t!("handle_touch_end({pos:?})"); let state = self.touch_info.lock().unwrap().stop(); match state { TouchStateAction::Inactive => return false, TouchStateAction::Started { pos: _, instant: _ } => { let rect = self.rect.get(); let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let baseline = self.baseline.get(); { let mut editable = self.editable.lock().unwrap(); let rendered = editable.render(); // Adjust with scroll here too let x = pos.x - rect.x; let cpos = rendered.x_to_pos(x, font_size, window_scale, baseline); let cidx = rendered.pos_to_idx(cpos); editable.set_cursor_idx(cidx); // begin selection let mut select = self.select.lock().unwrap(); select.clear(); } self.is_phone_select.store(false, Ordering::Relaxed); // Reshow cursor (if hidden) self.hide_cursor.store(false, Ordering::Relaxed); self.redraw().await; } _ => {} } window::show_keyboard(true); true } /// Whenever the cursor property is modified this MUST be called /// to recalculate the scroll x property. fn apply_cursor_scrolling(&self, atom: &mut PropertyAtomicGuard) { let rect = self.rect.get(); let cursor_pos = self.cursor_pos.get() as usize; let cursor_width = self.cursor_width.get(); let mut scroll = self.scroll.get(); let cursor_x = { let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let baseline = self.baseline.get(); let glyphs = self.glyphs.lock().unwrap().clone(); let mut glyph_pos_iter = GlyphPositionIter::new(font_size, window_scale, &glyphs, baseline); if cursor_pos == 0 { 0. } else if cursor_pos == glyphs.len() { let glyph_pos = glyph_pos_iter.last().unwrap(); let rhs = glyph_pos.rhs() + eol_nudge(font_size, &glyphs); rhs } else { assert!(cursor_pos < glyphs.len()); let glyph_pos = glyph_pos_iter.nth(cursor_pos).expect("glyph pos mismatch glyphs"); glyph_pos.x } }; // The LHS and RHS of the cursor box let cursor_lhs = cursor_x - scroll; let cursor_rhs = cursor_lhs + cursor_width; // RHS is outside if cursor_rhs > rect.w { // We want a scroll so RHS = w // cursor_x - scroll + CURSOR_WIDTH = rect.w scroll = cursor_x + cursor_width - rect.w; // LHS is negative } else if cursor_lhs < 0. { // We want scroll so LHS = 0 // cursor_x - scroll = 0 scroll = cursor_x; } self.scroll.set(atom, scroll); } fn max_cursor_scroll(&self) -> f32 { let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let baseline = self.baseline.get(); let rhs = { let mut editable = self.editable.lock().unwrap(); let rendered = editable.render(); if rendered.glyphs.is_empty() { return 0. } let glyph_pos_iter = GlyphPositionIter::new(font_size, window_scale, &rendered.glyphs, baseline); let last_rect = glyph_pos_iter.last().unwrap(); let mut rhs = last_rect.x + last_rect.w; rhs += eol_nudge(font_size, &rendered.glyphs); rhs }; let rect_w = self.rect.get().w; if rhs <= rect_w { return 0. } let max_scroll = rhs - rect_w; max_scroll } fn clamp_scroll(&self, atom: &mut PropertyAtomicGuard) { let mut scroll = self.scroll.get(); scroll = scroll.clamp(0., self.max_cursor_scroll()); self.scroll.set(atom, scroll); } fn pause_blinking(&self) { self.blink_is_paused.store(true, Ordering::Relaxed); self.cursor_is_visible.store(true, Ordering::Relaxed); } async fn redraw(&self) { let timest = unixtime(); t!("redraw()"); let parent_rect = self.parent_rect.lock().unwrap().unwrap().clone(); self.rect.eval(&parent_rect).expect("unable to eval rect"); let Some(draw_update) = self.make_draw_calls() else { error!(target: "ui::editbox", "Text failed to draw"); return }; self.render_api.replace_draw_calls(timest, draw_update.draw_calls); } fn redraw_cursor(&self) { let timest = unixtime(); let cursor_instrs = self.get_cursor_instrs(); let draw_calls = vec![( self.cursor_dc_key, GfxDrawCall { instrs: cursor_instrs, dcs: vec![], z_index: self.z_index.get() }, )]; self.render_api.replace_draw_calls(timest, draw_calls); } fn get_cursor_instrs(&self) -> Vec { if !self.is_focused.get() || !self.cursor_is_visible.load(Ordering::Relaxed) || self.hide_cursor.load(Ordering::Relaxed) { return vec![] } let mut cursor_instrs = vec![]; let rect_w = self.rect.get().w; let mut cursor_pos = Point::zero(); cursor_pos.x += self.cursor_px_offset(); if cursor_pos.x > rect_w { return vec![] } cursor_instrs.push(GfxDrawInstruction::Move(cursor_pos)); let cursor_mesh = { let mut cursor_mesh = self.cursor_mesh.lock().unwrap(); if cursor_mesh.is_none() { *cursor_mesh = Some(self.regen_cursor_mesh()); } cursor_mesh.clone().unwrap() }; cursor_instrs.push(GfxDrawInstruction::Draw(cursor_mesh)); cursor_instrs } fn make_draw_calls(&self) -> Option { let rect = self.rect.get(); let text_mesh = self.regen_text_mesh(rect.clone()); let cursor_instrs = self.get_cursor_instrs(); Some(DrawUpdate { key: self.text_dc_key, draw_calls: vec![ ( self.text_dc_key, GfxDrawCall { instrs: vec![ GfxDrawInstruction::Move(rect.pos()), GfxDrawInstruction::Draw(text_mesh), ], dcs: vec![self.cursor_dc_key], z_index: self.z_index.get(), }, ), ( self.cursor_dc_key, GfxDrawCall { instrs: cursor_instrs, dcs: vec![], z_index: self.z_index.get() }, ), ], }) } } impl Drop for EditBox { fn drop(&mut self) { self.render_api .replace_draw_calls(unixtime(), vec![(self.text_dc_key, Default::default())]); } } #[async_trait] impl UIObject for EditBox { fn priority(&self) -> u32 { self.priority.get() } async fn start(self: Arc, ex: ExecutorPtr) { let me = Arc::downgrade(&self); let mut on_modify = OnModify::new(ex.clone(), self.node.clone(), me.clone()); on_modify.when_change(self.is_focused.prop(), Self::change_focus); // When text has been changed. // Cursor and selection might be invalidated. async fn reset(self_: Arc) { self_.editable.lock().unwrap().end_compose(); self_.editable.lock().unwrap().set_text(self_.text.get(), String::new()); let atom = &mut PropertyAtomicGuard::new(); self_.cursor_pos.set(atom, 0); self_.selected.clone().set_null(atom, Role::Internal, 0).unwrap(); self_.selected.clone().set_null(atom, Role::Internal, 1).unwrap(); self_.scroll.set(atom, 0.); self_.redraw(); } async fn redraw(self_: Arc) { self_.redraw().await; } on_modify.when_change(self.rect.prop(), redraw); on_modify.when_change(self.baseline.prop(), redraw); // The commented properties are modified on input events // So then redraw() will get repeatedly triggered when these properties // are changed. We should find a solution. For now the hooks are disabled. //on_modify.when_change(scroll.prop(), redraw); //on_modify.when_change(cursor_pos.prop(), redraw); on_modify.when_change(self.font_size.prop(), redraw); // We must also reshape text on_modify.when_change(self.text.prop(), reset); on_modify.when_change(self.text_color.prop(), redraw); on_modify.when_change(self.hi_bg_color.prop(), redraw); //on_modify.when_change(selected.clone(), redraw); on_modify.when_change(self.z_index.prop(), redraw); on_modify.when_change(self.debug.prop(), redraw); async fn regen_cursor(self_: Arc) { let mesh = std::mem::take(&mut *self_.cursor_mesh.lock().unwrap()); } on_modify.when_change(self.cursor_color.prop(), regen_cursor); on_modify.when_change(self.cursor_ascent.prop(), regen_cursor); on_modify.when_change(self.cursor_descent.prop(), regen_cursor); on_modify.when_change(self.cursor_width.prop(), regen_cursor); let me2 = me.clone(); let cursor_blink_time = self.cursor_blink_time.clone(); let cursor_idle_time = self.cursor_idle_time.clone(); let blinking_cursor_task = ex.spawn(async move { loop { msleep(cursor_blink_time.get() as u64).await; let self_ = me2.upgrade().unwrap(); if self_.blink_is_paused.swap(false, Ordering::Relaxed) { msleep(cursor_idle_time.get() as u64).await; continue } // Invert the bool self_.cursor_is_visible.fetch_not(Ordering::Relaxed); self_.redraw_cursor(); } }); let mut tasks = on_modify.tasks; tasks.push(blinking_cursor_task); self.tasks.set(tasks); } async fn draw( &self, parent_rect: Rectangle, trace_id: u32, atom: &mut PropertyAtomicGuard, ) -> Option { t!("EditBox::draw() [trace_id={trace_id}]"); *self.parent_rect.lock().unwrap() = Some(parent_rect); self.rect.eval(&parent_rect).ok()?; self.clamp_scroll(atom); self.make_draw_calls() } async fn handle_char(&self, key: char, mods: KeyMods, repeat: bool) -> bool { // First filter for only single digit keys if DISALLOWED_CHARS.contains(&key) { return false } if !self.is_focused.get() { return false } let atom = &mut PropertyAtomicGuard::new(); if mods.ctrl || mods.alt { if repeat { return false } self.handle_shortcut(key, &mods, atom).await; return true } let actions = { let mut repeater = self.key_repeat.lock().unwrap(); repeater.key_down(PressedKey::Char(key), repeat) }; t!("Key {key:?} has {actions} actions"); for _ in 0..actions { self.insert_char(key).await; } true } async fn handle_key_down(&self, key: KeyCode, mods: KeyMods, repeat: bool) -> bool { // First filter for only single digit keys // Avoid processing events handled by insert_char() if !ALLOWED_KEYCODES.contains(&key) { return false } if !self.is_focused.get() { return false } let actions = { let mut repeater = self.key_repeat.lock().unwrap(); repeater.key_down(PressedKey::Key(key), repeat) }; let atom = &mut PropertyAtomicGuard::new(); // Suppress noisy message if actions > 0 { t!("Key {key:?} has {actions} actions"); } for _ in 0..actions { self.handle_key(&key, &mods, atom).await; } true } async fn handle_mouse_btn_down(&self, btn: MouseButton, mouse_pos: Point) -> bool { if !self.is_active.get() { return false } if btn != MouseButton::Left { return false } let rect = self.rect.get(); let atom = &mut PropertyAtomicGuard::new(); // clicking inside box will: // 1. make it active // 2. begin selection if !rect.contains(mouse_pos) { if self.is_focused.get() { d!("EditBox unfocused"); self.is_focused.set(atom, false); self.select.lock().unwrap().clear(); self.redraw().await; } return false } if self.is_focused.get() { d!("EditBox clicked"); } else { d!("EditBox focused"); self.is_focused.set(atom, true); } let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let baseline = self.baseline.get(); { let mut editable = self.editable.lock().unwrap(); let rendered = editable.render(); let x = mouse_pos.x - rect.x + self.scroll.get(); let cpos = rendered.x_to_pos(x, font_size, window_scale, baseline); let cidx = rendered.pos_to_idx(cpos); editable.set_cursor_idx(cidx); // begin selection let mut select = self.select.lock().unwrap(); select.clear(); select.push(Selection::new(cpos, cpos)); self.mouse_btn_held.store(true, Ordering::Relaxed); } self.pause_blinking(); self.redraw().await; true } async fn handle_mouse_btn_up(&self, btn: MouseButton, mouse_pos: Point) -> bool { if !self.is_active.get() { return false } // releasing mouse button will end selection self.mouse_btn_held.store(false, Ordering::Relaxed); false } async fn handle_mouse_move(&self, mouse_pos: Point) -> bool { if !self.is_active.get() { return false } let rect = self.rect.get(); self.is_mouse_hover.store(rect.contains(mouse_pos), Ordering::Relaxed); if !self.mouse_btn_held.load(Ordering::Relaxed) { return false } // if active and selection_active, then use x to modify the selection. // also implement scrolling when cursor is to the left or right // just scroll to the end // also set cursor_pos too let font_size = self.font_size.get(); let window_scale = self.window_scale.get(); let baseline = self.baseline.get(); { let mut editable = self.editable.lock().unwrap(); let rendered = editable.render(); let x = mouse_pos.x - rect.x + self.scroll.get(); let cpos = rendered.x_to_pos(x, font_size, window_scale, baseline); let cidx = rendered.pos_to_idx(cpos); editable.set_cursor_idx(cidx); // begin selection let mut select = self.select.lock().unwrap(); select.last_mut().unwrap().end = cpos; } self.pause_blinking(); //self.apply_cursor_scrolling(); self.redraw().await; true } async fn handle_mouse_wheel(&self, wheel_pos: Point) -> bool { if !self.is_mouse_hover.load(Ordering::Relaxed) { return false } let atom = &mut PropertyAtomicGuard::new(); let mut scroll = self.scroll.get() + wheel_pos.y * self.scroll_speed.get(); scroll = scroll.clamp(0., self.max_cursor_scroll()); t!("handle_mouse_wheel({wheel_pos:?}) [scroll={scroll}]"); self.scroll.set(atom, scroll); self.redraw().await; true } async fn handle_touch(&self, phase: TouchPhase, id: u64, touch_pos: Point) -> bool { if !self.is_active.get() { return false } // Ignore multi-touch if id != 0 { return false } match phase { TouchPhase::Started => self.handle_touch_start(touch_pos).await, TouchPhase::Moved => self.handle_touch_move(touch_pos).await, TouchPhase::Ended => self.handle_touch_end(touch_pos).await, TouchPhase::Cancelled => false, } } /* async fn handle_compose_text( &self, suggest_text: &str, cursor_pos: i32, is_commit: bool, ) -> bool { t!("handle_compose_text({suggest_text}, {is_commit})"); if !self.is_active.get() { return false } { let mut editable = self.editable.lock().unwrap(); editable.compose(suggest_text, is_commit); } let atom = &mut PropertyAtomicGuard::new(); //self.apply_cursor_scrolling(); self.clamp_scroll(atom); self.redraw().await; true } async fn handle_set_compose_region(&self, start: usize, end: usize) -> bool { t!("handle_set_compose_region({start}, {end})"); if !self.is_active.get() { return false } { let mut editable = self.editable.lock().unwrap(); editable.set_compose_region(start, end); } self.redraw().await; true } */ } /// Filter these char events from being handled since we handle them /// using the key_up/key_down events. /// Avoids duplicate processing of keyboard input events. pub static DISALLOWED_CHARS: &'static [char] = &['\r', '\u{8}', '\u{7f}', '\t', '\n']; /// These keycodes are handled via normal key_up/key_down events. /// Anything in this list must be disallowed char events. pub static ALLOWED_KEYCODES: &'static [KeyCode] = &[ KeyCode::Left, KeyCode::Right, KeyCode::Up, KeyCode::Down, KeyCode::Enter, KeyCode::Kp0, KeyCode::Kp1, KeyCode::Kp2, KeyCode::Kp3, KeyCode::Kp4, KeyCode::Kp5, KeyCode::Kp6, KeyCode::Kp7, KeyCode::Kp8, KeyCode::Kp9, KeyCode::KpDecimal, KeyCode::KpEnter, KeyCode::Delete, KeyCode::Backspace, KeyCode::Home, KeyCode::End, ];