diff --git a/packages/native-core-macro/src/lib.rs b/packages/native-core-macro/src/lib.rs index dda96b512..5bf50afcc 100644 --- a/packages/native-core-macro/src/lib.rs +++ b/packages/native-core-macro/src/lib.rs @@ -306,8 +306,7 @@ impl Member { + field.ty.to_token_stream().to_string().as_str()) .as_str(), Span::call_site(), - ) - .into(), + ), ident: field.ident.as_ref()?.clone(), }) } diff --git a/packages/native-core/src/real_dom.rs b/packages/native-core/src/real_dom.rs index 7d56b0dca..656c59b52 100644 --- a/packages/native-core/src/real_dom.rs +++ b/packages/native-core/src/real_dom.rs @@ -102,11 +102,7 @@ impl RealDom { self.tree.add_child(node_id, child_id); } - fn create_template_node( - &mut self, - node: &TemplateNode, - mutations_vec: &mut FxHashMap, - ) -> RealNodeId { + fn create_template_node(&mut self, node: &TemplateNode) -> RealNodeId { match node { TemplateNode::Element { tag, @@ -139,27 +135,18 @@ impl RealDom { }); let node_id = self.create_node(node); for child in *children { - let child_id = self.create_template_node(child, mutations_vec); + let child_id = self.create_template_node(child); self.add_child(node_id, child_id); } node_id } - TemplateNode::Text { text } => { - let node_id = self.create_node(Node::new(NodeType::Text { - text: text.to_string(), - })); - node_id - } - TemplateNode::Dynamic { .. } => { - let node_id = self.create_node(Node::new(NodeType::Placeholder)); - node_id - } - TemplateNode::DynamicText { .. } => { - let node_id = self.create_node(Node::new(NodeType::Text { - text: String::new(), - })); - node_id - } + TemplateNode::Text { text } => self.create_node(Node::new(NodeType::Text { + text: text.to_string(), + })), + TemplateNode::Dynamic { .. } => self.create_node(Node::new(NodeType::Placeholder)), + TemplateNode::DynamicText { .. } => self.create_node(Node::new(NodeType::Text { + text: String::new(), + })), } } @@ -172,7 +159,7 @@ impl RealDom { for template in mutations.templates { let mut template_root_ids = Vec::new(); for root in template.roots { - let id = self.create_template_node(root, &mut nodes_updated); + let id = self.create_template_node(root); template_root_ids.push(id); } self.templates diff --git a/packages/native-core/src/utils/cursor.rs b/packages/native-core/src/utils/cursor.rs new file mode 100644 index 000000000..86bcb71b1 --- /dev/null +++ b/packages/native-core/src/utils/cursor.rs @@ -0,0 +1,484 @@ +use std::cmp::Ordering; + +use dioxus_html::input_data::keyboard_types::{Code, Key, Modifiers}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pos { + pub col: usize, + pub row: usize, +} + +impl Pos { + pub fn new(col: usize, row: usize) -> Self { + Self { row, col } + } + + pub fn up(&mut self, rope: &str) { + self.move_row(-1, rope); + } + + pub fn down(&mut self, rope: &str) { + self.move_row(1, rope); + } + + pub fn right(&mut self, rope: &str) { + self.move_col(1, rope); + } + + pub fn left(&mut self, rope: &str) { + self.move_col(-1, rope); + } + + pub fn move_row(&mut self, change: i32, rope: &str) { + let new = self.row as i32 + change; + if new >= 0 && new < rope.lines().count() as i32 { + self.row = new as usize; + } + } + + pub fn move_col(&mut self, change: i32, rope: &str) { + self.realize_col(rope); + let idx = self.idx(rope) as i32; + if idx + change >= 0 && idx + change <= rope.len() as i32 { + let len_line = self.len_line(rope) as i32; + let new_col = self.col as i32 + change; + let diff = new_col - len_line; + if diff > 0 { + self.down(rope); + self.col = 0; + self.move_col(diff - 1, rope); + } else if new_col < 0 { + self.up(rope); + self.col = self.len_line(rope); + self.move_col(new_col + 1, rope); + } else { + self.col = new_col as usize; + } + } + } + + pub fn col(&self, rope: &str) -> usize { + self.col.min(self.len_line(rope)) + } + + pub fn row(&self) -> usize { + self.row + } + + fn len_line(&self, rope: &str) -> usize { + let line = rope.lines().nth(self.row).unwrap_or_default(); + let len = line.len(); + if len > 0 && line.chars().nth(len - 1) == Some('\n') { + len - 1 + } else { + len + } + } + + pub fn idx(&self, rope: &str) -> usize { + rope.lines().take(self.row).map(|l| l.len()).sum::() + self.col(rope) + } + + // the column can be more than the line length, cap it + pub fn realize_col(&mut self, rope: &str) { + self.col = self.col(rope); + } +} + +impl Ord for Pos { + fn cmp(&self, other: &Self) -> Ordering { + self.row.cmp(&other.row).then(self.col.cmp(&other.col)) + } +} + +impl PartialOrd for Pos { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cursor { + pub start: Pos, + pub end: Option, +} + +impl Cursor { + pub fn from_start(pos: Pos) -> Self { + Self { + start: pos, + end: None, + } + } + + pub fn new(start: Pos, end: Pos) -> Self { + Self { + start, + end: Some(end), + } + } + + fn move_cursor(&mut self, f: impl FnOnce(&mut Pos), shift: bool) { + if shift { + self.with_end(f); + } else { + f(&mut self.start); + self.end = None; + } + } + + fn delete_selection(&mut self, text: &mut String) -> [i32; 2] { + let first = self.first(); + let last = self.last(); + let dr = first.row as i32 - last.row as i32; + let dc = if dr != 0 { + -(last.col as i32) + } else { + first.col as i32 - last.col as i32 + }; + text.replace_range(first.idx(text)..last.idx(text), ""); + if let Some(end) = self.end.take() { + if self.start > end { + self.start = end; + } + } + [dc, dr] + } + + pub fn handle_input( + &mut self, + data: &dioxus_html::KeyboardData, + text: &mut String, + max_width: usize, + ) { + use Code::*; + match data.code() { + ArrowUp => { + self.move_cursor(|c| c.up(text), data.modifiers().contains(Modifiers::SHIFT)); + } + ArrowDown => { + self.move_cursor( + |c| c.down(text), + data.modifiers().contains(Modifiers::SHIFT), + ); + } + ArrowRight => { + if data.modifiers().contains(Modifiers::CONTROL) { + self.move_cursor( + |c| { + let mut change = 1; + let idx = c.idx(text); + let length = text.len(); + while idx + change < length { + let chr = text.chars().nth(idx + change).unwrap(); + if chr.is_whitespace() { + break; + } + change += 1; + } + c.move_col(change as i32, text); + }, + data.modifiers().contains(Modifiers::SHIFT), + ); + } else { + self.move_cursor( + |c| c.right(text), + data.modifiers().contains(Modifiers::SHIFT), + ); + } + } + ArrowLeft => { + if data.modifiers().contains(Modifiers::CONTROL) { + self.move_cursor( + |c| { + let mut change = -1; + let idx = c.idx(text) as i32; + while idx + change > 0 { + let chr = text.chars().nth((idx + change) as usize).unwrap(); + if chr == ' ' { + break; + } + change -= 1; + } + c.move_col(change as i32, text); + }, + data.modifiers().contains(Modifiers::SHIFT), + ); + } else { + self.move_cursor( + |c| c.left(text), + data.modifiers().contains(Modifiers::SHIFT), + ); + } + } + End => { + self.move_cursor( + |c| c.col = c.len_line(text), + data.modifiers().contains(Modifiers::SHIFT), + ); + } + Home => { + self.move_cursor(|c| c.col = 0, data.modifiers().contains(Modifiers::SHIFT)); + } + Backspace => { + self.start.realize_col(text); + let mut start_idx = self.start.idx(text); + if self.end.is_some() { + self.delete_selection(text); + } else if start_idx > 0 { + self.start.left(text); + text.replace_range(start_idx - 1..start_idx, ""); + if data.modifiers().contains(Modifiers::CONTROL) { + start_idx = self.start.idx(text); + while start_idx > 0 + && text + .chars() + .nth(start_idx - 1) + .filter(|c| *c != ' ') + .is_some() + { + self.start.left(text); + text.replace_range(start_idx - 1..start_idx, ""); + start_idx = self.start.idx(text); + } + } + } + } + Enter => { + if text.len() + 1 - self.selection_len(text) <= max_width { + text.insert(self.start.idx(text), '\n'); + self.start.col = 0; + self.start.down(text); + } + } + Tab => { + if text.len() + 1 - self.selection_len(text) <= max_width { + self.start.realize_col(text); + self.delete_selection(text); + text.insert(self.start.idx(text), '\t'); + self.start.right(text); + } + } + _ => { + self.start.realize_col(text); + if let Key::Character(character) = data.key() { + if text.len() + 1 - self.selection_len(text) <= max_width { + self.delete_selection(text); + let character = character.chars().next().unwrap(); + text.insert(self.start.idx(text), character); + self.start.right(text); + } + } + } + } + } + + pub fn with_end(&mut self, f: impl FnOnce(&mut Pos)) { + let mut new = self.end.take().unwrap_or_else(|| self.start.clone()); + f(&mut new); + self.end.replace(new); + } + + pub fn first(&self) -> &Pos { + if let Some(e) = &self.end { + e.min(&self.start) + } else { + &self.start + } + } + + pub fn last(&self) -> &Pos { + if let Some(e) = &self.end { + e.max(&self.start) + } else { + &self.start + } + } + + pub fn selection_len(&self, text: &str) -> usize { + self.last().idx(text) - self.first().idx(text) + } +} + +impl Default for Cursor { + fn default() -> Self { + Self { + start: Pos::new(0, 0), + end: None, + } + } +} + +#[test] +fn pos_direction_movement() { + let mut pos = Pos::new(100, 0); + let text = "hello world\nhi"; + + assert_eq!(pos.col(text), text.lines().next().unwrap_or_default().len()); + pos.down(text); + assert_eq!(pos.col(text), text.lines().nth(1).unwrap_or_default().len()); + pos.up(text); + assert_eq!(pos.col(text), text.lines().next().unwrap_or_default().len()); + pos.left(text); + assert_eq!( + pos.col(text), + text.lines().next().unwrap_or_default().len() - 1 + ); + pos.right(text); + assert_eq!(pos.col(text), text.lines().next().unwrap_or_default().len()); +} + +#[test] +fn pos_col_movement() { + let mut pos = Pos::new(100, 0); + let text = "hello world\nhi"; + + // move inside a row + pos.move_col(-5, text); + assert_eq!( + pos.col(text), + text.lines().next().unwrap_or_default().len() - 5 + ); + pos.move_col(5, text); + assert_eq!(pos.col(text), text.lines().next().unwrap_or_default().len()); + + // move between rows + pos.move_col(3, text); + assert_eq!(pos.col(text), 2); + pos.move_col(-3, text); + assert_eq!(pos.col(text), text.lines().next().unwrap_or_default().len()); + + // don't panic if moving out of range + pos.move_col(-100, text); + pos.move_col(1000, text); +} + +#[test] +fn cursor_row_movement() { + let mut pos = Pos::new(100, 0); + let text = "hello world\nhi"; + + pos.move_row(1, text); + assert_eq!(pos.row(), 1); + pos.move_row(-1, text); + assert_eq!(pos.row(), 0); + + // don't panic if moving out of range + pos.move_row(-100, text); + pos.move_row(1000, text); +} + +#[test] +fn cursor_input() { + let mut cursor = Cursor::from_start(Pos::new(0, 0)); + let mut text = "hello world\nhi".to_string(); + + for _ in 0..5 { + cursor.handle_input( + &dioxus_html::KeyboardData::new( + dioxus_html::input_data::keyboard_types::Key::ArrowRight, + dioxus_html::input_data::keyboard_types::Code::ArrowRight, + dioxus_html::input_data::keyboard_types::Location::Standard, + false, + Modifiers::empty(), + ), + &mut text, + 10, + ); + } + + for _ in 0..5 { + cursor.handle_input( + &dioxus_html::KeyboardData::new( + dioxus_html::input_data::keyboard_types::Key::Backspace, + dioxus_html::input_data::keyboard_types::Code::Backspace, + dioxus_html::input_data::keyboard_types::Location::Standard, + false, + Modifiers::empty(), + ), + &mut text, + 10, + ); + } + + assert_eq!(text, " world\nhi"); + + let goal_text = "hello world\nhi"; + let max_width = goal_text.len(); + cursor.handle_input( + &dioxus_html::KeyboardData::new( + dioxus_html::input_data::keyboard_types::Key::Character("h".to_string()), + dioxus_html::input_data::keyboard_types::Code::KeyH, + dioxus_html::input_data::keyboard_types::Location::Standard, + false, + Modifiers::empty(), + ), + &mut text, + max_width, + ); + + cursor.handle_input( + &dioxus_html::KeyboardData::new( + dioxus_html::input_data::keyboard_types::Key::Character("e".to_string()), + dioxus_html::input_data::keyboard_types::Code::KeyE, + dioxus_html::input_data::keyboard_types::Location::Standard, + false, + Modifiers::empty(), + ), + &mut text, + max_width, + ); + + cursor.handle_input( + &dioxus_html::KeyboardData::new( + dioxus_html::input_data::keyboard_types::Key::Character("l".to_string()), + dioxus_html::input_data::keyboard_types::Code::KeyL, + dioxus_html::input_data::keyboard_types::Location::Standard, + false, + Modifiers::empty(), + ), + &mut text, + max_width, + ); + + cursor.handle_input( + &dioxus_html::KeyboardData::new( + dioxus_html::input_data::keyboard_types::Key::Character("l".to_string()), + dioxus_html::input_data::keyboard_types::Code::KeyL, + dioxus_html::input_data::keyboard_types::Location::Standard, + false, + Modifiers::empty(), + ), + &mut text, + max_width, + ); + + cursor.handle_input( + &dioxus_html::KeyboardData::new( + dioxus_html::input_data::keyboard_types::Key::Character("o".to_string()), + dioxus_html::input_data::keyboard_types::Code::KeyO, + dioxus_html::input_data::keyboard_types::Location::Standard, + false, + Modifiers::empty(), + ), + &mut text, + max_width, + ); + + // these should be ignored + for _ in 0..10 { + cursor.handle_input( + &dioxus_html::KeyboardData::new( + dioxus_html::input_data::keyboard_types::Key::Character("o".to_string()), + dioxus_html::input_data::keyboard_types::Code::KeyO, + dioxus_html::input_data::keyboard_types::Location::Standard, + false, + Modifiers::empty(), + ), + &mut text, + max_width, + ); + } + + assert_eq!(text.to_string(), goal_text); +} diff --git a/packages/native-core/src/utils/mod.rs b/packages/native-core/src/utils/mod.rs new file mode 100644 index 000000000..58e98b0fa --- /dev/null +++ b/packages/native-core/src/utils/mod.rs @@ -0,0 +1,3 @@ +mod persistant_iterator; +pub use persistant_iterator::*; +pub mod cursor; diff --git a/packages/native-core/src/utils.rs b/packages/native-core/src/utils/persistant_iterator.rs similarity index 100% rename from packages/native-core/src/utils.rs rename to packages/native-core/src/utils/persistant_iterator.rs diff --git a/packages/tui/Cargo.toml b/packages/tui/Cargo.toml index 116784c7b..94aaf3296 100644 --- a/packages/tui/Cargo.toml +++ b/packages/tui/Cargo.toml @@ -13,6 +13,7 @@ license = "MIT/Apache-2.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +dioxus = { path = "../dioxus", version = "^0.2.1" } dioxus-core = { path = "../core", version = "^0.2.1" } dioxus-html = { path = "../html", version = "^0.2.1" } dioxus-native-core = { path = "../native-core", version = "^0.2.0" } diff --git a/packages/tui/examples/tui_widgets.rs b/packages/tui/examples/tui_widgets.rs new file mode 100644 index 000000000..793057bc5 --- /dev/null +++ b/packages/tui/examples/tui_widgets.rs @@ -0,0 +1,93 @@ +use dioxus::prelude::*; +use dioxus_html::FormData; +use dioxus_tui::prelude::*; +use dioxus_tui::Config; + +fn main() { + dioxus_tui::launch_cfg(app, Config::new()); +} + +fn app(cx: Scope) -> Element { + let bg_green = use_state(cx, || false); + + let color = if *bg_green.get() { "green" } else { "red" }; + cx.render(rsx! { + div{ + width: "100%", + background_color: "{color}", + flex_direction: "column", + align_items: "center", + justify_content: "center", + + Input{ + oninput: |data: FormData| if &data.value == "good"{ + bg_green.set(true); + } else{ + bg_green.set(false); + }, + r#type: "checkbox", + value: "good", + width: "50%", + height: "10%", + checked: "true", + } + Input{ + oninput: |data: FormData| if &data.value == "hello world"{ + bg_green.set(true); + } else{ + bg_green.set(false); + }, + width: "50%", + height: "10%", + maxlength: "11", + } + Input{ + oninput: |data: FormData| { + if (data.value.parse::().unwrap() - 40.0).abs() < 5.0 { + bg_green.set(true); + } else{ + bg_green.set(false); + } + }, + r#type: "range", + width: "50%", + height: "10%", + min: "20", + max: "80", + } + Input{ + oninput: |data: FormData| { + if data.value == "10"{ + bg_green.set(true); + } else{ + bg_green.set(false); + } + }, + r#type: "number", + width: "50%", + height: "10%", + maxlength: "4", + } + Input{ + oninput: |data: FormData| { + if data.value == "hello world"{ + bg_green.set(true); + } else{ + bg_green.set(false); + } + }, + r#type: "password", + width: "50%", + height: "10%", + maxlength: "11", + } + Input{ + onclick: |_: FormData| bg_green.set(true), + r#type: "button", + value: "green", + width: "50%", + height: "10%", + } + } + }) +} diff --git a/packages/tui/src/hooks.rs b/packages/tui/src/hooks.rs index b57b71e13..d627ac3e0 100644 --- a/packages/tui/src/hooks.rs +++ b/packages/tui/src/hooks.rs @@ -287,7 +287,10 @@ impl InnerInputState { fn prepare_mouse_data(mouse_data: &MouseData, layout: &Layout) -> MouseData { let Point { x, y } = layout.location; - let node_origin = ClientPoint::new(x.into(), y.into()); + let node_origin = ClientPoint::new( + layout_to_screen_space(x).into(), + layout_to_screen_space(y).into(), + ); let new_client_coordinates = (mouse_data.client_coordinates() - node_origin) .to_point() diff --git a/packages/tui/src/layout.rs b/packages/tui/src/layout.rs index d863f13d8..ba50b59a6 100644 --- a/packages/tui/src/layout.rs +++ b/packages/tui/src/layout.rs @@ -7,7 +7,7 @@ use dioxus_native_core::state::ChildDepState; use dioxus_native_core_macro::sorted_str_slice; use taffy::prelude::*; -use crate::screen_to_layout_space; +use crate::{screen_to_layout_space, unit_to_layout_space}; #[derive(Debug, Clone, Copy, PartialEq)] pub(crate) enum PossiblyUninitalized { @@ -105,6 +105,59 @@ impl ChildDepState for TaffyLayout { child_layout.push(l.node.unwrap()); } + fn scale_dimention(d: Dimension) -> Dimension { + match d { + Dimension::Points(p) => Dimension::Points(unit_to_layout_space(p)), + Dimension::Percent(p) => Dimension::Percent(p), + Dimension::Auto => Dimension::Auto, + Dimension::Undefined => Dimension::Undefined, + } + } + let style = Style { + position: Rect { + left: scale_dimention(style.position.left), + right: scale_dimention(style.position.right), + top: scale_dimention(style.position.top), + bottom: scale_dimention(style.position.bottom), + }, + margin: Rect { + left: scale_dimention(style.margin.left), + right: scale_dimention(style.margin.right), + top: scale_dimention(style.margin.top), + bottom: scale_dimention(style.margin.bottom), + }, + padding: Rect { + left: scale_dimention(style.padding.left), + right: scale_dimention(style.padding.right), + top: scale_dimention(style.padding.top), + bottom: scale_dimention(style.padding.bottom), + }, + border: Rect { + left: scale_dimention(style.border.left), + right: scale_dimention(style.border.right), + top: scale_dimention(style.border.top), + bottom: scale_dimention(style.border.bottom), + }, + gap: Size { + width: scale_dimention(style.gap.width), + height: scale_dimention(style.gap.height), + }, + flex_basis: scale_dimention(style.flex_basis), + size: Size { + width: scale_dimention(style.size.width), + height: scale_dimention(style.size.height), + }, + min_size: Size { + width: scale_dimention(style.min_size.width), + height: scale_dimention(style.min_size.height), + }, + max_size: Size { + width: scale_dimention(style.max_size.width), + height: scale_dimention(style.max_size.height), + }, + ..style + }; + if let PossiblyUninitalized::Initialized(n) = self.node { if self.style != style { taffy.set_style(n, style).unwrap(); diff --git a/packages/tui/src/lib.rs b/packages/tui/src/lib.rs index 38638f7c5..7b4f72ce3 100644 --- a/packages/tui/src/lib.rs +++ b/packages/tui/src/lib.rs @@ -1,5 +1,6 @@ use anyhow::Result; use crossterm::{ + cursor::{MoveTo, RestorePosition, SavePosition, Show}, event::{DisableMouseCapture, EnableMouseCapture, Event as TermEvent, KeyCode, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, @@ -28,11 +29,13 @@ mod focus; mod hooks; mod layout; mod node; +pub mod prelude; pub mod query; mod render; mod style; mod style_attributes; mod widget; +mod widgets; pub use config::*; pub use hooks::*; @@ -43,6 +46,10 @@ pub(crate) fn screen_to_layout_space(screen: u16) -> f32 { screen as f32 * 10.0 } +pub(crate) fn unit_to_layout_space(screen: f32) -> f32 { + screen * 10.0 +} + pub(crate) fn layout_to_screen_space(layout: f32) -> f32 { layout / 10.0 } @@ -136,7 +143,13 @@ fn render_vdom( let mut terminal = (!cfg.headless).then(|| { enable_raw_mode().unwrap(); let mut stdout = std::io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture).unwrap(); + execute!( + stdout, + EnterAlternateScreen, + EnableMouseCapture, + MoveTo(0, 1000) + ) + .unwrap(); let backend = CrosstermBackend::new(io::stdout()); Terminal::new(backend).unwrap() }); @@ -181,14 +194,16 @@ fn render_vdom( taffy.compute_layout(root_node, size).unwrap(); } if let Some(terminal) = &mut terminal { + execute!(terminal.backend_mut(), SavePosition).unwrap(); terminal.draw(|frame| { let rdom = rdom.borrow(); let mut taffy = taffy.lock().expect("taffy lock poisoned"); // size is guaranteed to not change when rendering - resize(frame.size(), &mut *taffy, &rdom); + resize(frame.size(), &mut taffy, &rdom); let root = &rdom[NodeId(0)]; - render::render_vnode(frame, &*taffy, &rdom, root, cfg, Point::ZERO); + render::render_vnode(frame, &taffy, &rdom, root, cfg, Point::ZERO); })?; + execute!(terminal.backend_mut(), RestorePosition, Show).unwrap(); } else { let rdom = rdom.borrow(); resize( diff --git a/packages/tui/src/prelude/mod.rs b/packages/tui/src/prelude/mod.rs new file mode 100644 index 000000000..2155f7707 --- /dev/null +++ b/packages/tui/src/prelude/mod.rs @@ -0,0 +1 @@ +pub use crate::widgets::*; diff --git a/packages/tui/src/query.rs b/packages/tui/src/query.rs index 64da87650..c052ad9ea 100644 --- a/packages/tui/src/query.rs +++ b/packages/tui/src/query.rs @@ -89,7 +89,7 @@ impl<'a> ElementRef<'a> { .ok(); layout.map(|layout| Layout { order: layout.order, - size: layout.size.map(|v| layout_to_screen_space(v)), + size: layout.size.map(layout_to_screen_space), location: Point { x: layout_to_screen_space(layout.location.x), y: layout_to_screen_space(layout.location.y), diff --git a/packages/tui/src/render.rs b/packages/tui/src/render.rs index ef18f2989..e7de5eaac 100644 --- a/packages/tui/src/render.rs +++ b/packages/tui/src/render.rs @@ -41,7 +41,7 @@ pub(crate) fn render_vnode( let x = layout_to_screen_space(fx).round() as u16; let y = layout_to_screen_space(fy).round() as u16; let Size { width, height } = *size; - let width = layout_to_screen_space(fx + width).round() as u16 + x; + let width = layout_to_screen_space(fx + width).round() as u16 - x; let height = layout_to_screen_space(fy + height).round() as u16 - y; match &node.node_data.node_type { diff --git a/packages/tui/src/widgets/button.rs b/packages/tui/src/widgets/button.rs new file mode 100644 index 000000000..f3ebc7901 --- /dev/null +++ b/packages/tui/src/widgets/button.rs @@ -0,0 +1,59 @@ +use std::collections::HashMap; + +use dioxus::prelude::*; +use dioxus_elements::input_data::keyboard_types::Key; +use dioxus_html as dioxus_elements; +use dioxus_html::FormData; + +#[derive(Props)] +pub(crate) struct ButtonProps<'a> { + #[props(!optional)] + raw_onclick: Option<&'a EventHandler<'a, FormData>>, + #[props(!optional)] + value: Option<&'a str>, + #[props(!optional)] + width: Option<&'a str>, + #[props(!optional)] + height: Option<&'a str>, +} + +#[allow(non_snake_case)] +pub(crate) fn Button<'a>(cx: Scope<'a, ButtonProps>) -> Element<'a> { + let state = use_state(cx, || false); + let width = cx.props.width.unwrap_or("1px"); + let height = cx.props.height.unwrap_or("1px"); + + let single_char = width == "1px" || height == "1px"; + let text = if let Some(v) = cx.props.value { v } else { "" }; + let border_style = if single_char { "none" } else { "solid" }; + let update = || { + let new_state = !state.get(); + if let Some(callback) = cx.props.raw_onclick { + callback.call(FormData { + value: text.to_string(), + values: HashMap::new(), + files: None, + }); + } + state.set(new_state); + }; + cx.render(rsx! { + div{ + width: "{width}", + height: "{height}", + border_style: "{border_style}", + flex_direction: "row", + align_items: "center", + justify_content: "center", + onclick: move |_| { + update(); + }, + onkeydown: move |evt|{ + if !evt.is_auto_repeating() && match evt.key(){ Key::Character(c) if c == " " =>true, Key::Enter=>true, _=>false } { + update(); + } + }, + "{text}" + } + }) +} diff --git a/packages/tui/src/widgets/checkbox.rs b/packages/tui/src/widgets/checkbox.rs new file mode 100644 index 000000000..483117212 --- /dev/null +++ b/packages/tui/src/widgets/checkbox.rs @@ -0,0 +1,82 @@ +use std::collections::HashMap; + +use dioxus::prelude::*; +use dioxus_elements::input_data::keyboard_types::Key; +use dioxus_html as dioxus_elements; +use dioxus_html::FormData; + +#[derive(Props)] +pub(crate) struct CheckBoxProps<'a> { + #[props(!optional)] + raw_oninput: Option<&'a EventHandler<'a, FormData>>, + #[props(!optional)] + value: Option<&'a str>, + #[props(!optional)] + width: Option<&'a str>, + #[props(!optional)] + height: Option<&'a str>, + #[props(!optional)] + checked: Option<&'a str>, +} + +#[allow(non_snake_case)] +pub(crate) fn CheckBox<'a>(cx: Scope<'a, CheckBoxProps>) -> Element<'a> { + let state = use_state(cx, || cx.props.checked.filter(|&c| c == "true").is_some()); + let width = cx.props.width.unwrap_or("1px"); + let height = cx.props.height.unwrap_or("1px"); + + let single_char = width == "1px" && height == "1px"; + let text = if single_char { + if *state.get() { + "☑" + } else { + "☐" + } + } else if *state.get() { + "✓" + } else { + " " + }; + let border_style = if width == "1px" || height == "1px" { + "none" + } else { + "solid" + }; + let update = move || { + let new_state = !state.get(); + if let Some(callback) = cx.props.raw_oninput { + callback.call(FormData { + value: if let Some(value) = &cx.props.value { + if new_state { + value.to_string() + } else { + String::new() + } + } else { + "on".to_string() + }, + values: HashMap::new(), + files: None, + }); + } + state.set(new_state); + }; + cx.render(rsx! { + div { + width: "{width}", + height: "{height}", + border_style: "{border_style}", + align_items: "center", + justify_content: "center", + onclick: move |_| { + update(); + }, + onkeydown: move |evt| { + if !evt.is_auto_repeating() && match evt.key(){ Key::Character(c) if c == " " =>true, Key::Enter=>true, _=>false } { + update(); + } + }, + "{text}" + } + }) +} diff --git a/packages/tui/src/widgets/input.rs b/packages/tui/src/widgets/input.rs new file mode 100644 index 000000000..2294b0f10 --- /dev/null +++ b/packages/tui/src/widgets/input.rs @@ -0,0 +1,102 @@ +use dioxus::prelude::*; +use dioxus_core::prelude::fc_to_builder; +use dioxus_html::FormData; + +use crate::widgets::button::Button; +use crate::widgets::checkbox::CheckBox; +use crate::widgets::number::NumbericInput; +use crate::widgets::password::Password; +use crate::widgets::slider::Slider; +use crate::widgets::textbox::TextBox; + +#[derive(Props)] +pub struct InputProps<'a> { + r#type: Option<&'static str>, + oninput: Option>, + onclick: Option>, + value: Option<&'a str>, + size: Option<&'a str>, + maxlength: Option<&'a str>, + width: Option<&'a str>, + height: Option<&'a str>, + min: Option<&'a str>, + max: Option<&'a str>, + step: Option<&'a str>, + checked: Option<&'a str>, +} + +#[allow(non_snake_case)] +pub fn Input<'a>(cx: Scope<'a, InputProps<'a>>) -> Element<'a> { + cx.render(match cx.props.r#type { + Some("checkbox") => { + rsx! { + CheckBox{ + raw_oninput: cx.props.oninput.as_ref(), + value: cx.props.value, + width: cx.props.width, + height: cx.props.height, + checked: cx.props.checked, + } + } + } + Some("range") => { + rsx! { + Slider{ + raw_oninput: cx.props.oninput.as_ref(), + value: cx.props.value, + width: cx.props.width, + height: cx.props.height, + max: cx.props.max, + min: cx.props.min, + step: cx.props.step, + } + } + } + Some("button") => { + rsx! { + Button{ + raw_onclick: cx.props.onclick.as_ref(), + value: cx.props.value, + width: cx.props.width, + height: cx.props.height, + } + } + } + Some("number") => { + rsx! { + NumbericInput{ + raw_oninput: cx.props.oninput.as_ref(), + value: cx.props.value, + size: cx.props.size, + max_length: cx.props.maxlength, + width: cx.props.width, + height: cx.props.height, + } + } + } + Some("password") => { + rsx! { + Password{ + raw_oninput: cx.props.oninput.as_ref(), + value: cx.props.value, + size: cx.props.size, + max_length: cx.props.maxlength, + width: cx.props.width, + height: cx.props.height, + } + } + } + _ => { + rsx! { + TextBox{ + raw_oninput: cx.props.oninput.as_ref(), + value: cx.props.value, + size: cx.props.size, + max_length: cx.props.maxlength, + width: cx.props.width, + height: cx.props.height, + } + } + } + }) +} diff --git a/packages/tui/src/widgets/mod.rs b/packages/tui/src/widgets/mod.rs new file mode 100644 index 000000000..916295e50 --- /dev/null +++ b/packages/tui/src/widgets/mod.rs @@ -0,0 +1,18 @@ +mod button; +mod checkbox; +mod input; +mod number; +mod password; +mod slider; +mod textbox; + +use dioxus_core::{ElementId, RenderReturn, Scope}; +pub use input::*; + +pub(crate) fn get_root_id(cx: Scope) -> Option { + if let RenderReturn::Sync(Ok(sync)) = cx.root_node() { + sync.root_ids.get(0).map(|id| id.get()) + } else { + None + } +} diff --git a/packages/tui/src/widgets/number.rs b/packages/tui/src/widgets/number.rs new file mode 100644 index 000000000..05cb2d678 --- /dev/null +++ b/packages/tui/src/widgets/number.rs @@ -0,0 +1,209 @@ +use crate::widgets::get_root_id; +use crate::Query; +use crossterm::{cursor::MoveTo, execute}; +use dioxus::prelude::*; +use dioxus_elements::input_data::keyboard_types::Key; +use dioxus_html as dioxus_elements; +use dioxus_html::FormData; +use dioxus_native_core::utils::cursor::{Cursor, Pos}; +use std::{collections::HashMap, io::stdout}; +use taffy::geometry::Point; + +#[derive(Props)] +pub(crate) struct NumbericInputProps<'a> { + #[props(!optional)] + raw_oninput: Option<&'a EventHandler<'a, FormData>>, + #[props(!optional)] + value: Option<&'a str>, + #[props(!optional)] + size: Option<&'a str>, + #[props(!optional)] + max_length: Option<&'a str>, + #[props(!optional)] + width: Option<&'a str>, + #[props(!optional)] + height: Option<&'a str>, +} +#[allow(non_snake_case)] +pub(crate) fn NumbericInput<'a>(cx: Scope<'a, NumbericInputProps>) -> Element<'a> { + let tui_query: Query = cx.consume_context().unwrap(); + let tui_query_clone = tui_query.clone(); + + let text_ref = use_ref(cx, || { + if let Some(intial_text) = cx.props.value { + intial_text.to_string() + } else { + String::new() + } + }); + let cursor = use_ref(cx, Cursor::default); + let dragging = use_state(cx, || false); + + let text = text_ref.read().clone(); + let start_highlight = cursor.read().first().idx(&text); + let end_highlight = cursor.read().last().idx(&text); + let (text_before_first_cursor, text_after_first_cursor) = text.split_at(start_highlight); + let (text_highlighted, text_after_second_cursor) = + text_after_first_cursor.split_at(end_highlight - start_highlight); + + let max_len = cx + .props + .max_length + .as_ref() + .and_then(|s| s.parse().ok()) + .unwrap_or(usize::MAX); + + let width = cx + .props + .width + .map(|s| s.to_string()) + // px is the same as em in tui + .or_else(|| cx.props.size.map(|s| s.to_string() + "px")) + .unwrap_or_else(|| "10px".to_string()); + let height = cx.props.height.unwrap_or("3px"); + + // don't draw a border unless there is enough space + let border = if width + .strip_suffix("px") + .and_then(|w| w.parse::().ok()) + .filter(|w| *w < 3) + .is_some() + || height + .strip_suffix("px") + .and_then(|h| h.parse::().ok()) + .filter(|h| *h < 3) + .is_some() + { + "none" + } else { + "solid" + }; + + let update = |text: String| { + if let Some(input_handler) = &cx.props.raw_oninput { + input_handler.call(FormData { + value: text, + values: HashMap::new(), + files: None, + }); + } + }; + let increase = move || { + let mut text = text_ref.write(); + *text = (text.parse::().unwrap_or(0.0) + 1.0).to_string(); + update(text.clone()); + }; + let decrease = move || { + let mut text = text_ref.write(); + *text = (text.parse::().unwrap_or(0.0) - 1.0).to_string(); + update(text.clone()); + }; + + render! { + div{ + width: "{width}", + height: "{height}", + border_style: "{border}", + + onkeydown: move |k| { + let is_text = match k.key(){ + Key::ArrowLeft | Key::ArrowRight | Key::Backspace => true, + Key::Character(c) if c=="." || c== "-" || c.chars().all(|c|c.is_numeric())=> true, + _ => false, + }; + if is_text{ + let mut text = text_ref.write(); + cursor.write().handle_input(&k, &mut text, max_len); + update(text.clone()); + + let node = tui_query.get(get_root_id(cx).unwrap()); + let Point{ x, y } = node.pos().unwrap(); + + let Pos { col, row } = cursor.read().start; + let (x, y) = (col as u16 + x as u16 + if border == "none" {0} else {1}, row as u16 + y as u16 + if border == "none" {0} else {1}); + if let Ok(pos) = crossterm::cursor::position() { + if pos != (x, y){ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + } + else{ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + } + else{ + match k.key() { + Key::ArrowUp =>{ + increase(); + } + Key::ArrowDown =>{ + decrease(); + } + _ => () + } + } + }, + onmousemove: move |evt| { + if *dragging.get() { + let offset = evt.data.element_coordinates(); + let mut new = Pos::new(offset.x as usize, offset.y as usize); + if border != "none" { + new.col = new.col.saturating_sub(1); + } + // textboxs are only one line tall + new.row = 0; + + if new != cursor.read().start { + cursor.write().end = Some(new); + } + } + }, + onmousedown: move |evt| { + let offset = evt.data.element_coordinates(); + let mut new = Pos::new(offset.x as usize, offset.y as usize); + if border != "none" { + new.col = new.col.saturating_sub(1); + } + new.row = 0; + + new.realize_col(&text_ref.read()); + cursor.set(Cursor::from_start(new)); + dragging.set(true); + let node = tui_query_clone.get(get_root_id(cx).unwrap()); + let Point{ x, y } = node.pos().unwrap(); + + let Pos { col, row } = cursor.read().start; + let (x, y) = (col as u16 + x as u16 + if border == "none" {0} else {1}, row as u16 + y as u16 + if border == "none" {0} else {1}); + if let Ok(pos) = crossterm::cursor::position() { + if pos != (x, y){ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + } + else{ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + }, + onmouseup: move |_| { + dragging.set(false); + }, + onmouseleave: move |_| { + dragging.set(false); + }, + onmouseenter: move |_| { + dragging.set(false); + }, + onfocusout: |_| { + execute!(stdout(), MoveTo(0, 1000)).unwrap(); + }, + + "{text_before_first_cursor}" + + span{ + background_color: "rgba(255, 255, 255, 50%)", + + "{text_highlighted}" + } + + "{text_after_second_cursor}" + } + } +} diff --git a/packages/tui/src/widgets/password.rs b/packages/tui/src/widgets/password.rs new file mode 100644 index 000000000..7f8455d71 --- /dev/null +++ b/packages/tui/src/widgets/password.rs @@ -0,0 +1,186 @@ +use crate::widgets::get_root_id; +use crate::Query; +use crossterm::{cursor::*, execute}; +use dioxus::prelude::*; +use dioxus_elements::input_data::keyboard_types::Key; +use dioxus_html as dioxus_elements; +use dioxus_html::FormData; +use dioxus_native_core::utils::cursor::{Cursor, Pos}; +use std::{collections::HashMap, io::stdout}; +use taffy::geometry::Point; + +#[derive(Props)] +pub(crate) struct PasswordProps<'a> { + #[props(!optional)] + raw_oninput: Option<&'a EventHandler<'a, FormData>>, + #[props(!optional)] + value: Option<&'a str>, + #[props(!optional)] + size: Option<&'a str>, + #[props(!optional)] + max_length: Option<&'a str>, + #[props(!optional)] + width: Option<&'a str>, + #[props(!optional)] + height: Option<&'a str>, +} +#[allow(non_snake_case)] +pub(crate) fn Password<'a>(cx: Scope<'a, PasswordProps>) -> Element<'a> { + let tui_query: Query = cx.consume_context().unwrap(); + let tui_query_clone = tui_query.clone(); + + let text_ref = use_ref(cx, || { + if let Some(intial_text) = cx.props.value { + intial_text.to_string() + } else { + String::new() + } + }); + let cursor = use_ref(cx, Cursor::default); + let dragging = use_state(cx, || false); + + let text = text_ref.read().clone(); + let start_highlight = cursor.read().first().idx(&text); + let end_highlight = cursor.read().last().idx(&text); + let (text_before_first_cursor, text_after_first_cursor) = text.split_at(start_highlight); + let (text_highlighted, text_after_second_cursor) = + text_after_first_cursor.split_at(end_highlight - start_highlight); + + let text_before_first_cursor = ".".repeat(text_before_first_cursor.len()); + let text_highlighted = ".".repeat(text_highlighted.len()); + let text_after_second_cursor = ".".repeat(text_after_second_cursor.len()); + + let max_len = cx + .props + .max_length + .as_ref() + .and_then(|s| s.parse().ok()) + .unwrap_or(usize::MAX); + + let width = cx + .props + .width + .map(|s| s.to_string()) + // px is the same as em in tui + .or_else(|| cx.props.size.map(|s| s.to_string() + "px")) + .unwrap_or_else(|| "10px".to_string()); + let height = cx.props.height.unwrap_or("3px"); + + // don't draw a border unless there is enough space + let border = if width + .strip_suffix("px") + .and_then(|w| w.parse::().ok()) + .filter(|w| *w < 3) + .is_some() + || height + .strip_suffix("px") + .and_then(|h| h.parse::().ok()) + .filter(|h| *h < 3) + .is_some() + { + "none" + } else { + "solid" + }; + + render! { + div{ + width: "{width}", + height: "{height}", + border_style: "{border}", + + onkeydown: move |k| { + if k.key()== Key::Enter { + return; + } + let mut text = text_ref.write(); + cursor.write().handle_input(&k, &mut text, max_len); + if let Some(input_handler) = &cx.props.raw_oninput{ + input_handler.call(FormData{ + value: text.clone(), + values: HashMap::new(), + files: None + }); + } + + let node = tui_query.get(get_root_id(cx).unwrap()); + let Point{ x, y } = node.pos().unwrap(); + + let Pos { col, row } = cursor.read().start; + let (x, y) = (col as u16 + x as u16 + if border == "none" {0} else {1}, row as u16 + y as u16 + if border == "none" {0} else {1}); + if let Ok(pos) = crossterm::cursor::position() { + if pos != (x, y){ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + } + else{ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + }, + + onmousemove: move |evt| { + if *dragging.get() { + let offset = evt.data.element_coordinates(); + let mut new = Pos::new(offset.x as usize, offset.y as usize); + if border != "none" { + new.col = new.col.saturating_sub(1); + } + // textboxs are only one line tall + new.row = 0; + + if new != cursor.read().start { + cursor.write().end = Some(new); + } + } + }, + onmousedown: move |evt| { + let offset = evt.data.element_coordinates(); + let mut new = Pos::new(offset.x as usize, offset.y as usize); + if border != "none" { + new.col = new.col.saturating_sub(1); + } + // textboxs are only one line tall + new.row = 0; + + new.realize_col(&text_ref.read()); + cursor.set(Cursor::from_start(new)); + dragging.set(true); + let node = tui_query_clone.get(get_root_id(cx).unwrap()); + let Point{ x, y } = node.pos().unwrap(); + + let Pos { col, row } = cursor.read().start; + let (x, y) = (col as u16 + x as u16 + if border == "none" {0} else {1}, row as u16 + y as u16 + if border == "none" {0} else {1}); + if let Ok(pos) = crossterm::cursor::position() { + if pos != (x, y){ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + } + else{ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + }, + onmouseup: move |_| { + dragging.set(false); + }, + onmouseleave: move |_| { + dragging.set(false); + }, + onmouseenter: move |_| { + dragging.set(false); + }, + onfocusout: |_| { + execute!(stdout(), MoveTo(0, 1000)).unwrap(); + }, + + "{text_before_first_cursor}" + + span{ + background_color: "rgba(255, 255, 255, 50%)", + + "{text_highlighted}" + } + + "{text_after_second_cursor}" + } + } +} diff --git a/packages/tui/src/widgets/slider.rs b/packages/tui/src/widgets/slider.rs new file mode 100644 index 000000000..43f0ac77e --- /dev/null +++ b/packages/tui/src/widgets/slider.rs @@ -0,0 +1,108 @@ +use std::collections::HashMap; + +use crate::widgets::get_root_id; +use crate::Query; +use dioxus::prelude::*; +use dioxus_elements::input_data::keyboard_types::Key; +use dioxus_html as dioxus_elements; +use dioxus_html::FormData; + +#[derive(Props)] +pub(crate) struct SliderProps<'a> { + #[props(!optional)] + raw_oninput: Option<&'a EventHandler<'a, FormData>>, + #[props(!optional)] + value: Option<&'a str>, + #[props(!optional)] + width: Option<&'a str>, + #[props(!optional)] + height: Option<&'a str>, + #[props(!optional)] + min: Option<&'a str>, + #[props(!optional)] + max: Option<&'a str>, + #[props(!optional)] + step: Option<&'a str>, +} + +#[allow(non_snake_case)] +pub(crate) fn Slider<'a>(cx: Scope<'a, SliderProps>) -> Element<'a> { + let tui_query: Query = cx.consume_context().unwrap(); + + let value_state = use_state(cx, || 0.0); + let value: Option = cx.props.value.and_then(|v| v.parse().ok()); + let width = cx.props.width.unwrap_or("20px"); + let height = cx.props.height.unwrap_or("1px"); + let min = cx.props.min.and_then(|v| v.parse().ok()).unwrap_or(0.0); + let max = cx.props.max.and_then(|v| v.parse().ok()).unwrap_or(100.0); + let size = max - min; + let step = cx + .props + .step + .and_then(|v| v.parse().ok()) + .unwrap_or(size / 10.0); + + let current_value = if let Some(value) = value { + value + } else { + *value_state.get() + } + .max(min) + .min(max); + let fst_width = 100.0 * (current_value - min) / size; + let snd_width = 100.0 * (max - current_value) / size; + assert!(fst_width + snd_width > 99.0 && fst_width + snd_width < 101.0); + + let update = |value: String| { + if let Some(oninput) = cx.props.raw_oninput { + oninput.call(FormData { + value, + values: HashMap::new(), + files: None, + }); + } + }; + + render! { + div{ + width: "{width}", + height: "{height}", + display: "flex", + flex_direction: "row", + onkeydown: move |event| { + match event.key() { + Key::ArrowLeft => { + value_state.set((current_value - step).max(min).min(max)); + update(value_state.current().to_string()); + } + Key::ArrowRight => { + value_state.set((current_value + step).max(min).min(max)); + update(value_state.current().to_string()); + } + _ => () + } + }, + onmousemove: move |evt| { + let mouse = evt.data; + if !mouse.held_buttons().is_empty(){ + let node = tui_query.get(get_root_id(cx).unwrap()); + let width = node.size().unwrap().width; + let offset = mouse.element_coordinates(); + value_state.set(min + size*(offset.x as f32) / width as f32); + update(value_state.current().to_string()); + } + }, + div{ + width: "{fst_width}%", + background_color: "rgba(10,10,10,0.5)", + } + div{ + "|" + } + div{ + width: "{snd_width}%", + background_color: "rgba(10,10,10,0.5)", + } + } + } +} diff --git a/packages/tui/src/widgets/textbox.rs b/packages/tui/src/widgets/textbox.rs new file mode 100644 index 000000000..8628fca29 --- /dev/null +++ b/packages/tui/src/widgets/textbox.rs @@ -0,0 +1,182 @@ +use crate::widgets::get_root_id; +use crate::Query; +use crossterm::{cursor::*, execute}; +use dioxus::prelude::*; +use dioxus_elements::input_data::keyboard_types::Key; +use dioxus_html as dioxus_elements; +use dioxus_html::FormData; +use dioxus_native_core::utils::cursor::{Cursor, Pos}; +use std::{collections::HashMap, io::stdout}; +use taffy::geometry::Point; + +#[derive(Props)] +pub(crate) struct TextBoxProps<'a> { + #[props(!optional)] + raw_oninput: Option<&'a EventHandler<'a, FormData>>, + #[props(!optional)] + value: Option<&'a str>, + #[props(!optional)] + size: Option<&'a str>, + #[props(!optional)] + max_length: Option<&'a str>, + #[props(!optional)] + width: Option<&'a str>, + #[props(!optional)] + height: Option<&'a str>, +} +#[allow(non_snake_case)] +pub(crate) fn TextBox<'a>(cx: Scope<'a, TextBoxProps>) -> Element<'a> { + let tui_query: Query = cx.consume_context().unwrap(); + let tui_query_clone = tui_query.clone(); + + let text_ref = use_ref(cx, || { + if let Some(intial_text) = cx.props.value { + intial_text.to_string() + } else { + String::new() + } + }); + let cursor = use_ref(cx, Cursor::default); + let dragging = use_state(cx, || false); + + let text = text_ref.read().clone(); + let start_highlight = cursor.read().first().idx(&text); + let end_highlight = cursor.read().last().idx(&text); + let (text_before_first_cursor, text_after_first_cursor) = text.split_at(start_highlight); + let (text_highlighted, text_after_second_cursor) = + text_after_first_cursor.split_at(end_highlight - start_highlight); + + let max_len = cx + .props + .max_length + .as_ref() + .and_then(|s| s.parse().ok()) + .unwrap_or(usize::MAX); + + let width = cx + .props + .width + .map(|s| s.to_string()) + // px is the same as em in tui + .or_else(|| cx.props.size.map(|s| s.to_string() + "px")) + .unwrap_or_else(|| "10px".to_string()); + let height = cx.props.height.unwrap_or("3px"); + + // don't draw a border unless there is enough space + let border = if width + .strip_suffix("px") + .and_then(|w| w.parse::().ok()) + .filter(|w| *w < 3) + .is_some() + || height + .strip_suffix("px") + .and_then(|h| h.parse::().ok()) + .filter(|h| *h < 3) + .is_some() + { + "none" + } else { + "solid" + }; + + render! { + div{ + width: "{width}", + height: "{height}", + border_style: "{border}", + + onkeydown: move |k| { + if k.key() == Key::Enter { + return; + } + let mut text = text_ref.write(); + cursor.write().handle_input(&k, &mut text, max_len); + if let Some(input_handler) = &cx.props.raw_oninput{ + input_handler.call(FormData{ + value: text.clone(), + values: HashMap::new(), + files: None + }); + } + + let node = tui_query.get(get_root_id(cx).unwrap()); + let Point{ x, y } = node.pos().unwrap(); + + let Pos { col, row } = cursor.read().start; + let (x, y) = (col as u16 + x as u16 + if border == "none" {0} else {1}, row as u16 + y as u16 + if border == "none" {0} else {1}); + if let Ok(pos) = crossterm::cursor::position() { + if pos != (x, y){ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + } + else{ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + }, + + onmousemove: move |evt| { + if *dragging.get() { + let offset = evt.data.element_coordinates(); + let mut new = Pos::new(offset.x as usize, offset.y as usize); + if border != "none" { + new.col = new.col.saturating_sub(1); + } + // textboxs are only one line tall + new.row = 0; + + if new != cursor.read().start { + cursor.write().end = Some(new); + } + } + }, + onmousedown: move |evt| { + let offset = evt.data.element_coordinates(); + let mut new = Pos::new(offset.x as usize, offset.y as usize); + if border != "none" { + new.col = new.col.saturating_sub(1); + } + // textboxs are only one line tall + new.row = 0; + + new.realize_col(&text_ref.read()); + cursor.set(Cursor::from_start(new)); + dragging.set(true); + let node = tui_query_clone.get(get_root_id(cx).unwrap()); + let Point{ x, y } = node.pos().unwrap(); + + let Pos { col, row } = cursor.read().start; + let (x, y) = (col as u16 + x as u16 + if border == "none" {0} else {1}, row as u16 + y as u16 + if border == "none" {0} else {1}); + if let Ok(pos) = crossterm::cursor::position() { + if pos != (x, y){ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + } + else{ + execute!(stdout(), MoveTo(x, y)).unwrap(); + } + }, + onmouseup: move |_| { + dragging.set(false); + }, + onmouseleave: move |_| { + dragging.set(false); + }, + onmouseenter: move |_| { + dragging.set(false); + }, + onfocusout: |_| { + execute!(stdout(), MoveTo(0, 1000)).unwrap(); + }, + + "{text_before_first_cursor}" + + span{ + background_color: "rgba(255, 255, 255, 50%)", + + "{text_highlighted}" + } + + "{text_after_second_cursor}" + } + } +}