cushy/src/widgets/input.rs
Jonathan Johnson 07b93397c5
Optimizations
2023-11-12 19:54:10 -08:00

619 lines
22 KiB
Rust

use std::cmp::Ordering;
use std::fmt::Debug;
use std::panic::UnwindSafe;
use std::time::Duration;
use kludgine::app::winit::event::{ElementState, Ime, KeyEvent};
use kludgine::app::winit::keyboard::Key;
use kludgine::cosmic_text::{
Action, Affinity, Attrs, Buffer, Cursor, Edit, Editor, Metrics, Shaping,
};
use kludgine::figures::units::{Lp, Px, UPx};
use kludgine::figures::{
FloatConversion, IntoSigned, IntoUnsigned, Point, Rect, ScreenScale, Size,
};
use kludgine::shapes::{Shape, StrokeOptions};
use kludgine::text::TextOrigin;
use kludgine::{Color, Kludgine};
use crate::context::{EventContext, LayoutContext, WidgetContext};
use crate::styles::components::{HighlightColor, LineHeight, OutlineColor, TextColor, TextSize};
use crate::utils::ModifiersExt;
use crate::value::{Generation, IntoValue, Value};
use crate::widget::{Callback, EventHandling, Widget, HANDLED, IGNORED};
use crate::ConstraintLimit;
const CURSOR_BLINK_DURATION: Duration = Duration::from_millis(500);
/// A text input widget.
#[must_use]
pub struct Input {
/// The value of this widget.
pub text: Value<String>,
on_key: Option<Callback<KeyEvent, EventHandling>>,
editor: Option<LiveEditor>,
cursor_state: CursorState,
needs_to_select_all: bool,
mouse_buttons_down: usize,
}
impl Input {
/// Returns an empty widget.
pub fn empty() -> Self {
Self::new(String::new())
}
/// Returns a new widget containing `initial_text`.
pub fn new(initial_text: impl IntoValue<String>) -> Self {
Self {
text: initial_text.into_value(),
editor: None,
cursor_state: CursorState::default(),
on_key: None,
mouse_buttons_down: 0,
needs_to_select_all: true,
}
}
/// Sets the `on_key` callback.
///
/// This function is called for every keyboard input event. If [`HANDLED`]
/// is returned, this widget will ignore the event.
pub fn on_key<F>(mut self, on_key: F) -> Self
where
F: FnMut(KeyEvent) -> EventHandling + Send + UnwindSafe + 'static,
{
self.on_key = Some(Callback::new(on_key));
self
}
fn editor_mut(
&mut self,
kludgine: &mut Kludgine,
context: &WidgetContext<'_, '_>,
) -> &mut Editor {
match (&self.editor, self.text.generation()) {
(Some(editor), generation) if editor.generation == generation => {}
(_, generation) => {
let scale = kludgine.scale();
let mut buffer = Buffer::new(
kludgine.font_system(),
Metrics::new(
context.get(&TextSize).into_px(scale).into_float(),
context.get(&LineHeight).into_px(scale).into_float(),
),
);
self.text.map(|text| {
buffer.set_text(
kludgine.font_system(),
text,
Attrs::new(),
Shaping::Advanced,
);
});
self.editor = Some(LiveEditor {
editor: Editor::new(buffer),
generation,
});
}
}
&mut self.editor.as_mut().expect("just initialized").editor
}
fn select_all(&mut self) {
let Some(editor) = self.editor.as_mut().map(|editor| &mut editor.editor) else {
return;
};
if !editor.buffer().lines.is_empty() {
let line = editor.buffer().lines.len() - 1;
let end = Cursor::new_with_affinity(
line,
editor.buffer().lines[line].text().len(),
Affinity::After,
);
editor.set_cursor(end);
editor.set_select_opt(Some(Cursor::new_with_affinity(0, 0, Affinity::Before)));
}
}
}
impl Default for Input {
fn default() -> Self {
Self::new(String::new())
}
}
impl Debug for Input {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Input")
.field("text", &self.text)
.finish_non_exhaustive()
}
}
impl Widget for Input {
fn hit_test(&mut self, _location: Point<Px>, _context: &mut EventContext<'_, '_>) -> bool {
true
}
fn accept_focus(&mut self, _context: &mut EventContext<'_, '_>) -> bool {
true
}
fn mouse_down(
&mut self,
location: Point<Px>,
_device_id: kludgine::app::winit::event::DeviceId,
_button: kludgine::app::winit::event::MouseButton,
context: &mut EventContext<'_, '_>,
) -> EventHandling {
self.mouse_buttons_down += 1;
context.focus();
self.needs_to_select_all = false;
self.editor_mut(context.kludgine, &context.widget).action(
context.kludgine.font_system(),
Action::Click {
x: location.x.0,
y: location.y.0,
},
);
context.set_needs_redraw();
HANDLED
}
fn mouse_drag(
&mut self,
location: Point<Px>,
_device_id: kludgine::app::winit::event::DeviceId,
_button: kludgine::app::winit::event::MouseButton,
context: &mut EventContext<'_, '_>,
) {
self.editor_mut(context.kludgine, &context.widget).action(
context.kludgine.font_system(),
Action::Drag {
x: location.x.0,
y: location.y.0,
},
);
self.cursor_state.force_on();
context.set_needs_redraw();
}
fn mouse_up(
&mut self,
_location: Option<Point<Px>>,
_device_id: kludgine::app::winit::event::DeviceId,
_button: kludgine::app::winit::event::MouseButton,
_context: &mut EventContext<'_, '_>,
) {
self.mouse_buttons_down -= 1;
}
#[allow(clippy::too_many_lines)]
fn redraw(&mut self, context: &mut crate::context::GraphicsContext<'_, '_, '_, '_, '_>) {
self.cursor_state.update(context.elapsed());
let cursor_state = self.cursor_state;
let size = context.gfx.size();
let highlight = context.get(&HighlightColor);
let editor = self.editor_mut(&mut context.gfx, &context.widget);
let cursor = editor.cursor();
let selection = editor.select_opt();
let buffer = editor.buffer_mut();
buffer.set_size(
context.gfx.font_system(),
size.width.into_float(),
size.height.into_float(),
);
buffer.shape_until_scroll(context.gfx.font_system());
if context.focused() {
context.draw_focus_ring();
context.set_ime_allowed(true);
let line_height = Px::from_float(buffer.metrics().line_height);
if let Some(selection) = selection {
let (start, end) = if selection < cursor {
(selection, cursor)
} else {
(cursor, selection)
};
match (cursor_glyph(buffer, &start), cursor_glyph(buffer, &end)) {
(Ok((start_position, _)), Ok((end_position, end_width))) => {
if start_position.y == end_position.y {
// Single line selection
let width = end_position.x - start_position.x + end_width;
context.gfx.draw_shape(
&Shape::filled_rect(
Rect::new(start_position, Size::new(width, line_height)),
highlight,
),
Point::default(),
None,
None,
);
} else {
// Draw from start to end of line,
let width = size.width.into_signed() - start_position.x;
context.gfx.draw_shape(
&Shape::filled_rect(
Rect::new(start_position, Size::new(width, line_height)),
highlight,
),
Point::default(),
None,
None,
);
// Fill region between
let bottom_of_first_line = start_position.y + line_height;
let distance_between = end_position.y - bottom_of_first_line;
if distance_between > 0 {
context.gfx.draw_shape(
&Shape::filled_rect(
Rect::new(
Point::new(Px(0), bottom_of_first_line),
Size::new(size.width.into_signed(), distance_between),
),
highlight,
),
Point::default(),
None,
None,
);
}
// Draw from 0 to end + width
context.gfx.draw_shape(
&Shape::filled_rect(
Rect::new(
Point::new(Px(0), end_position.y),
Size::new(end_position.x + end_width, line_height),
),
highlight,
),
Point::default(),
None,
None,
);
}
}
(Ok((start_position, _)), Err(_)) => {
let width = size.width.into_signed() - start_position.x;
context.gfx.draw_shape(
&Shape::filled_rect(
Rect::new(start_position, Size::new(width, line_height)),
highlight,
),
Point::default(),
None,
None,
);
}
(Err(_), Ok((end_position, end_width))) => {
if end_position.y > 0 {
todo!("fill above start");
}
context.gfx.draw_shape(
&Shape::filled_rect(
Rect::new(
Point::new(Px(0), end_position.y),
Size::new(end_position.x + end_width, line_height),
),
highlight,
),
Point::default(),
None,
None,
);
}
(Err(start_not_visible), Err(end_not_visible))
if start_not_visible != end_not_visible =>
{
todo!("render full selection")
}
(Err(_), Err(_)) => {}
}
} else if let Ok((location, _)) = cursor_glyph(buffer, &cursor) {
let window_focused = context.window().focused().get();
if window_focused && cursor_state.visible {
context.gfx.draw_shape(
&Shape::filled_rect(
Rect::new(
location,
Size::new(Px(1), line_height),
),
highlight, // TODO cursor should be a bold color, highlight probably not. This should have its own color.
),
Point::default(),
None,
None,
);
}
if window_focused {
context.redraw_in(cursor_state.remaining_until_blink);
} else {
context.redraw_when_changed(context.window().focused());
}
}
} else {
let outline_color = context.get(&OutlineColor);
context.stroke_outline::<Lp>(outline_color, StrokeOptions::default());
}
let text_color = context.get(&TextColor);
context.gfx.draw_text_buffer(
buffer,
text_color,
TextOrigin::TopLeft,
Point::<Px>::default(),
None,
None,
);
}
fn layout(
&mut self,
available_space: Size<ConstraintLimit>,
context: &mut LayoutContext<'_, '_, '_, '_, '_>,
) -> Size<UPx> {
if self.needs_to_select_all {
self.needs_to_select_all = false;
self.select_all();
}
let editor = self.editor_mut(&mut context.graphics.gfx, &context.graphics.widget);
let buffer = editor.buffer_mut();
buffer.set_size(
context.gfx.font_system(),
available_space.width.max().into_float(),
available_space.height.max().into_float(),
);
context
.gfx
.measure_text_buffer::<Px>(buffer, Color::WHITE)
.size
.into_unsigned()
}
fn keyboard_input(
&mut self,
_device_id: kludgine::app::winit::event::DeviceId,
input: kludgine::app::winit::event::KeyEvent,
_is_synthetic: bool,
context: &mut EventContext<'_, '_>,
) -> EventHandling {
if let Some(on_key) = &mut self.on_key {
on_key.invoke(input.clone())?;
}
let editor = self.editor_mut(context.kludgine, &context.widget);
// println!(
// "Keyboard input: {:?}. {:?}, {:?}",
// input.logical_key, input.text, input.physical_key
// );
let (text_changed, handled) = match (input.state, input.logical_key, input.text.as_deref()) {
(ElementState::Pressed, key @ (Key::Backspace | Key::Delete), _) => {
editor.action(
context.kludgine.font_system(),
match key {
Key::Backspace => Action::Backspace,
Key::Delete => Action::Delete,
_ => unreachable!("previously matched"),
},
);
(true, HANDLED)
}
(ElementState::Pressed, key @ (Key::ArrowLeft | Key::ArrowDown | Key::ArrowUp | Key::ArrowRight), _) => {
let modifiers = context.modifiers();
match (editor.select_opt(), modifiers.state().shift_key()) {
(None, true) => {
editor.set_select_opt(Some(editor.cursor()));
}
(Some(_), false) => {
editor.set_select_opt(None);
}
_ => {}
};
editor.action(
context.kludgine.font_system(),
match key {
Key::ArrowLeft if modifiers.word_select() => Action::PreviousWord,
Key::ArrowLeft => Action::Left,
Key::ArrowDown => Action::Down,
Key::ArrowUp => Action::Up,
Key::ArrowRight if modifiers.word_select() => Action::NextWord,
Key::ArrowRight => Action::Right,
_ => unreachable!("previously matched"),
},
);
(false, HANDLED)
}
(state, _, Some("a")) if context.modifiers().primary() => {
if state.is_pressed() {
self.select_all();
}
(false, HANDLED)
}
(state, _, Some(text))
if !context.modifiers().primary()
&& text != "\t" // tab
&& text != "\r" // enter/return
&& text != "\u{1b}" // escape
=>
{
if state.is_pressed() {
editor.insert_string(text, None);
}
(state.is_pressed(), HANDLED)
}
(_, _, _) => (false, IGNORED),
};
if handled.is_break() {
context.set_needs_redraw();
self.cursor_state.force_on();
if text_changed {
if let Value::Dynamic(value) = &self.text {
let state = self.editor.as_mut().expect("just_used");
value.map_mut(|text| {
text.clear();
for line in &state.editor.buffer().lines {
text.push_str(line.text());
}
});
state.generation = Some(value.generation());
}
}
}
handled
}
fn ime(&mut self, ime: Ime, context: &mut EventContext<'_, '_>) -> EventHandling {
match ime {
Ime::Enabled | Ime::Disabled => {}
Ime::Preedit(text, cursor) => {
tracing::warn!("TODO: preview IME input {text}, cursor: {cursor:?}");
}
Ime::Commit(text) => {
self.editor_mut(context.kludgine, &context.widget)
.insert_string(&text, None);
context.set_needs_redraw();
}
}
HANDLED
}
fn focus(&mut self, context: &mut EventContext<'_, '_>) {
if self.mouse_buttons_down == 0 {
self.needs_to_select_all = true;
}
context.set_ime_allowed(true);
context.set_needs_redraw();
}
fn blur(&mut self, context: &mut EventContext<'_, '_>) {
context.set_ime_allowed(false);
context.set_needs_redraw();
}
}
struct LiveEditor {
editor: Editor,
generation: Option<Generation>,
}
fn cursor_glyph(buffer: &Buffer, cursor: &Cursor) -> Result<(Point<Px>, Px), NotVisible> {
// let cursor = buffer.layout_cursor(cursor);
let mut layout_cursor = buffer.layout_cursor(cursor);
// TODO this is because of a TODO inside of layout_cursor. It currently
// falls back to 0,0 on the current line, rather than picking the last one.
if layout_cursor.glyph == 0 && layout_cursor.layout == 0 && cursor.index > 0 {
layout_cursor.glyph = usize::MAX;
}
let mut return_after_character = false;
let searching_for = match buffer
.lines
.get(layout_cursor.line)
.and_then(|line| {
line.layout_opt()
.as_ref()
.expect("line layout missing")
.get(layout_cursor.layout)
})
.and_then(|layout| layout.glyphs.get(layout_cursor.glyph))
// TODO these should progressively fail rather than a single or_else.
.or_else(|| {
return_after_character = true;
buffer
.lines
.last()
.and_then(|line| {
line.layout_opt()
.as_ref()
.expect("line layout missing")
.last()
})
.and_then(|layout| layout.glyphs.last())
}) {
Some(glyph) => glyph,
None => return Err(NotVisible::Before),
}
.start;
for (index, run) in buffer.layout_runs().enumerate() {
match run.line_i.cmp(&cursor.line) {
Ordering::Less => continue,
Ordering::Equal => {}
Ordering::Greater => {
if index > 0 {
return Err(NotVisible::After);
}
return Err(NotVisible::Before);
}
}
if let Some(glyph) = run.glyphs.iter().find(|g| g.start == searching_for) {
let physical = glyph.physical((0., run.line_y), 1.);
let position = Point::new(Px(physical.x), Px::from_float(run.line_top));
let width = Px::from_float(glyph.w);
return Ok(if return_after_character {
(Point::new(position.x + width, position.y), Px(0))
} else {
(position, width)
});
}
}
Err(NotVisible::After)
}
#[derive(Debug, Eq, PartialEq)]
enum NotVisible {
Before,
After,
}
#[derive(Clone, Copy)]
struct CursorState {
visible: bool,
remaining_until_blink: Duration,
}
impl Default for CursorState {
fn default() -> Self {
Self {
visible: true,
remaining_until_blink: CURSOR_BLINK_DURATION,
}
}
}
impl CursorState {
pub fn update(&mut self, elapsed: Duration) {
let total_cycles = elapsed.as_nanos() / CURSOR_BLINK_DURATION.as_nanos();
let remaining = Duration::from_nanos(
u64::try_from(elapsed.as_nanos() % CURSOR_BLINK_DURATION.as_nanos())
.expect("remainder fits in u64"),
);
// If we have an odd number of totaal cycles, flip the visibility.
if total_cycles & 1 == 1 {
self.visible = !self.visible;
}
if let Some(remaining) = self.remaining_until_blink.checked_sub(remaining) {
self.remaining_until_blink = remaining;
} else {
self.visible = !self.visible;
self.remaining_until_blink =
CURSOR_BLINK_DURATION - (remaining - self.remaining_until_blink);
}
}
pub fn force_on(&mut self) {
self.visible = true;
self.remaining_until_blink = CURSOR_BLINK_DURATION;
}
}