Ver Fonte

Add basic text editing widget.

Kestrel há 8 meses atrás
pai
commit
347460c180
14 ficheiros alterados com 485 adições e 49 exclusões
  1. 2 0
      Cargo.toml
  2. 88 0
      examples/render_layout_tree.rs
  3. 78 0
      examples/text_edits.rs
  4. 66 2
      src/input.rs
  5. 60 6
      src/layout.rs
  6. 18 12
      src/layout/cache.rs
  7. 2 2
      src/layout/calc.rs
  8. 5 5
      src/text.rs
  9. 18 1
      src/ui.rs
  10. 11 7
      src/widget.rs
  11. 15 12
      src/widget/button.rs
  12. 1 1
      src/widget/label.rs
  13. 110 0
      src/widget/text_edit.rs
  14. 11 1
      src/window.rs

+ 2 - 0
Cargo.toml

@@ -21,3 +21,5 @@ kahlo = { git = "https://git.flying-kestrel.ca/kestrel/kahlo-rs.git", rev = "690
 fontdue = "0.9.0"
 
 [dev-dependencies]
+serde_json = { version = "1.0" }
+serde = { version = "1.0", features = ["derive"] }

+ 88 - 0
examples/render_layout_tree.rs

@@ -0,0 +1,88 @@
+use std::collections::HashMap;
+
+
+#[derive(serde::Deserialize)]
+struct Margin {
+    top: isize,
+    bottom: isize,
+    left: isize,
+    right: isize,
+}
+
+#[derive(serde::Deserialize)]
+struct Node {
+    id: usize,
+    behaviour: String,
+    child_arrangement: String,
+    width_policy: [usize; 3],
+    height_policy: [usize; 3],
+    halign: String,
+    valign: String,
+    margin: Margin,
+    table_cell: Option<[usize; 2]>,
+    render_area: Option<[[usize; 2]; 2]>,
+
+    children: Vec<Node>,
+}
+
+fn main() {
+    let mut args = std::env::args();
+    let execname = args.next().unwrap();
+    if args.len() != 1 {
+        println!("usage: {} path-to-json", execname);
+        return
+    }
+
+    let fname = args.next().unwrap();
+    let indata = std::fs::read(fname).expect("couldn't open file");
+
+    let nodetree : Node = serde_json::from_slice(&indata).expect("couldn't parse file");
+
+    let mut x_pos : HashMap<usize, usize> = HashMap::new();
+    let mut y_pos : HashMap<usize, usize> = HashMap::new();
+
+    let mut parents : HashMap<usize, usize> = HashMap::new();
+    let mut siblings : HashMap<usize, usize> = HashMap::new();
+
+    let mut dfs = vec![&nodetree];
+    let mut max_x = 0;
+    while let Some(node) = dfs.pop() {
+        println!("visiting node {}", node.id);
+
+        // X coordinate:
+        // case 1: no sibling, so same x as parent
+        // case 2: sibling, so use max_x + 1
+        // Y coordinate:
+        // always use parent's y pos + 1
+
+        let x = match siblings.get(&node.id) {
+            None => parents.get(&node.id).and_then(|p| x_pos.get(p)).cloned().unwrap_or(0),
+            Some(_sib) => max_x + 1,
+        };
+        let y = parents.get(&node.id).and_then(|p| y_pos.get(p)).map(|v| v + 1).unwrap_or(0);
+
+        max_x = max_x.max(x);
+
+        x_pos.insert(node.id, x);
+        y_pos.insert(node.id, y);
+
+        let mut sibling : Option<usize> = None;
+        for ch in node.children.iter() {
+            parents.insert(ch.id, node.id);
+            if let Some(sibling) = sibling {
+                siblings.insert(ch.id, sibling);
+            }
+            sibling = Some(ch.id);
+            dfs.push(ch);
+        }
+    }
+
+    let mut yvalues = y_pos.values().collect::<Vec<_>>();
+    yvalues.sort();
+    yvalues.dedup();
+    for y in yvalues {
+        let mut line = y_pos.iter().filter(|(_,v)| *v == y).map(|(k,_)| (x_pos.get(k).unwrap(), k)).collect::<Vec<_>>();
+        line.sort();
+        println!("{line:?}");
+    }
+}

+ 78 - 0
examples/text_edits.rs

@@ -0,0 +1,78 @@
+use kahlo::math::PixelSideOffsets;
+use patina::{
+    layout::SizePolicy,
+    prelude::*,
+    ui::{UIControlMsg, UIHandle},
+    widget,
+};
+
+enum TextEditMsg {
+    CloseRequest,
+}
+
+struct TextEditWindow {
+    root: widget::Frame<Self>,
+}
+
+impl TextEditWindow {
+    fn new(uih: &mut UIHandle) -> Self {
+        let mut group = widget::FormGroup::new(uih);
+        group.layout_node_mut().set_height_policy(SizePolicy::fixed(0));
+        group.set_label_margin(PixelSideOffsets::new(0, 10, 0, 10));
+
+        group.add_widget("simple editor:", {
+            let mut edit = widget::TextEdit::new(uih);
+            edit.set_text("default text");
+            edit.boxed()
+        });
+        group.add_widget("simple editor:", {
+            let mut edit = widget::TextEdit::new(uih);
+            edit.set_text("......");
+            edit.boxed()
+        });
+        group.add_widget("simple editor:", {
+            let mut edit = widget::TextEdit::new(uih);
+            edit.set_text("^^^^^^");
+            edit.boxed()
+        });
+
+        Self { root: widget::Frame::wrap_widget(group) }
+    }
+}
+
+impl Component for TextEditWindow {
+    type ParentMsg = UIControlMsg;
+    type Msg = TextEditMsg;
+
+    fn process(&mut self, _msg: Self::Msg) -> Vec<Self::ParentMsg> {
+        let mut out = String::new();
+        patina::layout::dump_node_tree(self.root.layout_node(), &mut out);
+        std::fs::write("patina-form-example.layout_tree", out)
+            .expect("couldn't dump node tree");
+
+        let jsout = std::fs::File::create("patina-text-edit-example.json").unwrap();
+        patina::layout::dump_tree_json(self.root.layout_node(), &mut std::io::BufWriter::new(jsout)).expect("couldn't dump node tree JSON");
+        vec![UIControlMsg::Terminate]
+    }
+}
+
+impl WindowComponent for TextEditWindow {
+    fn map_window_event(&self, we: patina::window::WindowEvent) -> Option<Self::Msg> {
+        match we {
+            patina::window::WindowEvent::CloseRequested => Some(TextEditMsg::CloseRequest),
+            _ => None,
+        }
+    }
+
+    type RootWidget = widget::Frame<Self>;
+    fn root_widget(&self) -> &Self::RootWidget {
+        &self.root
+    }
+    fn root_widget_mut(&mut self) -> &mut Self::RootWidget {
+        &mut self.root
+    }
+}
+
+fn main() {
+    patina::ui::make_opinionated_ui(TextEditWindow::new).run();
+}

+ 66 - 2
src/input.rs

@@ -1,5 +1,11 @@
 mod button;
+use std::cell::Cell;
+
 pub use button::ButtonSet;
+use kahlo::math::PixelBox;
+use winit::keyboard::Key;
+
+use crate::layout::LayoutNodeID;
 
 #[derive(Clone, Copy, Debug)]
 #[repr(u8)]
@@ -23,6 +29,26 @@ impl From<MouseButton> for u8 {
 
 pub type MouseButtonSet = ButtonSet<MouseButton>;
 
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
+pub enum MouseCheckStatus {
+    /// Cursor is outside the region.
+    Idle,
+    /// Cursor just entered the region.
+    Enter,
+    /// Cursor just left the region.
+    Leave,
+
+    /// Cursor is still in the region, no mouse button held.
+    Hover,
+    /// Cursor is still in the region, with the mouse button pressed.
+    Hold,
+
+    /// Cursor is in the region, and the button was just pressed.
+    Click,
+    /// Cursor is in the region, and the button was just released.
+    Release,
+}
+
 #[derive(Default)]
 pub struct MouseState {
     pub pos: kahlo::math::PixelPoint,
@@ -43,12 +69,50 @@ impl MouseState {
 #[derive(Default)]
 pub struct InputState {
     pub mouse: MouseState,
+    pub text_input: Option<String>,
+    pub keypress: Option<Key>,
+    focus: Cell<Option<LayoutNodeID>>,
 }
 
 impl InputState {
-    /// Note the passing of a frame.
-    pub fn tick(&mut self) {
+    pub fn mouse_status_in(&self, area: PixelBox, button: MouseButton) -> MouseCheckStatus {
+        match (area.contains(self.mouse.pos), area.contains(self.mouse.last_pos)) {
+            (false, false) => MouseCheckStatus::Idle,
+            (true, false) => MouseCheckStatus::Enter,
+            (false, true) => MouseCheckStatus::Leave,
+            (true, true) => match (self.mouse.buttons.active(button), self.mouse.last_buttons.active(button)) {
+                (false, false) => MouseCheckStatus::Hover,
+                (true, false) => MouseCheckStatus::Click,
+                (false, true) => MouseCheckStatus::Release,
+                (true, true) => MouseCheckStatus::Hold
+            }
+        }
+    }
+}
+
+/// Focus-related functionality
+impl InputState {
+    pub fn focus_on(&self, id: LayoutNodeID) {
+        self.focus.set(Some(id));
+    }
+    pub fn clear_focus(&self) {
+        self.focus.set(None);
+    }
+    pub fn is_focused(&self, id: LayoutNodeID) -> bool {
+        self.focus.get() == Some(id)
+    }
+}
+
+/// Internal updating functions
+impl InputState {
+    /// Note the passing of a frame, and clear per-frame input information.
+    pub(crate) fn tick(&mut self) {
         self.mouse.last_pos = self.mouse.pos;
         self.mouse.last_buttons = self.mouse.buttons;
+        self.text_input.take();
+        self.keypress.take();
+    }
+    pub(crate) fn set_text_input(&mut self, input: String) {
+        self.text_input = Some(input);
     }
 }

+ 60 - 6
src/layout.rs

@@ -8,14 +8,14 @@
 
 use std::{ops::Deref, rc::Rc};
 
-use cache::{Layer, LayoutCacheKey, NodeState};
+use cache::{Layer, NodeState};
 use kahlo::math::{PixelBox, PixelSideOffsets};
 
 mod arr;
 mod cache;
 mod calc;
 
-pub use cache::LayoutCache;
+pub use cache::{LayoutCache,LayoutNodeID};
 pub use calc::recalculate;
 
 pub struct CellMarker;
@@ -193,7 +193,7 @@ pub struct LayoutNode {
     /// Human-readable label for making layout tree dumps easier to read.
     label: Option<String>,
     /// Unique identifier for this LayoutNode
-    cache_key: LayoutCacheKey,
+    cache_key: LayoutNodeID,
     /// Reference to the global layout item cache.
     cache: Rc<LayoutCache>,
     /// Layout behaviour, or how this node behaves with respect to its parent.
@@ -230,7 +230,7 @@ impl std::fmt::Debug for LayoutNode {
 
 impl LayoutNode {
     pub fn new(cache: Rc<LayoutCache>) -> Self {
-        let cache_key = LayoutCacheKey::generate();
+        let cache_key = LayoutNodeID::generate();
         cache.update_queue.borrow_mut().push(cache_key);
         Self {
             label: None,
@@ -247,6 +247,10 @@ impl LayoutNode {
         }
     }
 
+    pub fn id(&self) -> LayoutNodeID {
+        self.cache_key
+    }
+
     pub fn cache(&self) -> &Rc<LayoutCache> {
         &self.cache
     }
@@ -260,7 +264,7 @@ impl LayoutNode {
     }
 
     pub fn relayout_tree(&self) {
-        let mut to_mark: Vec<LayoutCacheKey> = vec![self.cache_key];
+        let mut to_mark: Vec<LayoutNodeID> = vec![self.cache_key];
         while let Some(next) = to_mark.pop() {
             self.cache.with_state(next, |ns| {
                 ns.needs_update = true;
@@ -378,7 +382,7 @@ impl Clone for LayoutNode {
     fn clone(&self) -> Self {
         Self {
             label: self.label.clone(),
-            cache_key: LayoutCacheKey::generate(),
+            cache_key: LayoutNodeID::generate(),
             cache: self.cache.clone(),
             behaviour: self.behaviour,
             child_arrangement: self.child_arrangement.clone(),
@@ -437,6 +441,56 @@ pub fn dump_node_tree(lna: LayoutNodeAccess, out: &mut String) {
     dump_node_tree_helper(lna, 0, out);
 }
 
+pub fn dump_tree_json<W: ?Sized + std::io::Write>(lna: LayoutNodeAccess, out: &mut std::io::BufWriter<W>) -> std::io::Result<()> {
+    use std::io::Write;
+
+    write!(out, "{{")?;
+    write!(out, "\"label\": \"{}\" ", lna.label().unwrap_or("null"))?;
+    write!(out, ",\"id\": {} ", <LayoutNodeID as Into<usize>>::into(lna.id()))?;
+    write!(out, ",\"behaviour\": \"{:?}\"", lna.behaviour())?;
+    write!(out, ",\"child_arrangement\": \"{:?}\"", lna.arrangement())?;
+    write!(out, ",\"width_policy\": [{},{},{}]",
+       lna.width_policy().minimum,
+       lna.width_policy().desired,
+       lna.width_policy().slack_weight)?;
+    write!(out, ",\"height_policy\": [{},{},{}]",
+       lna.height_policy().minimum,
+       lna.height_policy().desired,
+       lna.height_policy().slack_weight)?;
+    write!(out, ",\"halign\": \"{:?}\"", lna.halign())?;
+    write!(out, ",\"valign\": \"{:?}\"", lna.valign())?;
+    write!(out, ",\"margin\": {{\"top\": {}, \"bottom\": {}, \"left\": {}, \"right\": {}}}",
+       lna.margin().top,
+       lna.margin().bottom,
+       lna.margin().left,
+       lna.margin().right,
+    )?;
+    match lna.table_cell() {
+        None => write!(out, ",\"table_cell\": null")?,
+        Some(c) => write!(out, ",\"table_cell\": [{},{}]", c.x, c.y)?
+    }
+    match lna.render_area() {
+        None => write!(out, ",\"render_area\": null")?,
+        Some(a) => write!(out, ",\"render_area\": [[{},{}],[{},{}]]", a.min.x, a.min.y, a.max.x, a.max.y)?
+    }
+
+    write!(out, ",\"children\": [")?;
+
+    let mut needs_sep = false;
+    for child in lna.child_iter() {
+        if needs_sep {
+            out.write(",".as_bytes())?;
+        } else {
+            needs_sep = true;
+        }
+        dump_tree_json(child, out)?;
+    }
+
+    write!(out, r#"]}}"#)?;
+
+    Ok(())
+}
+
 /// Iterator implementation to iterate across children of a LayoutNodeContainer
 #[derive(Clone)]
 pub struct LayoutChildIter<'l> {

+ 18 - 12
src/layout/cache.rs

@@ -3,11 +3,17 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc};
 use super::SizePolicy2D;
 
 #[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Copy)]
-pub struct LayoutCacheKey(usize);
-static NEXT_CACHE_KEY: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
-impl LayoutCacheKey {
+pub struct LayoutNodeID(usize);
+static NEXT_NODE_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(1);
+impl LayoutNodeID {
     pub fn generate() -> Self {
-        Self(NEXT_CACHE_KEY.fetch_add(1, std::sync::atomic::Ordering::SeqCst))
+        Self(NEXT_NODE_ID.fetch_add(1, std::sync::atomic::Ordering::SeqCst))
+    }
+}
+
+impl Into<usize> for LayoutNodeID {
+    fn into(self) -> usize {
+        self.0
     }
 }
 
@@ -18,15 +24,15 @@ pub struct NodeState {
     pub(super) net_policy: SizePolicy2D,
     pub(super) layer: Layer,
     pub(super) area: Option<kahlo::math::PixelBox>,
-    pub(super) children: Vec<LayoutCacheKey>,
+    pub(super) children: Vec<LayoutNodeID>,
 }
 
 #[derive(Debug, Default)]
 pub struct LayoutCache {
-    pub(super) update_queue: RefCell<Vec<LayoutCacheKey>>,
-    pub(super) render_queue: RefCell<Vec<LayoutCacheKey>>,
-    states: RefCell<HashMap<LayoutCacheKey, NodeState>>,
-    parents: RefCell<HashMap<LayoutCacheKey, LayoutCacheKey>>,
+    pub(super) update_queue: RefCell<Vec<LayoutNodeID>>,
+    pub(super) render_queue: RefCell<Vec<LayoutNodeID>>,
+    states: RefCell<HashMap<LayoutNodeID, NodeState>>,
+    parents: RefCell<HashMap<LayoutNodeID, LayoutNodeID>>,
 }
 
 impl LayoutCache {
@@ -69,17 +75,17 @@ impl LayoutCache {
 
     pub fn with_state<R, F: FnOnce(&mut NodeState) -> R>(
         &self,
-        ckey: LayoutCacheKey,
+        ckey: LayoutNodeID,
         f: F,
     ) -> Option<R> {
         self.states.borrow_mut().get_mut(&ckey).map(f)
     }
 
-    pub fn has_state_for(&self, ckey: LayoutCacheKey) -> bool {
+    pub fn has_state_for(&self, ckey: LayoutNodeID) -> bool {
         self.states.borrow().contains_key(&ckey)
     }
 
-    pub fn store(&self, ckey: LayoutCacheKey, parent: Option<LayoutCacheKey>, ns: NodeState) {
+    pub fn store(&self, ckey: LayoutNodeID, parent: Option<LayoutNodeID>, ns: NodeState) {
         self.states.borrow_mut().insert(ckey, ns);
         if let Some(pck) = parent {
             self.parents.borrow_mut().insert(ckey, pck);

+ 2 - 2
src/layout/calc.rs

@@ -1,4 +1,4 @@
-use super::{cache::LayoutCacheKey, Layer, LayoutNodeAccess, NodeBehaviour, NodeState};
+use super::{cache::LayoutNodeID, Layer, LayoutNodeAccess, NodeBehaviour, NodeState};
 
 pub fn recalculate(node: LayoutNodeAccess) {
     if let NodeBehaviour::Fixed { area } = node.behaviour() {
@@ -17,7 +17,7 @@ pub fn recalculate(node: LayoutNodeAccess) {
     }
 }
 
-fn arrangement_pass(parent: Option<LayoutCacheKey>, node: LayoutNodeAccess, layer: Layer) {
+fn arrangement_pass(parent: Option<LayoutNodeID>, node: LayoutNodeAccess, layer: Layer) {
     // early-out check
     if node.with_state(|st| st.needs_update) == Some(false) {
         return;

+ 5 - 5
src/text.rs

@@ -37,9 +37,9 @@ impl<'l> TextLine<'l> {
 
     pub fn calculate_size(&self) -> PixelSize {
         // pass: determine required size
-        let total_width = {
-            let ofs = self.generate_baseline_offsets().last().unwrap();
-            ofs.0 + ofs.1
+        let total_width = match self.generate_baseline_offsets().last() {
+            Some(ofs) => ofs.0 + ofs.1,
+            None => 0,
         };
         let line_metrics = self.font.horizontal_line_metrics(self.size).unwrap();
         let line_height = line_metrics.new_line_size.ceil() as i32;
@@ -47,7 +47,7 @@ impl<'l> TextLine<'l> {
         PixelSize::new(total_width as i32, line_height)
     }
 
-    pub fn render_line(&self) -> kahlo::Alphamap {
+    pub fn render_line(&self) -> (kahlo::Alphamap, i32) {
         // pass 1: determine required size
         let alphamap_size = self.calculate_size();
 
@@ -75,7 +75,7 @@ impl<'l> TextLine<'l> {
             );
         }
 
-        alphamap
+        (alphamap, 0)
     }
 }
 

+ 18 - 1
src/ui.rs

@@ -65,7 +65,6 @@ impl<UIC: UIComponent> UI<UIC> {
             state: UIState {
                 layout_cache: lcache,
                 window_states: Default::default(),
-                // text_renderer: TextRenderer::new(),
                 theme: Theme::default_with_font(
                     fontdue::Font::from_bytes(
                         std::fs::read(
@@ -193,11 +192,27 @@ impl<UIC: UIComponent> winit::application::ApplicationHandler<()> for UI<UIC> {
                 wsa.notify_resize(PixelSize::new(newsize.width as i32, newsize.height as i32));
                 wsa.request_redraw();
             }
+            winit::event::WindowEvent::KeyboardInput { device_id, event, is_synthetic } => {
+                if event.state == winit::event::ElementState::Pressed {
+                    if let Some(text) = event.text {
+                        wsa.update_text_input(text.as_str());
+                    }
+                    wsa.push_keypress(event.logical_key);
+
+                    self.pump_events(event_loop);
+                    wsa.request_redraw();
+                }
+            }
             _ => {}
         }
     }
 }
 
+/// Opinionated base UI component.
+///
+/// Most of the time, this will be the UI component you will want to use: a single main window,
+/// passing UI events back from the window component. Unless you want multiple windows, there is
+/// no reason to not use this component.
 pub struct OpinionatedUIComponent<'l, WC: WindowComponent> {
     window: Option<Window<WC>>,
     builder: Option<Box<dyn FnOnce(&mut UIHandle) -> WC + 'l>>,
@@ -233,6 +248,8 @@ impl<'l, WC: WindowComponent<ParentMsg = UIControlMsg>> UIComponent
     }
 }
 
+/// Build an [`OpinionatedUIComponent`] for a given [`WindowComponent`] that passes
+/// [`UIControlMsg`]s back to its parent component.
 pub fn make_opinionated_ui<'l, WC: WindowComponent<ParentMsg = UIControlMsg>>(
     wc: impl 'l + FnOnce(&mut UIHandle) -> WC,
 ) -> UI<OpinionatedUIComponent<'l, WC>> {

+ 11 - 7
src/widget.rs

@@ -6,18 +6,22 @@ use crate::{
     ui::UIHandle,
     window::RenderFormat,
 };
-
-mod label;
 use kahlo::math::PixelSideOffsets;
-pub use label::Label;
+
+mod button;
 mod frame;
-pub use frame::Frame;
 mod group;
-pub use group::{PlainGroup,FormGroup};
-mod button;
-pub use button::Button;
+mod label;
 mod spacer;
+mod text_edit;
+pub use button::Button;
+pub use frame::Frame;
+pub use group::{PlainGroup,FormGroup};
+pub use label::Label;
 pub use spacer::Spacer;
+pub use text_edit::TextEdit;
+
+pub type EventHook<C> = Box<dyn Fn() -> Option<<C as Component>::Msg>>;
 
 pub trait Widget<C: Component> {
     fn poll(&mut self, uih: &mut UIHandle, input_state: Option<&InputState>) -> Vec<C::Msg>;

+ 15 - 12
src/widget/button.rs

@@ -2,7 +2,7 @@ use kahlo::{math::PixelSideOffsets, prelude::*};
 
 use crate::{
     component::Component,
-    input::MouseButton,
+    input::{MouseButton, MouseCheckStatus},
     layout::{
         HorizontalAlignment, LayoutNode, LayoutNodeAccess, LayoutNodeContainer, SizePolicy,
         VerticalAlignment,
@@ -86,21 +86,24 @@ impl<C: Component> Widget<C> for Button<C> {
         input_state: Option<&crate::input::InputState>,
     ) -> Vec<<C as Component>::Msg> {
         let mut result: Vec<<C as Component>::Msg> = vec![];
-        if let (Some(istate), Some(area)) = (input_state, self.layout.render_area()) {
-            let before = self.state;
-            if area.contains_inclusive(istate.mouse.pos) {
-                if istate.mouse.buttons.active(MouseButton::Left) {
-                    self.state = ButtonState::Clicked;
-                } else if istate.mouse.released().active(MouseButton::Left) {
+        if let Some((istate, area)) = input_state.zip(self.layout.render_area()) {
+            let saved = self.state;
+            match istate.mouse_status_in(area, MouseButton::Left) {
+                MouseCheckStatus::Idle | MouseCheckStatus::Leave => {
                     self.state = ButtonState::Idle;
-                    result.extend(self.hook.as_ref().and_then(|v| v()));
-                } else {
+                },
+                MouseCheckStatus::Click | MouseCheckStatus::Hold => {
+                    self.state = ButtonState::Clicked;
+                }
+                MouseCheckStatus::Enter | MouseCheckStatus::Hover => {
                     self.state = ButtonState::Hovered;
                 }
-            } else {
-                self.state = ButtonState::Idle;
+                MouseCheckStatus::Release => {
+                    self.state = ButtonState::Hovered;
+                    result.extend(self.hook.as_ref().and_then(|v| v()));
+                }
             }
-            if self.state != before {
+            if self.state != saved {
                 self.layout.render_needed();
             }
         }

+ 1 - 1
src/widget/label.rs

@@ -57,7 +57,7 @@ impl<C: Component> Label<C> {
             return;
         };
         let line = uih.theme().make_line(text.as_str());
-        let rendered = line.render_line();
+        let (rendered, offset) = line.render_line();
         let sz = rendered.size();
         *self.rendered.borrow_mut() = Some(rendered);
         self.lnode.render_needed();

+ 110 - 0
src/widget/text_edit.rs

@@ -0,0 +1,110 @@
+use std::cell::RefCell;
+
+use crate::{prelude::*, layout::{LeafLayoutNode, LayoutNodeAccess, LayoutNode, SizePolicy}, ui::UIHandle, input::{MouseButton, MouseCheckStatus}};
+use kahlo::{prelude::*, math::PixelBox};
+use winit::keyboard::{Key, NamedKey};
+
+pub struct TextEdit<C: Component> {
+    lnode: LeafLayoutNode,
+    text: String,
+    text_shift: usize,
+    cursor_pos: Option<usize>,
+    rendered: RefCell<Option<(kahlo::Alphamap, i32)>>,
+    onchange: Option<Box<dyn Fn(&str) -> Option<C::Msg>>>,
+}
+
+impl<C: Component> TextEdit<C> {
+    pub fn new(uih: &UIHandle) -> Self {
+        let minheight = (uih.theme().ui_font_size * 1.5).round() as usize;
+        let mut lnode = uih.new_layout_node();
+        lnode
+            .set_width_policy(SizePolicy::expanding(1))
+            .set_height_policy(SizePolicy::fixed(minheight));
+
+        Self {
+            lnode: LeafLayoutNode::new(lnode),
+            text: String::new(),
+            text_shift: 0,
+            cursor_pos: None,
+            rendered: None.into(),
+            onchange: None
+        }
+    }
+
+    pub fn set_text(&mut self, to: &str) {
+        self.text = to.to_string();
+    }
+
+    fn update_hints(&mut self, uih: &UIHandle) {
+        let line = uih.theme().make_line(self.text.as_str());
+        let (rendered, offset) = line.render_line();
+        let sz = rendered.size();
+        *self.rendered.borrow_mut() = Some((rendered, offset));
+        self.lnode.render_needed();
+
+        let wp = self.lnode.width_policy().with_minimum(sz.width as usize);
+        self.lnode.set_width_policy(wp);
+        let hp = self.lnode.height_policy().with_minimum(sz.height as usize);
+        self.lnode.set_height_policy(hp);
+    }
+}
+
+impl<C: Component> Widget<C> for TextEdit<C> {
+    fn poll(&mut self, uih: &mut UIHandle, input_state: Option<&crate::input::InputState>) -> Vec<<C as Component>::Msg> {
+        let Some(istate) = input_state else { return vec![] };
+
+        if istate.is_focused(self.lnode.id()) {
+            if let Some(kp) = &istate.keypress {
+                match kp {
+                    Key::Named(NamedKey::Backspace) => {
+                        self.text.pop();
+                        self.lnode.render_needed();
+                        self.update_hints(uih);
+                    },
+                    Key::Named(NamedKey::Delete) => {
+                        self.text.pop();
+                    },
+                    Key::Named(NamedKey::Enter) => {
+                        istate.clear_focus();
+                    },
+                    _ => {
+                        if let Some(inp) = &istate.text_input {
+                            println!("appending text input {inp:x?}");
+                            self.text.push_str(inp.as_str());
+                            self.lnode.render_needed();
+                            self.update_hints(uih);
+                        }
+                    }
+                }
+            }
+        }
+        if istate.mouse_status_in(self.lnode.render_area().unwrap(), MouseButton::Left) == MouseCheckStatus::Click {
+            istate.focus_on(self.lnode.id());
+        }
+
+        self.update_hints(uih);
+
+        vec![]
+    }
+
+    fn layout_node(&self) -> LayoutNodeAccess {
+        LayoutNodeAccess::new(&self.lnode)
+    }
+    fn layout_node_mut(&mut self) -> &mut LayoutNode {
+        &mut self.lnode
+    }
+
+    fn render(&self, theme: &crate::theme::Theme, target: &mut kahlo::BitmapMut<crate::window::RenderFormat>) {
+        if self.lnode.render_check() {
+            let area = self.lnode.render_area().unwrap();
+            target.fill_region(area, theme.background);
+
+            let amap = self.rendered.borrow();
+            let Some((amap, offset)) = amap.as_ref() else {
+                return;
+            };
+
+            target.fill_region_masked(amap, PixelBox::from_size(amap.size()), area.min, theme.foreground, kahlo::colour::BlendMode::SourceAlpha);
+        }
+    }
+}

+ 11 - 1
src/window.rs

@@ -100,7 +100,7 @@ impl<WC: WindowComponent> WindowState<WC> {
             "time: {:.03}ms",
             (after - before).as_micros() as f32 / 1000.0
         );
-        let line = uih.theme().make_line(render_time.as_str()).render_line();
+        let (line,_) = uih.theme().make_line(render_time.as_str()).render_line();
 
         windowbuffer.fill_region_masked(
             &line,
@@ -159,6 +159,8 @@ pub(crate) trait WindowStateAccess {
     fn request_redraw(&self);
     fn update_mouse_pos(&self, pos: PixelPoint);
     fn update_mouse_button(&self, which: MouseButton, to: bool);
+    fn update_text_input(&self, what: &str);
+    fn push_keypress(&self, key: winit::keyboard::Key);
 }
 
 impl<WC: WindowComponent> WindowStateAccess for RefCell<WindowState<WC>> {
@@ -193,6 +195,14 @@ impl<WC: WindowComponent> WindowStateAccess for RefCell<WindowState<WC>> {
         let is = &mut self.borrow_mut().istate;
         is.mouse.buttons.set_button(which, to);
     }
+    fn update_text_input(&self, what: &str) {
+        let is = &mut self.borrow_mut().istate;
+        is.set_text_input(what.into());
+    }
+    fn push_keypress(&self, key: winit::keyboard::Key) {
+        let is = &mut self.borrow_mut().istate;
+        is.keypress = Some(key);
+    }
 }
 
 pub struct WindowBuilder<'r, 'l: 'r> {