T06a forms text: text input core, fields, numbers, masks, pin, inline, groups, tags, pairs, path, regex, expression, hotkey, mention

This commit is contained in:
ZacharyZhang-NY committed 2026-09-25 05:38:24 -04:00
1 parent 817da41c22
commit ec5713c10b
41 files changed
+6233 -45

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
use std::sync::LazyLock;
use gpui::{
App, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
prelude::*,
};
use regex::Regex;
use super::{Input, TextInput};
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
};
static EMAIL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$").expect("email pattern compiles"));
static URL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^https?://[^\s/?#]+\.[^\s/?#]+\S*$").expect("url pattern compiles")
});
pub(crate) fn is_email(text: &str) -> bool {
EMAIL.is_match(text)
}
pub(crate) fn is_url(text: &str) -> bool {
URL.is_match(text)
}
/// A field checked once focus leaves it; a note explains a miss.
fn checked(
state: &Entity<TextInput>,
icon: IconName,
valid: fn(&str) -> bool,
note: &'static str,
size: ControlSize,
window: &Window,
cx: &App,
) -> impl IntoElement + use<> {
let input = state.read(cx);
let miss = !input.focus().is_focused(window) && !input.is_empty() && !valid(input.text());
let theme = cx.theme();
let (subtle, danger) = (theme.colors.fg_subtle, theme.colors.danger);
div()
.flex()
.flex_col()
.gap_1()
.w_full()
.child(
Input::new(state)
.size(size)
.invalid(miss)
.prefix(Icon::new(icon).size(IconSize::Sm).color(subtle)),
)
.when(miss, |field| {
field.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(danger)
.child(SharedString::from(note)),
)
})
}
/// An email field, checked when focus leaves it.
#[derive(IntoElement)]
pub struct EmailInput {
state: Entity<TextInput>,
size: ControlSize,
}
impl EmailInput {
pub fn new(state: &Entity<TextInput>) -> Self {
Self {
state: state.clone(),
size: ControlSize::default(),
}
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for EmailInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
checked(
&self.state,
IconName::AtSign,
is_email,
"Enter an address like name@example.com",
self.size,
window,
cx,
)
}
}
/// A link field, checked when focus leaves it.
#[derive(IntoElement)]
pub struct UrlInput {
state: Entity<TextInput>,
size: ControlSize,
}
impl UrlInput {
pub fn new(state: &Entity<TextInput>) -> Self {
Self {
state: state.clone(),
size: ControlSize::default(),
}
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for UrlInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
checked(
&self.state,
IconName::Link,
is_url,
"Enter a link that starts with https://",
self.size,
window,
cx,
)
}
}
#[cfg(test)]
mod tests {
use super::{is_email, is_url};
#[test]
fn emails_and_urls() {
assert!(is_email("ada@example.com"));
assert!(!is_email("ada@example"));
assert!(!is_email("ada example.com"));
assert!(is_url("https://ely.dev/docs?x=1"));
assert!(is_url("http://localhost.test"));
assert!(!is_url("ely.dev"));
assert!(!is_url("https://nodot"));
}
}
+359
View File
@@ -0,0 +1,359 @@
use std::ops::Range;
use gpui::{
App, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
prelude::*,
};
use super::{Highlight, Input, TextInput};
use crate::{
theme::{ActiveTheme, ControlSize, TextSize},
typography::format::{self, Separators},
};
#[derive(Clone, Debug, PartialEq)]
enum Token {
Number(f64),
Name(String),
Op(char),
Open,
Close,
Comma,
}
fn tokens(source: &str) -> Result<Vec<(Range<usize>, Token)>, String> {
let mut out = Vec::new();
let mut chars = source.char_indices().peekable();
while let Some(&(ix, ch)) = chars.peek() {
if ch.is_whitespace() {
chars.next();
continue;
}
if ch.is_ascii_digit() || ch == '.' {
let mut end = ix;
while let Some(&(at, ch)) = chars.peek() {
if !(ch.is_ascii_digit() || ch == '.') {
break;
}
end = at + 1;
chars.next();
}
let number = source[ix..end]
.parse::<f64>()
.ok()
.filter(|number| number.is_finite())
.ok_or_else(|| format!("{} is not a number", &source[ix..end]))?;
out.push((ix..end, Token::Number(number)));
continue;
}
if ch.is_alphabetic() || ch == '_' {
let mut end = ix;
while let Some(&(at, ch)) = chars.peek() {
if !(ch.is_alphanumeric() || ch == '_') {
break;
}
end = at + ch.len_utf8();
chars.next();
}
out.push((ix..end, Token::Name(source[ix..end].to_string())));
continue;
}
chars.next();
let token = match ch {
'+' | '-' | '*' | '/' | '%' | '^' => Token::Op(ch),
'(' => Token::Open,
')' => Token::Close,
',' => Token::Comma,
other => return Err(format!("unexpected {other}")),
};
out.push((ix..ix + ch.len_utf8(), token));
}
Ok(out)
}
/// Passes `value` on, or names the step that left the finite numbers.
fn finite(value: f64, step: &str) -> Result<f64, String> {
if value.is_finite() {
Ok(value)
} else {
Err(format!("{step} has no finite value"))
}
}
struct Parser<'a> {
tokens: Vec<Token>,
at: usize,
variables: &'a [(SharedString, f64)],
}
impl Parser<'_> {
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.at)
}
fn next(&mut self) -> Option<Token> {
let token = self.tokens.get(self.at).cloned();
self.at += 1;
token
}
fn sum(&mut self) -> Result<f64, String> {
let mut value = self.product()?;
while let Some(Token::Op(op @ ('+' | '-'))) = self.peek().cloned() {
self.next();
let rhs = self.product()?;
value = finite(if op == '+' { value + rhs } else { value - rhs }, "+")?;
}
Ok(value)
}
fn product(&mut self) -> Result<f64, String> {
let mut value = self.power()?;
while let Some(Token::Op(op @ ('*' | '/' | '%'))) = self.peek().cloned() {
self.next();
let rhs = self.power()?;
if op != '*' && rhs == 0.0 {
return Err("division by zero".into());
}
value = match op {
'*' => finite(value * rhs, "*")?,
'/' => finite(value / rhs, "/")?,
_ => value % rhs,
};
}
Ok(value)
}
fn power(&mut self) -> Result<f64, String> {
let base = self.unary()?;
if self.peek() == Some(&Token::Op('^')) {
self.next();
return finite(base.powf(self.power()?), "^");
}
Ok(base)
}
fn unary(&mut self) -> Result<f64, String> {
if self.peek() == Some(&Token::Op('-')) {
self.next();
return Ok(-self.unary()?);
}
self.primary()
}
fn primary(&mut self) -> Result<f64, String> {
match self.next() {
Some(Token::Number(number)) => Ok(number),
Some(Token::Open) => {
let value = self.sum()?;
match self.next() {
Some(Token::Close) => Ok(value),
_ => Err("a ( is never closed".into()),
}
}
Some(Token::Name(name)) if self.peek() == Some(&Token::Open) => {
self.next();
let mut args = Vec::new();
if self.peek() != Some(&Token::Close) {
loop {
args.push(self.sum()?);
match self.next() {
Some(Token::Comma) => continue,
Some(Token::Close) => break,
_ => return Err(format!("{name}( is never closed")),
}
}
} else {
self.next();
}
finite(call(&name, &args)?, &format!("{name}(…)"))
}
Some(Token::Name(name)) => {
let (_, value) = self
.variables
.iter()
.find(|(known, _)| known.as_ref() == name)
.ok_or_else(|| format!("{name} is not defined"))?;
finite(*value, &name)
}
Some(other) => Err(format!("unexpected {other:?}")),
None => Err("the formula ends early".into()),
}
}
}
fn call(name: &str, args: &[f64]) -> Result<f64, String> {
let one = |f: fn(f64) -> f64| match args {
[x] => Ok(f(*x)),
_ => Err(format!("{name} takes one value")),
};
match name {
"abs" => one(f64::abs),
"sqrt" => one(f64::sqrt),
"round" => one(f64::round),
"floor" => one(f64::floor),
"ceil" => one(f64::ceil),
"min" | "max" if args.is_empty() => Err(format!("{name} needs values")),
"min" => Ok(args.iter().copied().fold(f64::INFINITY, f64::min)),
"max" => Ok(args.iter().copied().fold(f64::NEG_INFINITY, f64::max)),
_ => Err(format!("{name} is not a function")),
}
}
/// Evaluates a formula with the given variables.
pub fn evaluate(source: &str, variables: &[(SharedString, f64)]) -> Result<f64, String> {
let tokens = tokens(source)?
.into_iter()
.map(|(_, token)| token)
.collect();
let mut parser = Parser {
tokens,
at: 0,
variables,
};
let value = parser.sum()?;
match parser.peek() {
None => Ok(value),
Some(extra) => Err(format!("unexpected {extra:?}")),
}
}
/// Colors for a formula: numbers, names, operators.
pub fn expression_highlights(source: &str, cx: &App) -> Vec<(Range<usize>, Highlight)> {
let syntax = &cx.theme().colors.syntax;
let Ok(found) = tokens(source) else {
return Vec::new();
};
found
.iter()
.enumerate()
.map(|(ix, (range, token))| {
let color = match token {
Token::Number(_) => syntax.number,
Token::Name(_) if matches!(found.get(ix + 1), Some((_, Token::Open))) => {
syntax.function
}
Token::Name(_) => syntax.property,
Token::Op(_) => syntax.operator,
Token::Open | Token::Close | Token::Comma => syntax.punctuation,
};
(
range.clone(),
Highlight {
color,
background: None,
},
)
})
.collect()
}
/// A formula field: colored as you type, with its value or its problem below.
#[derive(IntoElement)]
pub struct ExpressionInput {
state: Entity<TextInput>,
variables: Vec<(SharedString, f64)>,
}
impl ExpressionInput {
/// Give the state `expression_highlights` as its highlighter.
pub fn new(state: &Entity<TextInput>) -> Self {
Self {
state: state.clone(),
variables: Vec::new(),
}
}
pub fn variable(mut self, name: impl Into<SharedString>, value: f64) -> Self {
self.variables.push((name.into(), value));
self
}
}
impl RenderOnce for ExpressionInput {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let source = self.state.read(cx).text().to_string();
let result = (!source.trim().is_empty()).then(|| evaluate(&source, &self.variables));
let theme = cx.theme();
let colors = &theme.colors;
let mono = theme.mono_family.clone();
let invalid = matches!(result, Some(Err(_)));
div()
.flex()
.flex_col()
.gap_1()
.w_full()
.child(
div().font_family(mono).child(
Input::new(&self.state)
.size(ControlSize::Md)
.invalid(invalid)
.prefix(div().text_color(colors.fg_subtle).child("=")),
),
)
.when_some(result, |field, result| {
let (text, color) = match result {
Ok(value) => (
format!(
"= {}",
format::number(value, 4, Separators::EN)
.trim_end_matches('0')
.trim_end_matches('.')
),
colors.fg_muted,
),
Err(problem) => (problem, colors.danger),
};
field.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(color)
.child(SharedString::from(text)),
)
})
}
}
#[cfg(test)]
mod tests {
use gpui::SharedString;
use super::evaluate;
#[test]
fn precedence_functions_and_variables() {
let vars = [
(SharedString::from("price"), 12.5),
(SharedString::from("qty"), 3.0),
];
assert_eq!(evaluate("1 + 2 * 3", &vars), Ok(7.0));
assert_eq!(evaluate("2 ^ 3 ^ 2", &vars), Ok(512.0));
assert_eq!(evaluate("-(price * qty) + max(1, 4, 2)", &vars), Ok(-33.5));
assert_eq!(evaluate("round(sqrt(16.4))", &vars), Ok(4.0));
}
#[test]
fn problems_are_named() {
let vars = [];
assert_eq!(evaluate("1 / 0", &vars), Err("division by zero".into()));
assert_eq!(evaluate("tax * 2", &vars), Err("tax is not defined".into()));
assert_eq!(evaluate("(1 + 2", &vars), Err("a ( is never closed".into()));
assert_eq!(
evaluate("abs(1, 2)", &vars),
Err("abs takes one value".into())
);
assert!(evaluate("1 $ 2", &vars).is_err());
}
#[test]
fn a_step_without_a_finite_value_fails_at_once() {
assert_eq!(
evaluate("min(sqrt(-1), 2)", &[]),
Err("sqrt(…) has no finite value".into())
);
assert_eq!(
evaluate("max(10 ^ 400, 1)", &[]),
Err("^ has no finite value".into())
);
}
}
+118
View File
@@ -0,0 +1,118 @@
use gpui::{
AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, Styled, Window, div, prelude::*,
};
use super::{TextInput, input::text_size};
use crate::theme::{ActiveTheme, ControlSize, Radius};
/// A block joined to a field's side: text such as `https://`, or a control.
#[derive(IntoElement)]
pub struct InputAddon {
body: AnyElement,
}
impl InputAddon {
pub fn text(text: impl Into<SharedString>) -> Self {
Self {
body: text.into().into_any_element(),
}
}
pub fn new(body: impl IntoElement) -> Self {
Self {
body: body.into_any_element(),
}
}
}
impl RenderOnce for InputAddon {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
self.body
}
}
/// A field with addons joined before and after it, in one frame.
#[derive(IntoElement)]
pub struct InputGroup {
state: Entity<TextInput>,
before: Vec<InputAddon>,
after: Vec<InputAddon>,
size: ControlSize,
}
impl InputGroup {
pub fn new(state: &Entity<TextInput>) -> Self {
Self {
state: state.clone(),
before: Vec::new(),
after: Vec::new(),
size: ControlSize::default(),
}
}
pub fn before(mut self, addon: InputAddon) -> Self {
self.before.push(addon);
self
}
pub fn after(mut self, addon: InputAddon) -> Self {
self.after.push(addon);
self
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for InputGroup {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let input = self.state.read(cx);
let focus = input.focus().clone();
let focused = focus.is_focused(window);
let theme = cx.theme();
let colors = &theme.colors;
let pad = theme.control_padding(self.size);
let addon = |addon: InputAddon, before: bool| {
div()
.flex()
.flex_none()
.items_center()
.h_full()
.px(pad)
.bg(colors.sunken)
.text_color(colors.fg_muted)
.map(|block| {
if before {
block.border_r_1()
} else {
block.border_l_1()
}
})
.border_color(colors.border_strong)
.child(addon)
};
div()
.flex()
.items_center()
.w_full()
.h(theme.control_height(self.size))
.overflow_hidden()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused {
colors.focus
} else {
colors.border_strong
})
.bg(colors.surface)
.text_size(theme.text_size(text_size(self.size)))
.text_color(colors.fg)
.on_mouse_down(MouseButton::Left, move |_, window, _| window.focus(&focus))
.children(self.before.into_iter().map(|block| addon(block, true)))
.child(div().flex_1().min_w_0().px(pad).child(self.state))
.children(self.after.into_iter().map(|block| addon(block, false)))
}
}
+111
View File
@@ -0,0 +1,111 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, Keystroke, ParentElement, RenderOnce, Styled,
Window, div, prelude::*,
};
use crate::{
primitives::{FocusRing, Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::KbdCombo,
};
type OnChange = Rc<dyn Fn(Option<Keystroke>, &mut Window, &mut App)>;
/// Records one shortcut, then lets go. Backspace clears; Escape leaves.
#[derive(IntoElement)]
pub struct HotkeyInput {
id: ElementId,
value: Option<Keystroke>,
on_change: Option<OnChange>,
}
impl HotkeyInput {
pub fn new(id: impl Into<ElementId>, value: Option<Keystroke>) -> Self {
Self {
id: id.into(),
value,
on_change: None,
}
}
pub fn on_change(
mut self,
handler: impl Fn(Option<Keystroke>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for HotkeyInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let focus = window
.use_keyed_state(self.id.clone(), cx, |_, cx| {
cx.focus_handle().tab_stop(true)
})
.read(cx)
.clone();
let recording = focus.is_focused(window);
let theme = cx.theme();
let colors = &theme.colors;
let on_change = self.on_change;
div()
.id(self.id)
.track_focus(&focus)
.flex()
.items_center()
.justify_between()
.gap_2()
.w_full()
.h(theme.control_height(ControlSize::Md))
.px(theme.control_padding(ControlSize::Md))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border_strong)
.bg(colors.surface)
.focus_ring(cx)
.cursor_pointer()
.text_size(theme.text_size(TextSize::Base))
.on_key_down(move |event, window, cx| {
cx.stop_propagation();
let stroke = &event.keystroke;
let bare = !stroke.modifiers.modified();
let next = match stroke.key.as_str() {
"escape" if bare => {
window.blur();
return;
}
"backspace" | "delete" if bare => None,
_ => Some(Keystroke {
key_char: None,
..stroke.clone()
}),
};
log::info!(
"hotkey input: {}",
next.as_ref()
.map_or("cleared".into(), |stroke| stroke.unparse())
);
window.blur();
if let Some(on_change) = &on_change {
on_change(next, window, cx);
}
})
.map(|field| match (&self.value, recording) {
(_, true) => field.child(
div()
.text_color(colors.fg_subtle)
.child("Press a shortcut…"),
),
(Some(stroke), false) => field.child(KbdCombo::new(&stroke.unparse())),
(None, false) => field.child(div().text_color(colors.fg_subtle).child("None")),
})
.child(
Icon::new(IconName::Keyboard)
.size(IconSize::Sm)
.color(colors.fg_subtle),
)
}
}
+153
View File
@@ -0,0 +1,153 @@
use std::rc::Rc;
use gpui::{
App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div,
prelude::*,
};
use super::{Input, InputEvent, TextInput};
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
};
type OnCommit = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
#[derive(Default)]
struct Editing {
field: Option<(Entity<TextInput>, Subscription)>,
on_commit: Option<OnCommit>,
}
impl Editing {
/// Ends editing; keeps the text unless `cancel`.
fn finish(&mut self, cancel: bool, window: &mut Window, cx: &mut Context<Self>) {
let Some((field, _)) = self.field.take() else {
return;
};
if cancel {
log::info!("inline edit: cancelled");
} else {
let text = SharedString::from(field.read(cx).text().to_string());
log::info!("inline edit: kept");
if let Some(commit) = self.on_commit.clone() {
commit(&text, window, cx);
}
}
cx.notify();
}
}
/// Text that becomes a field when clicked. Enter or leaving keeps; Escape reverts.
#[derive(IntoElement)]
pub struct InlineEdit {
id: ElementId,
value: SharedString,
placeholder: SharedString,
on_commit: Option<OnCommit>,
}
impl InlineEdit {
pub fn new(id: impl Into<ElementId>, value: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
value: value.into(),
placeholder: "Empty".into(),
on_commit: None,
}
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
self.placeholder = text.into();
self
}
pub fn on_commit(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_commit = Some(Rc::new(handler));
self
}
}
impl RenderOnce for InlineEdit {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = window.use_keyed_state(self.id.clone(), cx, |_, _| Editing::default());
state.update(cx, |editing, _| editing.on_commit = self.on_commit.clone());
if let Some(field) = state
.read(cx)
.field
.as_ref()
.map(|(field, _)| field.clone())
{
let escape = state.clone();
return div()
.id(self.id)
.w_full()
.on_key_down(move |event, window, cx| {
if event.keystroke.key == "escape" {
cx.stop_propagation();
escape.update(cx, |editing, cx| editing.finish(true, window, cx));
}
})
.child(Input::new(&field).size(ControlSize::Sm))
.into_any_element();
}
let theme = cx.theme();
let colors = &theme.colors;
let empty = self.value.is_empty();
let (value, start) = (self.value.clone(), state);
let group = SharedString::from(format!("inline-edit-{:?}", self.id));
div()
.id(self.id)
.group(group.clone())
.flex()
.items_center()
.gap_2()
.h(theme.control_height(ControlSize::Sm))
.px(theme.control_padding(ControlSize::Sm))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(gpui::transparent_black())
.cursor_text()
.hover(|style| style.bg(colors.hover))
.text_color(if empty { colors.fg_subtle } else { colors.fg })
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("inline edit: editing");
let value = value.to_string();
let field = cx.new(|cx| {
let mut input = TextInput::new(window, cx);
input.set_text(value, cx);
input
});
let all = field.read(cx).text().len();
field.update(cx, |input, cx| input.select(0..all, cx));
window.focus(&field.read(cx).focus().clone());
let owner = start.clone();
let events = window.subscribe(&field, cx, move |_, event, window, cx| {
if matches!(event, InputEvent::Submit | InputEvent::Blur) {
owner.update(cx, |editing, cx| editing.finish(false, window, cx));
}
});
start.update(cx, |editing, cx| {
editing.field = Some((field, events));
cx.notify();
});
})
.child(if empty { self.placeholder } else { self.value })
.child(
div()
.invisible()
.group_hover(group, |style| style.visible())
.child(
Icon::new(IconName::Pencil)
.size(IconSize::Xs)
.color(colors.fg_subtle),
),
)
.into_any_element()
}
}
+192
View File
@@ -0,0 +1,192 @@
use gpui::{
AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, Styled, Window, div, prelude::*,
};
use super::TextInput;
use crate::{
buttons::IconButton,
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
/// A text field's frame: border, focus, and room for things before and after.
#[derive(IntoElement)]
pub struct Input {
state: Entity<TextInput>,
size: ControlSize,
prefix: Option<AnyElement>,
suffix: Option<AnyElement>,
clearable: bool,
invalid: bool,
}
impl Input {
pub fn new(state: &Entity<TextInput>) -> Self {
Self {
state: state.clone(),
size: ControlSize::default(),
prefix: None,
suffix: None,
clearable: false,
invalid: false,
}
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
/// Adds a clear button while there is text.
pub fn clearable(mut self) -> Self {
self.clearable = true;
self
}
/// Draws the frame in the danger tone.
pub fn invalid(mut self, invalid: bool) -> Self {
self.invalid = invalid;
self
}
}
pub(crate) fn text_size(size: ControlSize) -> TextSize {
match size {
ControlSize::Sm => TextSize::Sm,
ControlSize::Md => TextSize::Base,
ControlSize::Lg => TextSize::Md,
}
}
impl RenderOnce for Input {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let input = self.state.read(cx);
let focus = input.focus().clone();
let (focused, empty, multi, disabled) = (
focus.is_focused(window),
input.is_empty(),
input.is_multi_line(),
input.is_disabled(),
);
let theme = cx.theme();
let colors = &theme.colors;
let border = if self.invalid {
colors.danger
} else if focused {
colors.focus
} else {
colors.border_strong
};
let (state, clear_id) = (self.state.clone(), self.state.entity_id());
div()
.flex()
.gap_2()
.w_full()
.px(theme.control_padding(self.size))
.map(|frame| {
if multi {
frame.items_start().py_1p5()
} else {
frame.items_center().h(theme.control_height(self.size))
}
})
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(border)
.bg(if disabled {
colors.sunken
} else {
colors.surface
})
.text_size(theme.text_size(text_size(self.size)))
.text_color(if disabled {
colors.fg_disabled
} else {
colors.fg
})
.on_mouse_down(MouseButton::Left, move |_, window, _| window.focus(&focus))
.children(self.prefix)
.child(div().flex_1().min_w_0().child(self.state))
.when(self.clearable && !empty && !disabled, |frame| {
frame.child(
IconButton::new(("input-clear", clear_id), IconName::CircleX)
.size(ControlSize::Sm)
.tooltip("Clear")
.on_click(move |_, window, cx| {
state.update(cx, |input, cx| input.set_text("", cx));
window.focus(&state.read(cx).focus().clone());
}),
)
})
.children(self.suffix)
}
}
/// A masked field with a lock before it and an eye that shows the text.
#[derive(IntoElement)]
pub struct PasswordInput {
state: Entity<TextInput>,
size: ControlSize,
}
impl PasswordInput {
/// Build the state with `TextInput::masked`.
pub fn new(state: &Entity<TextInput>) -> Self {
Self {
state: state.clone(),
size: ControlSize::default(),
}
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for PasswordInput {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let masked = self.state.read(cx).is_masked();
let (toggle, subtle) = (self.state.clone(), cx.theme().colors.fg_subtle);
Input::new(&self.state)
.size(self.size)
.prefix(Icon::new(IconName::Lock).size(IconSize::Sm).color(subtle))
.suffix(
IconButton::new(
("password-eye", self.state.entity_id()),
if masked {
IconName::Eye
} else {
IconName::EyeOff
},
)
.size(ControlSize::Sm)
.tooltip(if masked {
"Show password"
} else {
"Hide password"
})
.on_click(move |_, _, cx| {
toggle.update(cx, |input, cx| {
let masked = input.is_masked();
log::info!(
"password input: {}",
if masked { "shown" } else { "hidden" }
);
input.set_masked(!masked, cx);
})
}),
)
}
}
+360
View File
@@ -0,0 +1,360 @@
use std::{ops::Range, rc::Rc};
use gpui::{
AnyElement, App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window,
canvas, div, prelude::*,
};
use super::{
Input, TextInput,
suggest::{Suggestion, suggestion_list},
};
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius},
};
fn is_slot(slot: char) -> bool {
matches!(slot, '9' | 'a' | '*')
}
/// Fits typed characters into `mask`: `9` a digit, `a` a letter, `*` either; the rest is literal.
pub(crate) fn apply_mask(mask: &str, raw: &str) -> String {
let mut raw = raw.chars().filter(char::is_ascii_alphanumeric).peekable();
let mut out = String::new();
for slot in mask.chars() {
let fits = |ch: &char| match slot {
'9' => ch.is_ascii_digit(),
'a' => ch.is_ascii_alphabetic(),
_ => ch.is_ascii_alphanumeric(),
};
if raw.peek().is_none() {
break;
}
if !is_slot(slot) {
out.push(slot);
continue;
}
while raw.peek().is_some_and(|ch| !fits(ch)) {
raw.next();
}
match raw.next() {
Some(ch) => out.push(ch),
None => break,
}
}
out
}
/// What `fitted` holds in the slots of `mask`, from char `from` to char `to`.
fn slot_chars(mask: &str, fitted: &str, from: usize, to: usize) -> String {
mask.chars()
.zip(fitted.chars())
.enumerate()
.filter(|(ix, (slot, _))| (from..to).contains(ix) && is_slot(*slot))
.map(|(_, (_, ch))| ch)
.collect()
}
/// Fits an edit into `mask`: `range` of `text` becomes `typed`. Gives the text and caret.
pub(crate) fn refit(mask: &str, text: &str, range: Range<usize>, typed: &str) -> (String, usize) {
let at = |byte: usize| text[..byte].chars().count();
let head = slot_chars(mask, text, 0, at(range.start)) + typed;
let fitted = apply_mask(
mask,
&(head.clone() + &slot_chars(mask, text, at(range.end), usize::MAX)),
);
let caret = if typed.is_empty() {
range.start
} else {
apply_mask(mask, &head).len()
};
let caret = caret.min(fitted.len());
(fitted, caret)
}
/// The mask shown while empty: slots become underscores.
pub(crate) fn mask_hint(mask: &str) -> String {
mask.chars()
.map(|slot| {
if matches!(slot, '9' | 'a' | '*') {
'_'
} else {
slot
}
})
.collect()
}
fn masking(
id: &ElementId,
state: &Entity<TextInput>,
mask: &SharedString,
window: &mut Window,
cx: &mut App,
) {
let applied = window.use_keyed_state(id.clone(), cx, |_, _| None::<SharedString>);
let old = applied.read(cx).clone();
if old.as_ref() != Some(mask) {
applied.update(cx, |applied, _| *applied = Some(mask.clone()));
let fit_mask = mask.clone();
state.update(cx, |input, cx| {
let raw = match &old {
Some(old) => slot_chars(old, input.text(), 0, usize::MAX),
None => input.text().to_string(),
};
input.set_fit(Rc::new(move |text, range, typed| {
refit(&fit_mask, text, range, typed)
}));
input.set_text(raw, cx);
});
log::info!("masked input: mask {mask}");
}
let hint = mask_hint(mask);
if state.read(cx).placeholder_text().as_ref() != hint {
state.update(cx, |input, cx| input.set_placeholder(hint, cx));
}
}
/// A field that fits typing into a pattern such as `(999) 999-9999`.
#[derive(IntoElement)]
pub struct MaskedInput {
id: ElementId,
state: Entity<TextInput>,
mask: SharedString,
size: ControlSize,
prefix: Option<AnyElement>,
}
impl MaskedInput {
pub fn new(
id: impl Into<ElementId>,
state: &Entity<TextInput>,
mask: impl Into<SharedString>,
) -> Self {
Self {
id: id.into(),
state: state.clone(),
mask: mask.into(),
size: ControlSize::default(),
prefix: None,
}
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
pub fn prefix(mut self, prefix: impl IntoElement) -> Self {
self.prefix = Some(prefix.into_any_element());
self
}
}
impl RenderOnce for MaskedInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
masking(&self.id, &self.state, &self.mask, window, cx);
let field = Input::new(&self.state).size(self.size);
match self.prefix {
Some(prefix) => field.prefix(prefix),
None => field,
}
}
}
/// Dialing code, flag and number pattern of each country offered.
pub const COUNTRIES: [(&str, &str, &str, &str); 6] = [
("US", "+1", "🇺🇸", "(999) 999-9999"),
("GB", "+44", "🇬🇧", "9999 999999"),
("DE", "+49", "🇩🇪", "9999 9999999"),
("FR", "+33", "🇫🇷", "9 99 99 99 99"),
("JP", "+81", "🇯🇵", "99-9999-9999"),
("CN", "+86", "🇨🇳", "999 9999 9999"),
];
type OnCountry = Rc<dyn Fn(&'static str, &mut Window, &mut App)>;
/// A phone number: a country picker for the code, then its number pattern.
#[derive(IntoElement)]
pub struct PhoneInput {
id: ElementId,
state: Entity<TextInput>,
country: &'static str,
on_country: Option<OnCountry>,
}
impl PhoneInput {
/// `country` is a code from `COUNTRIES`.
pub fn new(id: impl Into<ElementId>, state: &Entity<TextInput>, country: &'static str) -> Self {
assert!(
COUNTRIES.iter().any(|(code, ..)| *code == country),
"phone input has no country {country}"
);
Self {
id: id.into(),
state: state.clone(),
country,
on_country: None,
}
}
pub fn on_country(
mut self,
handler: impl Fn(&'static str, &mut Window, &mut App) + 'static,
) -> Self {
self.on_country = Some(Rc::new(handler));
self
}
}
struct Picker {
open: bool,
anchor: Bounds<Pixels>,
}
impl RenderOnce for PhoneInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let picker = window.use_keyed_state((self.id.clone(), "picker"), cx, |_, _| Picker {
open: false,
anchor: Bounds::default(),
});
let (open, anchor) = (picker.read(cx).open, picker.read(cx).anchor);
let &(_, dial, flag, mask) = COUNTRIES
.iter()
.find(|(code, ..)| *code == self.country)
.expect("checked at construction");
let theme = cx.theme();
let (hover, muted) = (theme.colors.hover, theme.colors.fg_muted);
let rows: Vec<Suggestion> = COUNTRIES
.iter()
.map(|(code, dial, flag, _)| Suggestion {
label: SharedString::from(format!("{flag} {code}")),
note: Some(SharedString::from(*dial)),
})
.collect();
let (toggle, close, measure) = (picker.clone(), picker.clone(), picker.clone());
let on_country = self.on_country;
let pick = Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| {
let code = COUNTRIES[ix].0;
log::info!("phone input: country {code}");
close.update(cx, |picker, cx| {
picker.open = false;
cx.notify();
});
if let Some(on_country) = &on_country {
on_country(code, window, cx);
}
});
let dismiss = picker.clone();
let code = div()
.id((self.id.clone(), "code"))
.flex()
.items_center()
.gap_1()
.px_1()
.rounded(theme.radius(Radius::Sm))
.text_color(muted)
.cursor_pointer()
.hover(|style| style.bg(hover))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, _, cx| {
toggle.update(cx, |picker, cx| {
picker.open = !picker.open;
cx.notify();
})
})
.child(format!("{flag} {dial}"))
.child(
Icon::new(IconName::ChevronDown)
.size(IconSize::Xs)
.color(muted),
);
div()
.relative()
.w_full()
.child(MaskedInput::new(self.id.clone(), &self.state, mask).prefix(code))
.child(
canvas(
move |bounds, _, cx| {
if measure.read(cx).anchor != bounds {
measure.update(cx, |picker, _| picker.anchor = bounds);
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.when(open, |field| {
let current = COUNTRIES
.iter()
.position(|(code, ..)| *code == self.country);
field.child(suggestion_list(
(self.id.clone(), "countries"),
anchor,
&rows,
current.unwrap_or(usize::MAX),
pick,
Some(Rc::new(move |_, cx| {
dismiss.update(cx, |picker, cx| {
picker.open = false;
cx.notify();
})
})),
cx,
))
})
}
}
#[cfg(test)]
mod tests {
use super::{apply_mask, mask_hint, refit};
fn typed(mask: &str, keys: &str) -> String {
keys.chars().fold(String::new(), |text, key| {
let end = text.len();
refit(mask, &text, end..end, &key.to_string()).0
})
}
#[test]
fn mask_fits_slots_skips_misfits_and_stops_with_the_input() {
assert_eq!(apply_mask("(999) 999-9999", "5551234567"), "(555) 123-4567");
assert_eq!(apply_mask("(999) 999-9999", "555"), "(555");
assert_eq!(apply_mask("(999) 999-9999", "(55x5) 1"), "(555) 1");
assert_eq!(apply_mask("aa-9999", "ab1234"), "ab-1234");
assert_eq!(apply_mask("aa-9999", "12ab"), "ab");
assert_eq!(mask_hint("(999) 999-9999"), "(___) ___-____");
}
#[test]
fn typing_fills_slots_and_literals_never_eat_input() {
assert_eq!(typed("+1 999", "234"), "+1 234");
assert_eq!(typed("1-999", "123"), "1-123");
assert_eq!(typed("(999) 999-9999", "5551234567"), "(555) 123-4567");
}
#[test]
fn an_edit_replaces_its_range_and_places_the_caret() {
let fit = |mask, text, range, typed| refit(mask, text, range, typed);
assert_eq!(fit("1-999", "1-123", 0..5, "1"), ("1-1".into(), 3));
assert_eq!(fit("+1 999", "+1 23", 2..3, ""), ("+1 23".into(), 2));
assert_eq!(
fit("(999) 999-9999", "(555) 123-4567", 8..8, "9"),
("(555) 129-3456".into(), 9)
);
assert_eq!(
fit("(999) 999-9999", "(555) 1", 6..7, ""),
("(555".into(), 4)
);
assert_eq!(
fit("(999) 999-9999", "", 0..0, "(555) 123-4567"),
("(555) 123-4567".into(), 14)
);
}
}
+279
View File
@@ -0,0 +1,279 @@
use std::{ops::Range, rc::Rc};
use gpui::{
App, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Window, div, prelude::*,
};
use super::{
Highlight, Input, TextInput,
suggest::{Suggestion, suggestion_list},
text::{Down, Enter, Up},
};
use crate::theme::ActiveTheme;
const SHOWN: usize = 6;
/// The trigger being typed: its byte offset and char, ending at `caret`.
pub(crate) fn active_trigger(text: &str, caret: usize, triggers: &[char]) -> Option<(usize, char)> {
let before = &text[..caret];
let (ix, ch) = before
.char_indices()
.rev()
.take_while(|(_, ch)| !ch.is_whitespace())
.find(|(_, ch)| triggers.contains(ch))?;
let opens = before[..ix]
.chars()
.next_back()
.is_none_or(char::is_whitespace);
let word = before[ix + ch.len_utf8()..]
.chars()
.all(|ch| ch.is_alphanumeric() || ch == '_');
(opens && word).then_some((ix, ch))
}
/// The dismissed trigger, while it is still the one being typed.
fn dismissal(dismissed: Option<usize>, active: Option<usize>) -> Option<usize> {
dismissed.filter(|at| active == Some(*at))
}
/// Spans of `@name` and `#tag` for a highlighter.
pub(crate) fn mention_spans(text: &str) -> Vec<Range<usize>> {
let mut spans = Vec::new();
let mut previous_space = true;
let mut chars = text.char_indices().peekable();
while let Some((ix, ch)) = chars.next() {
if previous_space && (ch == '@' || ch == '#') {
let mut end = ix + 1;
while let Some(&(at, next)) = chars.peek() {
if !(next.is_alphanumeric() || next == '_') {
break;
}
end = at + next.len_utf8();
chars.next();
}
if end > ix + 1 {
spans.push(ix..end);
}
previous_space = false;
continue;
}
previous_space = ch.is_whitespace();
}
spans
}
/// Colors mentions and tags in the link tone over a faint wash.
pub fn mention_highlights(text: &str, cx: &App) -> Vec<(Range<usize>, Highlight)> {
let link = cx.theme().colors.link;
mention_spans(text)
.into_iter()
.map(|range| {
(
range,
Highlight {
color: link,
background: Some(link.opacity(0.08)),
},
)
})
.collect()
}
#[derive(Default)]
struct Picking {
highlighted: usize,
dismissed: Option<usize>,
}
/// A field where `@` or `#` opens suggestions at the caret. Arrows choose; Enter picks.
#[derive(IntoElement)]
pub struct MentionInput {
id: ElementId,
state: Entity<TextInput>,
triggers: Vec<(char, Vec<SharedString>)>,
}
impl MentionInput {
/// Give the state `mention_highlights` as its highlighter.
pub fn new(id: impl Into<ElementId>, state: &Entity<TextInput>) -> Self {
Self {
id: id.into(),
state: state.clone(),
triggers: Vec::new(),
}
}
/// Offers `handles` after `trigger`, such as `@` for people or `#` for tags.
pub fn trigger(
mut self,
trigger: char,
handles: impl IntoIterator<Item = impl Into<SharedString>>,
) -> Self {
let handles: Vec<SharedString> = handles.into_iter().map(Into::into).collect();
for handle in &handles {
assert!(
!handle.is_empty() && handle.chars().all(|ch| ch.is_alphanumeric() || ch == '_'),
"mention handle {handle:?} must be one word"
);
}
self.triggers.push((trigger, handles));
self
}
}
impl RenderOnce for MentionInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let picking = window.use_keyed_state(self.id.clone(), cx, |_, _| Picking::default());
let input = self.state.read(cx);
let (text, caret) = (input.text().to_string(), input.cursor());
let focused = input.focus().is_focused(window);
let marks: Vec<char> = self.triggers.iter().map(|(trigger, _)| *trigger).collect();
let active = active_trigger(&text, caret, &marks);
let anchor = active.and_then(|(ix, _)| input.bounds_for(ix));
let matches: Vec<SharedString> = active
.map(|(ix, trigger)| {
let query = text[ix + trigger.len_utf8()..caret].to_lowercase();
let names = &self
.triggers
.iter()
.find(|(mark, _)| *mark == trigger)
.expect("from triggers")
.1;
names
.iter()
.filter(|name| name.to_lowercase().contains(&query))
.take(SHOWN)
.cloned()
.collect()
})
.unwrap_or_default();
let dismissed = dismissal(picking.read(cx).dismissed, active.map(|(ix, _)| ix));
if dismissed != picking.read(cx).dismissed {
picking.update(cx, |picking, _| picking.dismissed = dismissed);
}
let open =
focused && !matches.is_empty() && active.is_some_and(|(ix, _)| dismissed != Some(ix));
let highlighted = picking
.read(cx)
.highlighted
.min(matches.len().saturating_sub(1));
log::debug!(
"mention input: open {open}, {} matches, caret {anchor:?}",
matches.len()
);
let rows: Vec<Suggestion> = matches
.iter()
.map(|name| Suggestion {
label: active.map_or(name.clone(), |(_, trigger)| {
SharedString::from(format!("{trigger}{name}"))
}),
note: None,
})
.collect();
let insert = {
let (state, matches) = (self.state.clone(), matches.clone());
Rc::new(move |pick: usize, _: &mut Window, cx: &mut App| {
let Some((ix, trigger)) = active else {
return;
};
let name = &matches[pick];
log::info!("mention input: picked {trigger}{name}");
state.update(cx, |input, cx| {
input.select(ix..caret, cx);
input.insert(&format!("{trigger}{name} "), cx);
});
})
};
let count = matches.len();
let (up, down, enter, escape) = (
picking.clone(),
picking.clone(),
insert.clone(),
picking.clone(),
);
div()
.id(self.id)
.relative()
.w_full()
.capture_action(move |_: &Up, _, cx| {
if open {
cx.stop_propagation();
up.update(cx, |picking, cx| {
picking.highlighted = (picking.highlighted + count - 1) % count;
cx.notify();
});
}
})
.capture_action(move |_: &Down, _, cx| {
if open {
cx.stop_propagation();
down.update(cx, |picking, cx| {
picking.highlighted = (picking.highlighted + 1) % count;
cx.notify();
});
}
})
.capture_action(move |_: &Enter, window, cx| {
if open {
cx.stop_propagation();
enter(highlighted, window, cx);
}
})
.on_key_down(move |event, _, cx| {
if open && event.keystroke.key == "escape" {
cx.stop_propagation();
escape.update(cx, |picking, cx| {
picking.dismissed = active.map(|(ix, _)| ix);
cx.notify();
});
}
})
.child(Input::new(&self.state))
.when_some(anchor.filter(|_| open), |field, anchor| {
field.child(suggestion_list(
"mentions",
anchor,
&rows,
highlighted,
insert,
None,
cx,
))
})
}
}
#[cfg(test)]
mod tests {
use super::{active_trigger, dismissal, mention_spans};
#[test]
fn triggers_open_after_space_and_close_on_space() {
let marks = ['@', '#'];
assert_eq!(active_trigger("hi @ad", 6, &marks), Some((3, '@')));
assert_eq!(active_trigger("@", 1, &marks), Some((0, '@')));
assert_eq!(active_trigger("mail@ad", 7, &marks), None);
assert_eq!(active_trigger("hi @ada lovelace", 16, &marks), None);
assert_eq!(active_trigger("go #rel", 7, &marks), Some((3, '#')));
}
#[test]
fn a_dismissal_ends_with_its_trigger() {
assert_eq!(dismissal(Some(0), Some(0)), Some(0));
assert_eq!(dismissal(Some(0), None), None);
assert_eq!(dismissal(Some(0), Some(4)), None);
}
#[test]
fn spans_cover_whole_names() {
assert_eq!(
mention_spans("ping @ada and #release now"),
vec![5..9, 14..22]
);
assert_eq!(
mention_spans("a@b # c"),
Vec::<std::ops::Range<usize>>::new()
);
}
}
+39
View File
@@ -0,0 +1,39 @@
mod checked;
mod expression;
mod group;
mod hotkey;
mod inline;
mod input;
mod masked;
mod mention;
mod number;
mod pairs;
mod path;
mod pattern;
mod pin;
mod search;
mod suggest;
mod tags;
#[cfg(all(test, feature = "test-support"))]
mod tests;
mod text;
mod unit;
pub use checked::{EmailInput, UrlInput};
pub use expression::{ExpressionInput, evaluate, expression_highlights};
pub use group::{InputAddon, InputGroup};
pub use hotkey::HotkeyInput;
pub use inline::InlineEdit;
pub use input::{Input, PasswordInput};
pub use masked::{COUNTRIES, MaskedInput, PhoneInput};
pub use mention::{MentionInput, mention_highlights};
pub use number::{NumberInput, ScrubInput};
pub use pairs::{KeyValueInput, ListInput};
pub use path::PathInput;
pub use pattern::{RegexInput, regex_highlights};
pub use pin::PinInput;
pub use search::SearchInput;
pub use tags::TagInput;
pub(crate) use text::bind_keys;
pub use text::{Highlight, InputEvent, TextInput};
pub use unit::UnitInput;
+482
View File
@@ -0,0 +1,482 @@
use std::{cell::Cell, rc::Rc};
use gpui::{
AnyElement, App, Context, CursorStyle, DragMoveEvent, ElementId, EmptyView, Entity, EntityId,
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, RenderOnce, SharedString,
StatefulInteractiveElement, Styled, Subscription, Window, div, prelude::*,
};
use super::{
Input, InputEvent, TextInput,
text::{Down, Up},
};
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, TextSize},
typography::format::{self, Separators},
};
type OnChange = Rc<dyn Fn(f64, &mut Window, &mut App)>;
type Nudge = Box<dyn Fn(&mut Window, &mut App)>;
/// Reads a typed number: groups and the typographic minus are allowed.
pub(crate) fn parse(text: &str) -> Option<f64> {
let plain: String = text
.chars()
.filter(|ch| *ch != ',')
.map(|ch| if ch == format::MINUS { '-' } else { ch })
.collect();
plain
.trim()
.parse::<f64>()
.ok()
.filter(|value| value.is_finite())
}
/// Rounds to `precision` places, then clamps to the rounded numbers in `min..=max`.
fn settle(value: f64, min: f64, max: f64, precision: usize) -> f64 {
let scale = 10f64.powi(precision as i32);
let (low, high) = (
on_grid(min, scale, f64::ceil),
on_grid(max, scale, f64::floor),
);
assert!(
low <= high,
"no {precision}-place number lies in {min}..={max}"
);
((value * scale).round() / scale).clamp(low, high)
}
/// `bound` on the `1 / scale` grid: kept if float error alone put it off, else moved `inward`.
fn on_grid(bound: f64, scale: f64, inward: fn(f64) -> f64) -> f64 {
let scaled = bound * scale;
let near = scaled.round();
if (scaled - near).abs() <= 4.0 * f64::EPSILON * near.abs().max(1.0) {
near / scale
} else {
inward(scaled) / scale
}
}
/// A number's live settings, read by its input's event handler.
struct Numeric {
input: Entity<TextInput>,
on_change: Option<OnChange>,
value: f64,
limits: (f64, f64, f64, usize),
_events: Subscription,
}
impl Numeric {
/// The typed number, or the owner's value while the text is not one.
fn current(&self, cx: &App) -> f64 {
parse(self.input.read(cx).text()).unwrap_or(self.value)
}
}
/// Shows `value` in the field, then tells the owner.
fn show(
input: &Entity<TextInput>,
on_change: Option<&OnChange>,
value: f64,
precision: usize,
window: &mut Window,
cx: &mut App,
) {
let shown = format::number(value, precision, Separators::EN);
input.update(cx, |input, cx| input.set_text(shown, cx));
log::info!("number input: {value}");
if let Some(on_change) = on_change {
on_change(value, window, cx);
}
}
/// Settles `next` into the limits and shows it.
fn commit(state: &Entity<Numeric>, next: f64, window: &mut Window, cx: &mut App) {
let (input, on_change, (min, max, _, precision)) = {
let numeric = state.read(cx);
(
numeric.input.clone(),
numeric.on_change.clone(),
numeric.limits,
)
};
let value = settle(next, min, max, precision);
show(&input, on_change.as_ref(), value, precision, window, cx);
}
fn numeric(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity<Numeric> {
window.use_keyed_state(id.clone(), cx, |window, cx: &mut Context<Numeric>| {
let input = cx.new(|cx| {
TextInput::new(window, cx)
.filter(|ch| ch.is_ascii_digit() || matches!(ch, '.' | ',' | '-' | format::MINUS))
});
let events = cx.subscribe_in(&input, window, |numeric, input, event, window, cx| {
if !matches!(event, InputEvent::Blur | InputEvent::Submit) {
return;
}
let (min, max, _, precision) = numeric.limits;
let value = settle(numeric.current(cx), min, max, precision);
show(
input,
numeric.on_change.as_ref(),
value,
precision,
window,
cx,
);
});
Numeric {
input,
on_change: None,
value: 0.0,
limits: (f64::MIN, f64::MAX, 1.0, 0),
_events: events,
}
})
}
struct Scrub {
owner: EntityId,
start: Rc<Cell<Option<(Pixels, f64)>>>,
}
/// Wraps `face` so dragging it sideways moves the number, one step per notch.
fn scrub_handle(
id: ElementId,
face: AnyElement,
state: Entity<Numeric>,
window: &mut Window,
cx: &mut App,
) -> impl IntoElement + use<> {
let owner = window
.use_keyed_state(id.clone(), cx, |_, _| ())
.entity_id();
let notch = cx.theme().handle_hit().to_pixels(window.rem_size());
div()
.id(id)
.cursor(CursorStyle::ResizeLeftRight)
.on_drag(
Scrub {
owner,
start: Rc::new(Cell::new(None)),
},
|scrub, _, _, cx| {
scrub.start.set(None);
cx.new(|_| EmptyView)
},
)
.on_drag_move(move |event: &DragMoveEvent<Scrub>, window, cx| {
let scrub = event.drag(cx);
if scrub.owner != owner {
return;
}
let (start, x) = (scrub.start.clone(), event.event.position.x);
let (anchor, from) = start.get().unwrap_or_else(|| {
let fresh = (x, state.read(cx).current(cx));
start.set(Some(fresh));
fresh
});
let notches = f64::from(((x - anchor) / notch).trunc());
let step = state.read(cx).limits.2;
commit(&state, from + notches * step, window, cx);
})
.child(face)
}
/// A number field: arrows or the stepper move it, dragging the grip scrubs it.
#[derive(IntoElement)]
pub struct NumberInput {
pub(super) id: ElementId,
value: f64,
min: f64,
max: f64,
step: f64,
precision: usize,
prefix: Option<SharedString>,
suffix: Option<AnyElement>,
size: ControlSize,
on_change: Option<OnChange>,
scrub_label: Option<SharedString>,
}
impl NumberInput {
pub fn new(id: impl Into<ElementId>, value: f64) -> Self {
Self {
id: id.into(),
value,
min: f64::MIN,
max: f64::MAX,
step: 1.0,
precision: 0,
prefix: None,
suffix: None,
size: ControlSize::default(),
on_change: None,
scrub_label: None,
}
}
pub fn range(mut self, min: f64, max: f64) -> Self {
assert!(min <= max, "number range {min}..{max} is empty");
self.min = min;
self.max = max;
self
}
pub fn step(mut self, step: f64) -> Self {
assert!(step > 0.0, "number step {step} must be positive");
self.step = step;
self
}
/// Decimal places kept and shown.
pub fn precision(mut self, places: usize) -> Self {
self.precision = places;
self
}
/// A symbol before the digits, such as `$`.
pub fn prefix(mut self, symbol: impl Into<SharedString>) -> Self {
self.prefix = Some(symbol.into());
self
}
/// Money in an ISO 4217 currency: its symbol and minor units.
pub fn currency(mut self, code: &str) -> Self {
let (symbol, places, _) = format::currency_parts(code);
self.prefix = Some(SharedString::from(symbol.to_string()));
self.precision = places;
self
}
/// A percentage, marked with `%`.
pub fn percent(self) -> Self {
self.suffix(div().child("%"))
}
/// Something after the digits, such as `%` or a unit.
pub fn suffix(mut self, suffix: impl IntoElement) -> Self {
self.suffix = Some(suffix.into_any_element());
self
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for NumberInput {
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let state = numeric(&self.id, window, cx);
let (value, min, max, step) = (self.value, self.min, self.max, self.step);
state.update(cx, |numeric, _| {
numeric.on_change = self.on_change.clone();
numeric.value = value;
numeric.limits = (min, max, step, self.precision);
});
let input = state.read(cx).input.clone();
let shown = format::number(value, self.precision, Separators::EN);
let editing = input.read(cx).focus().is_focused(window);
if !editing && input.read(cx).text() != shown {
input.update(cx, |input, cx| input.set_text(shown, cx));
}
let now = state.read(cx).current(cx);
let nudge = |by: f64| {
let state = state.clone();
move |window: &mut Window, cx: &mut App| {
let from = state.read(cx).current(cx);
commit(&state, from + by, window, cx);
}
};
let (up, down, key_up, key_down) = (nudge(step), nudge(-step), nudge(step), nudge(-step));
let label = self.scrub_label.take().map(|label| {
let theme = cx.theme();
let face = div()
.flex_none()
.min_w(theme.control_height(ControlSize::Md))
.whitespace_nowrap()
.text_size(theme.text_size(TextSize::Sm))
.text_color(theme.colors.fg_muted)
.child(label)
.into_any_element();
scrub_handle(
(self.id.clone(), "label").into(),
face,
state.clone(),
window,
cx,
)
});
let subtle = cx.theme().colors.fg_subtle;
let grip = scrub_handle(
(self.id.clone(), "grip").into(),
Icon::new(IconName::ChevronsUpDown)
.size(IconSize::Xs)
.color(subtle)
.into_any_element(),
state,
window,
cx,
);
let theme = cx.theme();
let colors = &theme.colors;
let half = |id: &'static str, icon: IconName, enabled: bool, act: Nudge| {
div()
.id((self.id.clone(), id))
.flex()
.flex_1()
.items_center()
.justify_center()
.w(theme.icon_size(IconSize::Lg))
.map(|half| {
if enabled {
half.cursor_pointer()
.hover(|style| style.bg(colors.hover))
.on_mouse_down(MouseButton::Left, |_, window, _| {
window.prevent_default()
})
.on_click(move |_, window, cx| act(window, cx))
} else {
half.opacity(0.4)
}
})
.child(Icon::new(icon).size(IconSize::Xs).color(colors.fg_muted))
};
let stepper = div()
.flex()
.flex_col()
.h_full()
.border_l_1()
.border_color(colors.border)
.child(half("up", IconName::ChevronUp, now < max, Box::new(up)))
.child(half(
"down",
IconName::ChevronDown,
now > min,
Box::new(down),
));
let field = Input::new(&input)
.size(self.size)
.prefix(
div()
.flex()
.items_center()
.gap_1()
.child(grip)
.when_some(self.prefix, |prefix, symbol| {
prefix.child(div().text_color(subtle).child(symbol))
}),
)
.suffix(
div()
.flex()
.items_center()
.gap_1()
.children(self.suffix)
.child(stepper),
);
div()
.flex()
.items_center()
.gap_2()
.w_full()
.children(label)
.child(
div()
.id(self.id)
.flex_1()
.min_w_0()
.capture_action(move |_: &Up, window, cx| {
cx.stop_propagation();
key_up(window, cx);
})
.capture_action(move |_: &Down, window, cx| {
cx.stop_propagation();
key_down(window, cx);
})
.child(field),
)
}
}
/// A label you drag sideways to change a number, beside its field.
#[derive(IntoElement)]
pub struct ScrubInput {
field: NumberInput,
}
impl ScrubInput {
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, value: f64) -> Self {
let mut field = NumberInput::new(id, value);
field.scrub_label = Some(label.into());
Self { field }
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.field = self.field.range(min, max);
self
}
pub fn step(mut self, step: f64) -> Self {
self.field = self.field.step(step);
self
}
pub fn precision(mut self, places: usize) -> Self {
self.field = self.field.precision(places);
self
}
pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self {
self.field = self.field.on_change(handler);
self
}
}
impl RenderOnce for ScrubInput {
fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement {
self.field
}
}
#[cfg(test)]
mod tests {
use super::{parse, settle};
#[test]
fn parses_grouped_and_typographic_minus_and_refuses_junk() {
assert_eq!(parse("1,234.5"), Some(1234.5));
assert_eq!(parse("\u{2212}3"), Some(-3.0));
assert_eq!(parse("abc"), None);
assert_eq!(parse("1e999"), None);
}
#[test]
fn settle_clamps_then_rounds() {
assert_eq!(settle(1.23456, 0.0, 10.0, 2), 1.23);
assert_eq!(settle(-5.0, 0.0, 10.0, 0), 0.0);
assert_eq!(settle(12.6, 0.0, 10.0, 0), 10.0);
}
#[test]
fn settle_rounds_before_it_clamps() {
assert_eq!(settle(0.2 + 0.1, 0.0, 0.25, 1), 0.2);
assert_eq!(settle(-0.26, -0.25, 1.0, 1), -0.2);
assert_eq!(settle(0.3, 0.0, 0.29, 2), 0.29);
assert_eq!(settle(0.29, 0.29, 0.29, 2), 0.29);
assert_eq!(settle(10000000.01, 0.0, 10000000.005, 2), 10000000.0);
}
#[test]
#[should_panic(expected = "no 1-place number lies in 0.21..=0.29")]
fn settle_refuses_a_range_without_a_number_at_its_precision() {
settle(0.25, 0.21, 0.29, 1);
}
}
+247
View File
@@ -0,0 +1,247 @@
use std::rc::Rc;
use gpui::{
App, Context, ElementId, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
Subscription, Window, div, prelude::*,
};
use super::{Input, InputEvent, TextInput};
use crate::{
buttons::{Button, ButtonVariant, IconButton},
primitives::IconName,
theme::ControlSize,
};
type OnRows = Rc<dyn Fn(Vec<Vec<String>>, &mut Window, &mut App)>;
/// Rows of fields: `columns` wide, each row removable, more rows on demand.
struct Rows {
columns: usize,
rows: Vec<Vec<(Entity<TextInput>, Subscription)>>,
placeholders: Vec<SharedString>,
on_change: Option<OnRows>,
}
impl Rows {
fn values(&self, cx: &App) -> Vec<Vec<String>> {
self.rows
.iter()
.map(|row| {
row.iter()
.map(|(field, _)| field.read(cx).text().to_string())
.collect()
})
.collect()
}
fn report(&self, window: &mut Window, cx: &mut Context<Self>) {
let values = self.values(cx);
if let Some(on_change) = self.on_change.clone() {
on_change(values, window, cx);
}
}
fn push(&mut self, seed: &[String], window: &mut Window, cx: &mut Context<Self>) {
let row = (0..self.columns)
.map(|column| {
let placeholder = self.placeholders[column].clone();
let text = seed.get(column).cloned().unwrap_or_default();
let field = cx.new(|cx| {
let mut input = TextInput::new(window, cx).placeholder(placeholder);
input.set_text(text, cx);
input
});
let events = cx.subscribe_in(&field, window, |rows, _, event, window, cx| {
if *event == InputEvent::Changed {
rows.report(window, cx);
}
});
(field, events)
})
.collect();
self.rows.push(row);
}
}
fn rows_state(
id: &ElementId,
seed: &[Vec<String>],
placeholders: &[SharedString],
window: &mut Window,
cx: &mut App,
) -> Entity<Rows> {
let (seed, placeholders) = (seed.to_vec(), placeholders.to_vec());
window.use_keyed_state(id.clone(), cx, move |window, cx: &mut Context<Rows>| {
let mut rows = Rows {
columns: placeholders.len(),
rows: Vec::new(),
placeholders,
on_change: None,
};
for row in &seed {
rows.push(row, window, cx);
}
rows
})
}
fn render_rows(
id: &ElementId,
state: Entity<Rows>,
add: &'static str,
cx: &App,
) -> impl IntoElement + use<> {
let rows = state.read(cx).rows.len();
let lines = (0..rows).map(|ix| {
let fields: Vec<Entity<TextInput>> = state.read(cx).rows[ix]
.iter()
.map(|(field, _)| field.clone())
.collect();
let remove = state.clone();
div()
.flex()
.items_center()
.gap_2()
.children(fields.iter().map(|field| {
div()
.flex_1()
.child(Input::new(field).size(ControlSize::Sm))
}))
.child(
IconButton::new(("remove-row", ix), IconName::Minus)
.size(ControlSize::Sm)
.tooltip("Remove")
.on_click(move |_, window, cx| {
log::info!("rows: removed {ix}");
remove.update(cx, |rows, cx| {
rows.rows.remove(ix);
rows.report(window, cx);
cx.notify();
})
}),
)
});
let grow = state.clone();
div()
.id(id.clone())
.flex()
.flex_col()
.gap_2()
.w_full()
.children(lines)
.child(
div().child(
Button::new(("add-row", 0usize), add)
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.icon(IconName::Plus)
.on_click(move |_, window, cx| {
log::info!("rows: added one");
grow.update(cx, |rows, cx| {
rows.push(&[], window, cx);
let last = rows.rows.last().expect("just pushed");
window.focus(&last[0].0.read(cx).focus().clone());
rows.report(window, cx);
cx.notify();
})
}),
),
)
}
/// Rows of key and value fields; add and remove rows.
#[derive(IntoElement)]
pub struct KeyValueInput {
id: ElementId,
seed: Vec<Vec<String>>,
on_change: Option<OnRows>,
}
impl KeyValueInput {
/// `seed` fills the rows on first render.
pub fn new(id: impl Into<ElementId>, seed: impl IntoIterator<Item = (String, String)>) -> Self {
Self {
id: id.into(),
seed: seed
.into_iter()
.map(|(key, value)| vec![key, value])
.collect(),
on_change: None,
}
}
/// Reports every pair after each change.
pub fn on_change(
mut self,
handler: impl Fn(Vec<(String, String)>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(
move |rows: Vec<Vec<String>>, window: &mut Window, cx: &mut App| {
let pairs = rows
.into_iter()
.map(|mut row| {
let value = row.pop().expect("two columns");
(row.pop().expect("two columns"), value)
})
.collect();
handler(pairs, window, cx)
},
));
self
}
}
impl RenderOnce for KeyValueInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let placeholders = [SharedString::from("Key"), SharedString::from("Value")];
let state = rows_state(&self.id, &self.seed, &placeholders, window, cx);
state.update(cx, |rows, _| rows.on_change = self.on_change.clone());
render_rows(&self.id, state, "Add pair", cx)
}
}
/// A list of text entries; add and remove entries.
#[derive(IntoElement)]
pub struct ListInput {
id: ElementId,
seed: Vec<Vec<String>>,
placeholder: SharedString,
on_change: Option<OnRows>,
}
impl ListInput {
pub fn new(id: impl Into<ElementId>, seed: impl IntoIterator<Item = String>) -> Self {
Self {
id: id.into(),
seed: seed.into_iter().map(|item| vec![item]).collect(),
placeholder: "Item".into(),
on_change: None,
}
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
self.placeholder = text.into();
self
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<String>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(
move |rows: Vec<Vec<String>>, window: &mut Window, cx: &mut App| {
handler(rows.into_iter().flatten().collect(), window, cx)
},
));
self
}
}
impl RenderOnce for ListInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let placeholders = [self.placeholder.clone()];
let state = rows_state(&self.id, &self.seed, &placeholders, window, cx);
state.update(cx, |rows, _| rows.on_change = self.on_change.clone());
render_rows(&self.id, state, "Add item", cx)
}
}
+93
View File
@@ -0,0 +1,93 @@
use gpui::{
App, ElementId, Entity, IntoElement, PathPromptOptions, RenderOnce, SharedString, Window,
};
use super::{Input, TextInput};
use crate::{
buttons::{Button, ButtonVariant},
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize},
};
/// A path field with Browse, which opens the system's file dialog.
#[derive(IntoElement)]
pub struct PathInput {
id: ElementId,
state: Entity<TextInput>,
directories: bool,
}
impl PathInput {
pub fn new(id: impl Into<ElementId>, state: &Entity<TextInput>) -> Self {
Self {
id: id.into(),
state: state.clone(),
directories: false,
}
}
/// Chooses folders instead of files.
pub fn directories(mut self) -> Self {
self.directories = true;
self
}
}
impl RenderOnce for PathInput {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let subtle = cx.theme().colors.fg_subtle;
let (state, directories) = (self.state.clone(), self.directories);
let icon = if directories {
IconName::Folder
} else {
IconName::File
};
Input::new(&self.state)
.prefix(Icon::new(icon).size(IconSize::Sm).color(subtle))
.suffix(
Button::new(self.id, "Browse…")
.size(ControlSize::Sm)
.variant(ButtonVariant::Ghost)
.on_click(move |_, window, cx| {
let chosen = cx.prompt_for_paths(PathPromptOptions {
files: !directories,
directories,
multiple: false,
prompt: Some(SharedString::from("Choose")),
});
log::info!("path input: dialog opened");
let state = state.clone();
window
.spawn(cx, async move |cx| {
let path = match chosen.await {
Ok(Ok(Some(paths))) => paths.into_iter().next(),
Ok(Ok(None)) => {
log::info!("path input: dialog cancelled");
return;
}
Ok(Err(error)) => {
log::error!("path input: dialog failed: {error:#}");
return;
}
Err(_) => {
log::error!("path input: dialog closed without an answer");
return;
}
};
let Some(path) = path else {
log::error!("path input: dialog chose nothing");
return;
};
let shown = path.display().to_string();
log::info!("path input: chose a path");
if let Err(error) =
state.update(cx, |input, cx| input.set_text(shown, cx))
{
log::error!("path input: field gone: {error:#}");
}
})
.detach();
}),
)
}
}
+191
View File
@@ -0,0 +1,191 @@
use std::ops::Range;
use gpui::{
App, Entity, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
prelude::*,
};
use regex::Regex;
use super::{Highlight, Input, TextInput};
use crate::theme::{ActiveTheme, ControlSize, TextSize};
/// Token spans of a regex, by kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Part {
Escape,
Class,
Group,
Quantifier,
Anchor,
Alternation,
}
/// Splits a pattern into colored parts; plain text stays uncolored.
pub(crate) fn parts(pattern: &str) -> Vec<(Range<usize>, Part)> {
let mut out = Vec::new();
let mut chars = pattern.char_indices().peekable();
while let Some((ix, ch)) = chars.next() {
let span = |end: usize| ix..end;
match ch {
'\\' => {
let end = chars
.next()
.map_or(pattern.len(), |(next, ch)| next + ch.len_utf8());
out.push((span(end), Part::Escape));
}
'[' => {
let mut end = pattern.len();
while let Some((next, ch)) = chars.next() {
if ch == '\\' {
chars.next();
} else if ch == ']' {
end = next + 1;
break;
}
}
out.push((span(end), Part::Class));
}
'(' | ')' => {
let mut end = ix + 1;
if ch == '(' && chars.peek().is_some_and(|(_, next)| *next == '?') {
chars.next();
end += 1;
if let Some((next, ch)) =
chars.next_if(|(_, ch)| matches!(ch, ':' | '=' | '!' | '<'))
{
end = next + ch.len_utf8();
}
}
out.push((span(end), Part::Group));
}
'*' | '+' | '?' => out.push((span(ix + 1), Part::Quantifier)),
'{' => {
let end = pattern[ix..]
.find('}')
.map_or(pattern.len(), |close| ix + close + 1);
while chars.peek().is_some_and(|(next, _)| *next < end) {
chars.next();
}
out.push((span(end), Part::Quantifier));
}
'^' | '$' => out.push((span(ix + 1), Part::Anchor)),
'|' => out.push((span(ix + 1), Part::Alternation)),
_ => {}
}
}
out
}
/// Colors for a regex, from the syntax palette.
pub fn regex_highlights(pattern: &str, cx: &App) -> Vec<(Range<usize>, Highlight)> {
let syntax = &cx.theme().colors.syntax;
parts(pattern)
.into_iter()
.map(|(range, part)| {
let color = match part {
Part::Escape => syntax.keyword,
Part::Class => syntax.string,
Part::Group => syntax.function,
Part::Quantifier => syntax.number,
Part::Anchor => syntax.constant,
Part::Alternation => syntax.operator,
};
(
range,
Highlight {
color,
background: None,
},
)
})
.collect()
}
/// A regular expression field: colored as you type, checked as you type.
#[derive(IntoElement)]
pub struct RegexInput {
state: Entity<TextInput>,
size: ControlSize,
}
impl RegexInput {
/// Give the state `regex_highlights` as its highlighter.
pub fn new(state: &Entity<TextInput>) -> Self {
Self {
state: state.clone(),
size: ControlSize::default(),
}
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
}
impl RenderOnce for RegexInput {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let pattern = self.state.read(cx).text().to_string();
let error = (!pattern.is_empty())
.then(|| Regex::new(&pattern).err())
.flatten()
.map(|error| {
let text = error.to_string();
let last = text.lines().last().unwrap_or_default().trim().to_string();
SharedString::from(last)
});
let theme = cx.theme();
let (subtle, danger) = (theme.colors.fg_subtle, theme.colors.danger);
let mono = theme.mono_family.clone();
let slash = || div().text_color(subtle).child("/");
div()
.flex()
.flex_col()
.gap_1()
.w_full()
.child(
div().font_family(mono).child(
Input::new(&self.state)
.size(self.size)
.invalid(error.is_some())
.prefix(slash())
.suffix(slash()),
),
)
.when_some(error, |field, error| {
field.child(
div()
.text_size(theme.text_size(TextSize::Xs))
.text_color(danger)
.child(error),
)
})
}
}
#[cfg(test)]
mod tests {
use super::{Part, parts};
#[test]
fn parts_cover_escapes_classes_groups_quantifiers() {
let found = parts(r"^(?:\d+)[a-z\]]{2,3}|x$");
let kinds: Vec<Part> = found.iter().map(|(_, part)| *part).collect();
assert_eq!(
kinds,
vec![
Part::Anchor,
Part::Group,
Part::Escape,
Part::Quantifier,
Part::Group,
Part::Class,
Part::Quantifier,
Part::Alternation,
Part::Anchor,
]
);
assert_eq!(found[1].0, 1..4);
assert_eq!(found[5].0, 8..15);
}
}
+150
View File
@@ -0,0 +1,150 @@
use std::rc::Rc;
use gpui::{
App, Context, ElementId, Entity, FontWeight, IntoElement, ParentElement, RenderOnce,
SharedString, Styled, Subscription, Window, div, prelude::*,
};
use super::{InputEvent, TextInput};
use crate::theme::{ActiveTheme, ControlSize, Radius, TextSize};
type OnCode = Rc<dyn Fn(&str, &mut Window, &mut App)>;
struct Pin {
input: Entity<TextInput>,
length: usize,
on_complete: Option<OnCode>,
_events: Subscription,
}
/// Boxes for a one-time code. Typing moves along; a paste fills them all.
#[derive(IntoElement)]
pub struct PinInput {
id: ElementId,
length: usize,
letters: bool,
masked: bool,
on_complete: Option<OnCode>,
}
impl PinInput {
pub fn new(id: impl Into<ElementId>, length: usize) -> Self {
assert!(length > 0, "a pin needs at least one box");
Self {
id: id.into(),
length,
letters: false,
masked: false,
on_complete: None,
}
}
/// Accepts letters as well as digits.
pub fn letters(mut self) -> Self {
self.letters = true;
self
}
/// Shows bullets in filled boxes.
pub fn masked(mut self) -> Self {
self.masked = true;
self
}
/// Runs once every box is filled.
pub fn on_complete(mut self, handler: impl Fn(&str, &mut Window, &mut App) + 'static) -> Self {
self.on_complete = Some(Rc::new(handler));
self
}
}
impl RenderOnce for PinInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let (length, letters) = (self.length, self.letters);
let state = window.use_keyed_state(self.id.clone(), cx, |window, cx: &mut Context<Pin>| {
let input = cx.new(|cx| {
TextInput::new(window, cx)
.max_len(length)
.filter(move |ch| ch.is_ascii_digit() || (letters && ch.is_ascii_alphabetic()))
});
let events = cx.subscribe_in(&input, window, |pin, input, event, window, cx| {
let code = input.read(cx).text().to_uppercase();
if *event == InputEvent::Changed && code.chars().count() == pin.length {
log::info!("pin input: complete");
if let Some(done) = pin.on_complete.clone() {
done(&code, window, cx);
}
}
});
Pin {
input,
length,
on_complete: None,
_events: events,
}
});
state.update(cx, |pin, _| pin.on_complete = self.on_complete.clone());
let input = state.read(cx).input.clone();
let (code, focused, caret_on) = {
let field = input.read(cx);
(
field.text().to_uppercase(),
field.focus().is_focused(window),
field.caret_on(),
)
};
let end = code.len();
let selection = input.read(cx).selection();
let whole = focused && end > 0 && selection == (0..end);
if focused && !whole && selection != (end..end) {
input.update(cx, |field, cx| field.select(end..end, cx));
}
let filled: Vec<char> = code.chars().collect();
let active = filled.len().min(length - 1);
let theme = cx.theme();
let colors = &theme.colors;
let side = theme.control_height(ControlSize::Lg) * 1.25;
let boxes = (0..length).map(|ix| {
let current = focused && ix == active;
let glyph = filled.get(ix).map(|ch| if self.masked { '•' } else { *ch });
div()
.relative()
.flex()
.items_center()
.justify_center()
.size(side)
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if current {
colors.focus
} else {
colors.border_strong
})
.bg(if whole && glyph.is_some() {
colors.selection
} else {
colors.surface
})
.text_size(theme.text_size(TextSize::Xl))
.font_weight(FontWeight::MEDIUM)
.text_color(colors.fg)
.when_some(glyph, |cell, glyph| {
cell.child(SharedString::from(glyph.to_string()))
})
.when(current && glyph.is_none() && caret_on, |cell| {
cell.child(
div()
.w(theme.caret_width())
.h(theme.text_size(TextSize::Xl))
.bg(colors.focus),
)
})
});
div()
.relative()
.flex()
.gap_2()
.children(boxes)
.child(div().absolute().inset_0().opacity(0.0).child(input))
}
}
+120
View File
@@ -0,0 +1,120 @@
use std::rc::Rc;
use gpui::{
App, Bounds, ElementId, Entity, IntoElement, ParentElement, Pixels, RenderOnce, SharedString,
Styled, Window, canvas, div, prelude::*,
};
use super::{
Input, TextInput,
suggest::{Suggestion, suggestion_list},
};
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize},
};
type OnPick = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A search field: a magnifier, a clear button, recent searches while empty.
#[derive(IntoElement)]
pub struct SearchInput {
id: ElementId,
state: Entity<TextInput>,
history: Vec<SharedString>,
size: ControlSize,
on_pick: Option<OnPick>,
}
impl SearchInput {
pub fn new(id: impl Into<ElementId>, state: &Entity<TextInput>) -> Self {
Self {
id: id.into(),
state: state.clone(),
history: Vec::new(),
size: ControlSize::default(),
on_pick: None,
}
}
/// Recent searches, newest first; shown while the field is empty.
pub fn history(mut self, history: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
self.history = history.into_iter().map(Into::into).collect();
self
}
pub fn size(mut self, size: ControlSize) -> Self {
self.size = size;
self
}
/// Runs after a recent search fills the field.
pub fn on_pick(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_pick = Some(Rc::new(handler));
self
}
}
impl RenderOnce for SearchInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let frame = window.use_keyed_state(self.id.clone(), cx, |_, _| Bounds::<Pixels>::default());
let input = self.state.read(cx);
let open = input.focus().is_focused(window) && input.is_empty() && !self.history.is_empty();
let anchor = *frame.read(cx);
let subtle = cx.theme().colors.fg_subtle;
let rows: Vec<Suggestion> = self
.history
.iter()
.map(|query| Suggestion {
label: query.clone(),
note: None,
})
.collect();
let (state, history, on_pick) = (self.state.clone(), self.history, self.on_pick);
let pick = Rc::new(move |ix: usize, window: &mut Window, cx: &mut App| {
let query = history[ix].clone();
log::info!("search input: recent {query}");
state.update(cx, |input, cx| input.set_text(query.to_string(), cx));
if let Some(on_pick) = &on_pick {
on_pick(&query, window, cx);
}
});
div()
.relative()
.w_full()
.child(
Input::new(&self.state)
.size(self.size)
.clearable()
.prefix(Icon::new(IconName::Search).size(IconSize::Sm).color(subtle)),
)
.child(
canvas(
move |bounds, _, cx| {
if *frame.read(cx) != bounds {
frame.update(cx, |frame, _| *frame = bounds);
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full(),
)
.when(open, |field| {
field.child(suggestion_list(
(self.id.clone(), "recent"),
anchor,
&rows,
usize::MAX,
pick,
None,
cx,
))
})
}
}
+79
View File
@@ -0,0 +1,79 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Bounds, ElementId, InteractiveElement, IntoElement, MouseButton,
ParentElement, Pixels, SharedString, Styled, anchored, deferred, div, point, prelude::*,
};
use crate::theme::{ActiveTheme, Elevation, Radius, TextSize};
/// One row in a suggestion list: a label and a quiet note.
#[derive(Clone, Debug)]
pub(crate) struct Suggestion {
pub label: SharedString,
pub note: Option<SharedString>,
}
type Pick = Rc<dyn Fn(usize, &mut gpui::Window, &mut App)>;
pub(crate) type Dismiss = Rc<dyn Fn(&mut gpui::Window, &mut App)>;
/// A list floating under `anchor`. Rows keep focus where it was.
pub(crate) fn suggestion_list(
id: impl Into<ElementId>,
anchor: Bounds<Pixels>,
rows: &[Suggestion],
highlighted: usize,
pick: Pick,
dismiss: Option<Dismiss>,
cx: &App,
) -> AnyElement {
let theme = cx.theme();
let colors = &theme.colors;
let list = div()
.id(id)
.min_w(theme.tooltip_max_width())
.p_1()
.flex()
.flex_col()
.rounded(theme.radius(Radius::Lg))
.bg(colors.overlay)
.border_1()
.border_color(colors.border)
.shadow(theme.elevation(Elevation::Floating))
.text_size(theme.text_size(TextSize::Sm))
.when_some(dismiss, |list, dismiss| {
list.on_mouse_down_out(move |_, window, cx| dismiss(window, cx))
})
.children(rows.iter().enumerate().map(|(ix, row)| {
let pick = pick.clone();
div()
.id(("suggestion", ix))
.flex()
.items_center()
.justify_between()
.gap_4()
.px_2()
.py_1()
.rounded(theme.radius(Radius::Md))
.text_color(colors.fg)
.when(ix == highlighted, |row| row.bg(colors.hover))
.hover(|style| style.bg(colors.hover))
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
pick(ix, window, cx);
})
.child(row.label.clone())
.when_some(row.note.clone(), |row, note| {
row.child(div().text_color(colors.fg_subtle).child(note))
})
}));
deferred(
anchored()
.position(point(anchor.left(), anchor.bottom()))
.child(div().mt_1().child(list)),
)
.with_priority(1)
.into_any_element()
}
+192
View File
@@ -0,0 +1,192 @@
use std::rc::Rc;
use gpui::{
App, Context, ElementId, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
RenderOnce, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div,
prelude::*,
};
use super::{InputEvent, TextInput, text::Backspace};
use crate::{
primitives::{Icon, IconName},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
};
type OnChange = Rc<dyn Fn(Vec<SharedString>, &mut Window, &mut App)>;
struct Tags {
input: Entity<TextInput>,
tags: Vec<SharedString>,
on_change: Option<OnChange>,
_events: Subscription,
}
impl Tags {
/// Adds the field's text as a tag, unless blank or already there.
fn take(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let typed = self
.input
.read(cx)
.text()
.trim()
.trim_end_matches(',')
.trim()
.to_string();
self.input.update(cx, |input, cx| input.set_text("", cx));
if typed.is_empty() || self.tags.iter().any(|tag| tag.as_ref() == typed) {
return;
}
let mut next = self.tags.clone();
next.push(typed.into());
log::info!("tag input: {} tags", next.len());
if let Some(on_change) = self.on_change.clone() {
on_change(next, window, cx);
}
}
}
/// A chip per tag, then a field. Enter or a comma adds; Backspace on empty drops the last.
#[derive(IntoElement)]
pub struct TagInput {
id: ElementId,
tags: Vec<SharedString>,
placeholder: SharedString,
on_change: Option<OnChange>,
}
impl TagInput {
pub fn new(
id: impl Into<ElementId>,
tags: impl IntoIterator<Item = impl Into<SharedString>>,
) -> Self {
Self {
id: id.into(),
tags: tags.into_iter().map(Into::into).collect(),
placeholder: "Add a tag".into(),
on_change: None,
}
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
self.placeholder = text.into();
self
}
pub fn on_change(
mut self,
handler: impl Fn(Vec<SharedString>, &mut Window, &mut App) + 'static,
) -> Self {
self.on_change = Some(Rc::new(handler));
self
}
}
impl RenderOnce for TagInput {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let placeholder = self.placeholder.clone();
let state =
window.use_keyed_state(self.id.clone(), cx, |window, cx: &mut Context<Tags>| {
let input = cx.new(|cx| TextInput::new(window, cx).placeholder(placeholder));
let events = cx.subscribe_in(&input, window, |tags, input, event, window, cx| {
let comma =
*event == InputEvent::Changed && input.read(cx).text().ends_with(',');
if comma || *event == InputEvent::Submit {
tags.take(window, cx);
}
});
Tags {
input,
tags: Vec::new(),
on_change: None,
_events: events,
}
});
state.update(cx, |tags, _| {
tags.tags = self.tags.clone();
tags.on_change = self.on_change.clone();
});
let input = state.read(cx).input.clone();
let focused = input.read(cx).focus().is_focused(window);
let focus = input.read(cx).focus().clone();
let theme = cx.theme();
let colors = &theme.colors;
let chips = self.tags.iter().enumerate().map(|(ix, tag)| {
let (remove, tags, on_change) = (ix, self.tags.clone(), self.on_change.clone());
div()
.flex()
.items_center()
.gap_1()
.pl_2()
.pr_1()
.h(theme.control_height(ControlSize::Sm))
.rounded(theme.radius(Radius::Sm))
.bg(colors.hover)
.text_size(theme.text_size(TextSize::Sm))
.child(tag.clone())
.child(
div()
.id(("tag-remove", ix))
.rounded(theme.radius(Radius::Sm))
.cursor_pointer()
.hover(|style| style.bg(colors.active))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
let mut next = tags.clone();
next.remove(remove);
log::info!("tag input: removed one, {} left", next.len());
if let Some(on_change) = &on_change {
on_change(next, window, cx);
}
})
.child(
Icon::new(IconName::X)
.size(IconSize::Xs)
.color(colors.fg_muted),
),
)
});
let (tags, on_change, empty_check) =
(self.tags.clone(), self.on_change.clone(), input.clone());
div()
.id(self.id)
.flex()
.flex_wrap()
.items_center()
.gap_1()
.w_full()
.min_h(theme.control_height(ControlSize::Md))
.px_1()
.py_0p5()
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(if focused {
colors.focus
} else {
colors.border_strong
})
.bg(colors.surface)
.text_size(theme.text_size(TextSize::Base))
.text_color(colors.fg)
.on_mouse_down(MouseButton::Left, move |_, window, _| window.focus(&focus))
.capture_action(move |_: &Backspace, window, cx| {
if !empty_check.read(cx).is_empty() || tags.is_empty() {
return;
}
cx.stop_propagation();
let mut next = tags.clone();
next.pop();
log::info!("tag input: backspace dropped the last tag");
if let Some(on_change) = &on_change {
on_change(next, window, cx);
}
})
.children(chips)
.child(
div()
.flex_1()
.min_w(theme.pane_min() / 2.0)
.px_1()
.child(input),
)
}
}
+254
View File
@@ -0,0 +1,254 @@
use gpui::{
AppContext as _, Context, Entity, EntityInputHandler, IntoElement, KeyUpEvent, Keystroke,
ParentElement, Render, TestAppContext, VisualTestContext, Window, div,
};
use super::{Input, MaskedInput, NumberInput, PinInput, TextInput, bind_keys};
use crate::theme::Theme;
struct Fields {
fields: Vec<Entity<TextInput>>,
clearable: bool,
}
impl Render for Fields {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
div().children(self.fields.iter().map(|field| {
let input = Input::new(field);
if self.clearable {
input.clearable()
} else {
input
}
}))
}
}
fn setup(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
bind_keys(cx);
});
}
fn open(
count: usize,
clearable: bool,
cx: &mut TestAppContext,
) -> (Vec<Entity<TextInput>>, &mut VisualTestContext) {
open_with(count, clearable, |input| input, cx)
}
fn open_with(
count: usize,
clearable: bool,
build: impl Fn(TextInput) -> TextInput + 'static,
cx: &mut TestAppContext,
) -> (Vec<Entity<TextInput>>, &mut VisualTestContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|window, cx| Fields {
fields: (0..count)
.map(|_| cx.new(|cx| build(TextInput::new(window, cx))))
.collect(),
clearable,
});
let fields = view.read_with(cx, |view, _| view.fields.clone());
(fields, cx)
}
fn text(field: &Entity<TextInput>, cx: &mut VisualTestContext) -> String {
field.read_with(cx, |input, _| input.text().to_string())
}
fn focus(field: &Entity<TextInput>, cx: &mut VisualTestContext) {
cx.update(|window, cx| window.focus(&field.read(cx).focus().clone()));
}
#[gpui::test]
fn tab_reaches_fields_and_skips_disabled_ones(cx: &mut TestAppContext) {
let (fields, cx) = open(3, false, cx);
fields[1].update(cx, |input, cx| input.set_disabled(true, cx));
cx.run_until_parked();
let mut order = Vec::new();
for _ in 0..2 {
order.push(cx.update(|window, cx| {
window.focus_next();
fields
.iter()
.position(|field| field.read(cx).focus().is_focused(window))
}));
}
assert_eq!(order, [Some(0), Some(2)]);
}
#[gpui::test]
fn enter_on_a_clear_button_clears_its_own_field(cx: &mut TestAppContext) {
let (fields, cx) = open(2, true, cx);
for field in &fields {
field.update(cx, |input, cx| input.set_text("kept", cx));
}
cx.run_until_parked();
cx.update(|window, _| {
window.focus_next();
window.focus_next();
});
cx.run_until_parked();
cx.simulate_keystrokes("enter");
cx.simulate_event(KeyUpEvent {
keystroke: Keystroke::parse("enter").unwrap(),
});
assert_eq!([text(&fields[0], cx), text(&fields[1], cx)], ["", "kept"]);
}
#[gpui::test]
fn a_composition_undoes_in_one_step(cx: &mut TestAppContext) {
let (fields, cx) = open(1, false, cx);
let field = &fields[0];
focus(field, cx);
cx.update(|window, cx| {
field.update(cx, |input, cx| {
input.replace_and_mark_text_in_range(None, "n", None, window, cx);
input.replace_and_mark_text_in_range(None, "ni", None, window, cx);
input.replace_text_in_range(None, "你", window, cx);
})
});
assert_eq!(text(field, cx), "你");
cx.simulate_keystrokes("cmd-z");
assert_eq!(text(field, cx), "");
}
#[gpui::test]
fn a_committed_composition_obeys_the_filter_and_length(cx: &mut TestAppContext) {
let (fields, cx) = open_with(
1,
false,
|input| input.filter(|ch| ch.is_ascii_digit()).max_len(1),
cx,
);
let field = &fields[0];
focus(field, cx);
cx.update(|window, cx| {
field.update(cx, |input, cx| {
input.replace_and_mark_text_in_range(None, "12a", None, window, cx);
input.unmark_text(window, cx);
})
});
assert_eq!(text(field, cx), "1");
cx.simulate_keystrokes("cmd-z");
assert_eq!(text(field, cx), "");
}
#[gpui::test]
fn undo_leaves_a_disabled_field_alone(cx: &mut TestAppContext) {
let (fields, cx) = open(1, false, cx);
let field = &fields[0];
focus(field, cx);
cx.simulate_input("a");
field.update(cx, |input, cx| input.set_disabled(true, cx));
cx.simulate_keystrokes("cmd-z");
assert_eq!(text(field, cx), "a");
}
struct Stepped {
value: f64,
}
impl Render for Stepped {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
NumberInput::new("stepped", self.value).on_change(move |value, _, cx| {
view.update(cx, |view, cx| {
view.value = value;
cx.notify();
})
})
}
}
#[gpui::test]
fn stepping_starts_from_the_typed_number(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Stepped { value: 4.0 });
cx.update(|window, _| window.focus_next());
cx.simulate_keystrokes("cmd-a");
cx.simulate_input("20");
cx.simulate_keystrokes("up");
assert_eq!(view.read_with(cx, |view, _| view.value), 21.0);
}
struct Pin {
code: String,
}
impl Render for Pin {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let view = cx.entity();
PinInput::new("pin", 4).on_complete(move |code, _, cx| {
let code = code.to_string();
view.update(cx, |view, _| view.code = code);
})
}
}
#[gpui::test]
fn select_all_then_typing_replaces_the_pin(cx: &mut TestAppContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|_, _| Pin {
code: String::new(),
});
cx.update(|window, _| window.focus_next());
cx.simulate_input("4821");
cx.simulate_keystrokes("cmd-a");
cx.simulate_input("1234");
assert_eq!(view.read_with(cx, |view, _| view.code.clone()), "1234");
}
struct Masked {
state: Entity<TextInput>,
mask: &'static str,
}
impl Render for Masked {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
MaskedInput::new("masked", &self.state, self.mask)
}
}
fn masked<'a>(
mask: &'static str,
cx: &'a mut TestAppContext,
) -> (Entity<TextInput>, &'a mut VisualTestContext) {
setup(cx);
let (view, cx) = cx.add_window_view(|window, cx| Masked {
state: cx.new(|cx| TextInput::new(window, cx)),
mask,
});
let state = view.read_with(cx, |view, _| view.state.clone());
focus(&state, cx);
(state, cx)
}
#[gpui::test]
fn select_all_then_typing_refits_a_masked_field(cx: &mut TestAppContext) {
let (state, cx) = masked("1-999", cx);
cx.simulate_input("123");
assert_eq!(text(&state, cx), "1-123");
cx.simulate_keystrokes("cmd-a");
cx.simulate_input("1");
assert_eq!(text(&state, cx), "1-1");
}
#[gpui::test]
fn a_composition_inside_a_masked_field_fits_from_where_it_began(cx: &mut TestAppContext) {
let (state, cx) = masked("(999) 999", cx);
cx.simulate_input("123456");
assert_eq!(text(&state, cx), "(123) 456");
cx.update(|window, cx| {
state.update(cx, |input, cx| {
input.select(2..2, cx);
input.replace_and_mark_text_in_range(None, "9", None, window, cx);
input.replace_text_in_range(None, "9", window, cx);
})
});
assert_eq!(text(&state, cx), "(192) 345");
}
+391
View File
@@ -0,0 +1,391 @@
use gpui::{
App, ClipboardItem, Context, CursorStyle, InteractiveElement, IntoElement, KeyBinding,
MouseButton, MouseDownEvent, MouseMoveEvent, ParentElement, Render, Styled, Window, actions,
div,
};
use super::{
InputEvent, TextInput,
edit::{self, next_grapheme, prev_grapheme},
element::TextElement,
};
actions!(
ely_input,
[
Backspace,
Delete,
DeleteWordLeft,
Left,
Right,
Up,
Down,
SelectLeft,
SelectRight,
SelectUp,
SelectDown,
WordLeft,
WordRight,
SelectWordLeft,
SelectWordRight,
Home,
End,
SelectHome,
SelectEnd,
SelectAll,
Copy,
Cut,
Paste,
Undo,
Redo,
Enter,
Submit,
ShowCharacterPalette,
]
);
const CONTEXT: &str = "ElyInput";
/// Binds the text keys. `init` calls it.
pub(crate) fn bind_keys(cx: &mut App) {
let word = if cfg!(target_os = "macos") {
"alt"
} else {
"ctrl"
};
let mut bindings = vec![
KeyBinding::new("backspace", Backspace, Some(CONTEXT)),
KeyBinding::new("delete", Delete, Some(CONTEXT)),
KeyBinding::new(&format!("{word}-backspace"), DeleteWordLeft, Some(CONTEXT)),
KeyBinding::new("left", Left, Some(CONTEXT)),
KeyBinding::new("right", Right, Some(CONTEXT)),
KeyBinding::new("up", Up, Some(CONTEXT)),
KeyBinding::new("down", Down, Some(CONTEXT)),
KeyBinding::new("shift-left", SelectLeft, Some(CONTEXT)),
KeyBinding::new("shift-right", SelectRight, Some(CONTEXT)),
KeyBinding::new("shift-up", SelectUp, Some(CONTEXT)),
KeyBinding::new("shift-down", SelectDown, Some(CONTEXT)),
KeyBinding::new(&format!("{word}-left"), WordLeft, Some(CONTEXT)),
KeyBinding::new(&format!("{word}-right"), WordRight, Some(CONTEXT)),
KeyBinding::new(&format!("{word}-shift-left"), SelectWordLeft, Some(CONTEXT)),
KeyBinding::new(
&format!("{word}-shift-right"),
SelectWordRight,
Some(CONTEXT),
),
KeyBinding::new("home", Home, Some(CONTEXT)),
KeyBinding::new("end", End, Some(CONTEXT)),
KeyBinding::new("shift-home", SelectHome, Some(CONTEXT)),
KeyBinding::new("shift-end", SelectEnd, Some(CONTEXT)),
KeyBinding::new("secondary-a", SelectAll, Some(CONTEXT)),
KeyBinding::new("secondary-c", Copy, Some(CONTEXT)),
KeyBinding::new("secondary-x", Cut, Some(CONTEXT)),
KeyBinding::new("secondary-v", Paste, Some(CONTEXT)),
KeyBinding::new("secondary-z", Undo, Some(CONTEXT)),
KeyBinding::new("secondary-shift-z", Redo, Some(CONTEXT)),
KeyBinding::new("enter", Enter, Some(CONTEXT)),
KeyBinding::new("secondary-enter", Submit, Some(CONTEXT)),
];
if cfg!(target_os = "macos") {
bindings.extend([
KeyBinding::new("cmd-left", Home, Some(CONTEXT)),
KeyBinding::new("cmd-right", End, Some(CONTEXT)),
KeyBinding::new("cmd-shift-left", SelectHome, Some(CONTEXT)),
KeyBinding::new("cmd-shift-right", SelectEnd, Some(CONTEXT)),
KeyBinding::new("ctrl-cmd-space", ShowCharacterPalette, Some(CONTEXT)),
]);
} else {
bindings.push(KeyBinding::new("ctrl-y", Redo, Some(CONTEXT)));
}
cx.bind_keys(bindings);
}
impl TextInput {
fn backspace(&mut self, _: &Backspace, _: &mut Window, cx: &mut Context<Self>) {
let range = if self.selection.is_empty() {
prev_grapheme(&self.text, self.cursor())..self.cursor()
} else {
self.selection.clone()
};
self.replace(range, "", false, cx);
}
fn delete(&mut self, _: &Delete, _: &mut Window, cx: &mut Context<Self>) {
let range = if self.selection.is_empty() {
self.cursor()..next_grapheme(&self.text, self.cursor())
} else {
self.selection.clone()
};
self.replace(range, "", false, cx);
}
fn delete_word_left(&mut self, _: &DeleteWordLeft, _: &mut Window, cx: &mut Context<Self>) {
let range = if self.selection.is_empty() {
edit::prev_word(&self.text, self.cursor())..self.cursor()
} else {
self.selection.clone()
};
self.replace(range, "", false, cx);
}
fn left(&mut self, _: &Left, _: &mut Window, cx: &mut Context<Self>) {
let to = if self.selection.is_empty() {
prev_grapheme(&self.text, self.cursor())
} else {
self.selection.start
};
self.move_to(to, cx);
}
fn right(&mut self, _: &Right, _: &mut Window, cx: &mut Context<Self>) {
let to = if self.selection.is_empty() {
next_grapheme(&self.text, self.cursor())
} else {
self.selection.end
};
self.move_to(to, cx);
}
/// The offset one visual line above or below the caret.
fn vertical(&self, down: bool) -> usize {
let caret = self.cursor();
let (Some(layout), Some(at)) = (
self.layout.as_ref(),
self.position_for(self.display_offset(caret)),
) else {
return caret;
};
let step = if down {
layout.line_height
} else {
-layout.line_height
};
let target = gpui::point(at.x, at.y + step + layout.line_height / 2.0);
if target.y < gpui::Pixels::ZERO {
return 0;
}
self.offset_for_point(layout.bounds.origin + target - self.scroll)
}
fn up(&mut self, _: &Up, _: &mut Window, cx: &mut Context<Self>) {
let to = self.vertical(false);
self.move_to(to, cx);
}
fn down(&mut self, _: &Down, _: &mut Window, cx: &mut Context<Self>) {
let to = if self.rows.is_some() {
self.vertical(true)
} else {
self.text.len()
};
self.move_to(to, cx);
}
fn select_left(&mut self, _: &SelectLeft, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(prev_grapheme(&self.text, self.cursor()), cx);
}
fn select_right(&mut self, _: &SelectRight, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(next_grapheme(&self.text, self.cursor()), cx);
}
fn select_up(&mut self, _: &SelectUp, _: &mut Window, cx: &mut Context<Self>) {
let to = self.vertical(false);
self.select_to(to, cx);
}
fn select_down(&mut self, _: &SelectDown, _: &mut Window, cx: &mut Context<Self>) {
let to = self.vertical(true);
self.select_to(to, cx);
}
fn word_left(&mut self, _: &WordLeft, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(edit::prev_word(&self.text, self.cursor()), cx);
}
fn word_right(&mut self, _: &WordRight, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(edit::next_word(&self.text, self.cursor()), cx);
}
fn select_word_left(&mut self, _: &SelectWordLeft, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(edit::prev_word(&self.text, self.cursor()), cx);
}
fn select_word_right(&mut self, _: &SelectWordRight, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(edit::next_word(&self.text, self.cursor()), cx);
}
fn home(&mut self, _: &Home, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(edit::line_start(&self.text, self.cursor()), cx);
}
fn end(&mut self, _: &End, _: &mut Window, cx: &mut Context<Self>) {
self.move_to(edit::line_end(&self.text, self.cursor()), cx);
}
fn select_home(&mut self, _: &SelectHome, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(edit::line_start(&self.text, self.cursor()), cx);
}
fn select_end(&mut self, _: &SelectEnd, _: &mut Window, cx: &mut Context<Self>) {
self.select_to(edit::line_end(&self.text, self.cursor()), cx);
}
fn select_all(&mut self, _: &SelectAll, _: &mut Window, cx: &mut Context<Self>) {
self.selection = 0..self.text.len();
self.reversed = false;
cx.notify();
}
fn copy(&mut self, _: &Copy, _: &mut Window, cx: &mut Context<Self>) {
if self.masked || self.selection.is_empty() {
return;
}
cx.write_to_clipboard(ClipboardItem::new_string(
self.text[self.selection.clone()].to_string(),
));
}
fn cut(&mut self, _: &Cut, _: &mut Window, cx: &mut Context<Self>) {
if self.masked || self.selection.is_empty() {
return;
}
cx.write_to_clipboard(ClipboardItem::new_string(
self.text[self.selection.clone()].to_string(),
));
self.replace(self.selection.clone(), "", false, cx);
}
fn paste(&mut self, _: &Paste, _: &mut Window, cx: &mut Context<Self>) {
let Some(text) = cx.read_from_clipboard().and_then(|item| item.text()) else {
return;
};
self.replace(self.selection.clone(), &text, false, cx);
}
fn undo(&mut self, _: &Undo, _: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
if let Some(previous) = self.history.undo(self.snapshot()) {
self.restore(previous, cx);
}
}
fn redo(&mut self, _: &Redo, _: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
if let Some(next) = self.history.redo(self.snapshot()) {
self.restore(next, cx);
}
}
fn enter(&mut self, _: &Enter, _: &mut Window, cx: &mut Context<Self>) {
if self.rows.is_some() {
self.replace(self.selection.clone(), "\n", false, cx);
} else {
log::info!("text input: submitted");
cx.emit(InputEvent::Submit);
}
}
fn submit(&mut self, _: &Submit, _: &mut Window, cx: &mut Context<Self>) {
log::info!("text input: submitted");
cx.emit(InputEvent::Submit);
}
fn show_character_palette(
&mut self,
_: &ShowCharacterPalette,
window: &mut Window,
_: &mut Context<Self>,
) {
window.show_character_palette();
}
fn mouse_down(&mut self, event: &MouseDownEvent, window: &mut Window, cx: &mut Context<Self>) {
if self.disabled {
return;
}
window.focus(&self.focus);
let at = self.offset_for_point(event.position);
match event.click_count {
2 => {
let word = edit::word_at(&self.text, at);
self.selection = word;
self.reversed = false;
cx.notify();
}
3.. => {
self.selection = edit::line_start(&self.text, at)..edit::line_end(&self.text, at);
self.reversed = false;
cx.notify();
}
_ if event.modifiers.shift => self.select_to(at, cx),
_ => {
self.selecting = true;
self.move_to(at, cx);
}
}
}
fn mouse_move(&mut self, event: &MouseMoveEvent, _: &mut Window, cx: &mut Context<Self>) {
if self.selecting && event.dragging() {
let at = self.offset_for_point(event.position);
self.select_to(at, cx);
}
}
fn mouse_up(&mut self, _: &gpui::MouseUpEvent, _: &mut Window, _: &mut Context<Self>) {
self.selecting = false;
}
}
impl Render for TextInput {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
div()
.id("ely-text-input")
.key_context(CONTEXT)
.track_focus(&self.focus)
.w_full()
.cursor(if self.disabled {
CursorStyle::OperationNotAllowed
} else {
CursorStyle::IBeam
})
.on_action(cx.listener(Self::backspace))
.on_action(cx.listener(Self::delete))
.on_action(cx.listener(Self::delete_word_left))
.on_action(cx.listener(Self::left))
.on_action(cx.listener(Self::right))
.on_action(cx.listener(Self::up))
.on_action(cx.listener(Self::down))
.on_action(cx.listener(Self::select_left))
.on_action(cx.listener(Self::select_right))
.on_action(cx.listener(Self::select_up))
.on_action(cx.listener(Self::select_down))
.on_action(cx.listener(Self::word_left))
.on_action(cx.listener(Self::word_right))
.on_action(cx.listener(Self::select_word_left))
.on_action(cx.listener(Self::select_word_right))
.on_action(cx.listener(Self::home))
.on_action(cx.listener(Self::end))
.on_action(cx.listener(Self::select_home))
.on_action(cx.listener(Self::select_end))
.on_action(cx.listener(Self::select_all))
.on_action(cx.listener(Self::copy))
.on_action(cx.listener(Self::cut))
.on_action(cx.listener(Self::paste))
.on_action(cx.listener(Self::undo))
.on_action(cx.listener(Self::redo))
.on_action(cx.listener(Self::enter))
.on_action(cx.listener(Self::submit))
.on_action(cx.listener(Self::show_character_palette))
.on_mouse_down(MouseButton::Left, cx.listener(Self::mouse_down))
.on_mouse_move(cx.listener(Self::mouse_move))
.on_mouse_up(MouseButton::Left, cx.listener(Self::mouse_up))
.on_mouse_up_out(MouseButton::Left, cx.listener(Self::mouse_up))
.child(TextElement::new(cx.entity()))
}
}
+189
View File
@@ -0,0 +1,189 @@
use std::{
ops::Range,
time::{Duration, Instant},
};
use unicode_segmentation::UnicodeSegmentation;
pub(crate) fn prev_grapheme(text: &str, offset: usize) -> usize {
text.grapheme_indices(true)
.rev()
.find_map(|(ix, _)| (ix < offset).then_some(ix))
.unwrap_or(0)
}
pub(crate) fn next_grapheme(text: &str, offset: usize) -> usize {
text.grapheme_indices(true)
.find_map(|(ix, _)| (ix > offset).then_some(ix))
.unwrap_or(text.len())
}
fn words(text: &str) -> impl DoubleEndedIterator<Item = (usize, &str)> {
text.split_word_bound_indices()
.filter(|(_, word)| !word.trim().is_empty())
}
/// Start of the word before `offset`.
pub(crate) fn prev_word(text: &str, offset: usize) -> usize {
words(text)
.rev()
.find_map(|(ix, _)| (ix < offset).then_some(ix))
.unwrap_or(0)
}
/// End of the word after `offset`.
pub(crate) fn next_word(text: &str, offset: usize) -> usize {
words(text)
.find_map(|(ix, word)| (ix + word.len() > offset).then_some(ix + word.len()))
.unwrap_or(text.len())
}
/// The word around `offset`, for double-click selection.
pub(crate) fn word_at(text: &str, offset: usize) -> Range<usize> {
text.split_word_bound_indices()
.find(|(ix, word)| *ix <= offset && offset < ix + word.len())
.map(|(ix, word)| ix..ix + word.len())
.unwrap_or(offset..offset)
}
pub(crate) fn line_start(text: &str, offset: usize) -> usize {
text[..offset].rfind('\n').map_or(0, |ix| ix + 1)
}
pub(crate) fn line_end(text: &str, offset: usize) -> usize {
text[offset..]
.find('\n')
.map_or(text.len(), |ix| offset + ix)
}
pub(crate) fn to_utf16(text: &str, offset: usize) -> usize {
text[..offset].chars().map(char::len_utf16).sum()
}
pub(crate) fn from_utf16(text: &str, offset: usize) -> usize {
let mut units = 0;
for (ix, ch) in text.char_indices() {
if units >= offset {
return ix;
}
units += ch.len_utf16();
}
text.len()
}
/// Display offset of a content offset when each grapheme shows as `bullet`.
pub(crate) fn masked_offset(text: &str, offset: usize, bullet: char) -> usize {
text[..offset].graphemes(true).count() * bullet.len_utf8()
}
/// Content offset of a display offset in masked text.
pub(crate) fn unmasked_offset(text: &str, display: usize, bullet: char) -> usize {
let count = display / bullet.len_utf8();
text.grapheme_indices(true)
.nth(count)
.map_or(text.len(), |(ix, _)| ix)
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Snapshot {
pub text: String,
pub selection: Range<usize>,
}
/// Typing within this pause joins one undo step.
const JOIN: Duration = Duration::from_millis(900);
/// Undo and redo as whole-text snapshots.
#[derive(Default)]
pub(crate) struct History {
undo: Vec<Snapshot>,
redo: Vec<Snapshot>,
typing_since: Option<Instant>,
}
impl History {
/// Records the state before an edit. Typing in one burst makes one step.
pub fn record(&mut self, before: Snapshot, typing: bool) {
let joins = typing && self.typing_since.is_some_and(|at| at.elapsed() < JOIN);
if !joins {
self.undo.push(before);
}
self.typing_since = typing.then(Instant::now);
self.redo.clear();
}
pub fn undo(&mut self, current: Snapshot) -> Option<Snapshot> {
let previous = self.undo.pop()?;
self.redo.push(current);
self.typing_since = None;
Some(previous)
}
pub fn redo(&mut self, current: Snapshot) -> Option<Snapshot> {
let next = self.redo.pop()?;
self.undo.push(current);
self.typing_since = None;
Some(next)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn graphemes_keep_clusters_whole() {
let text = "ae\u{301}🇯🇵z";
assert_eq!(next_grapheme(text, 0), 1);
assert_eq!(next_grapheme(text, 1), 4);
assert_eq!(next_grapheme(text, 4), 12);
assert_eq!(prev_grapheme(text, 12), 4);
assert_eq!(prev_grapheme(text, 0), 0);
}
#[test]
fn words_skip_spaces_and_punctuation_stops() {
let text = "hello brave, new";
assert_eq!(next_word(text, 0), 5);
assert_eq!(next_word(text, 5), 12);
assert_eq!(prev_word(text, 17), 14);
assert_eq!(prev_word(text, 14), 12);
assert_eq!(word_at(text, 8), 7..12);
}
#[test]
fn lines_and_utf16_round_trip() {
let text = "one\ntwo😀\nthree";
assert_eq!(line_start(text, 6), 4);
assert_eq!(line_end(text, 6), 11);
let emoji_end = 11;
assert_eq!(to_utf16(text, emoji_end), 9);
assert_eq!(from_utf16(text, 9), emoji_end);
}
#[test]
fn masking_maps_graphemes_to_bullets() {
let text = "pa😀s";
assert_eq!(masked_offset(text, 6, '•'), 9);
assert_eq!(unmasked_offset(text, 9, '•'), 6);
assert_eq!(unmasked_offset(text, 12, '•'), text.len());
}
#[test]
fn typing_joins_one_step_and_redo_clears_on_edit() {
let snap = |text: &str| Snapshot {
text: text.into(),
selection: text.len()..text.len(),
};
let mut history = History::default();
history.record(snap(""), true);
history.record(snap("a"), true);
history.record(snap("ab"), false);
assert_eq!(history.undo(snap("")).unwrap(), snap("ab"));
assert_eq!(history.undo(snap("ab")).unwrap(), snap(""));
assert!(history.undo(snap("")).is_none());
assert_eq!(history.redo(snap("")).unwrap(), snap("ab"));
history.record(snap("ab"), false);
assert!(history.redo(snap("abc")).is_none());
}
}
+410
View File
@@ -0,0 +1,410 @@
use gpui::{
App, AvailableSpace, Bounds, ContentMask, DispatchPhase, Element, ElementId,
ElementInputHandler, Entity, GlobalElementId, Hsla, InspectorElementId, IntoElement, LayoutId,
MouseMoveEvent, Pixels, Point, SharedString, Style, TextAlign, TextRun, UnderlineStyle, Window,
WrappedLine, fill, point, relative, size,
};
use super::{Layout, TextInput};
use crate::theme::ActiveTheme;
pub(crate) struct TextElement {
input: Entity<TextInput>,
}
impl TextElement {
pub fn new(input: Entity<TextInput>) -> Self {
Self { input }
}
}
impl IntoElement for TextElement {
type Element = Self;
fn into_element(self) -> Self::Element {
self
}
}
pub(crate) struct Prepaint {
lines: Vec<(usize, WrappedLine)>,
selections: Vec<Bounds<Pixels>>,
caret: Option<Bounds<Pixels>>,
selection_color: Hsla,
caret_color: Hsla,
}
/// What to show, and whether it is the placeholder.
fn shown(input: &TextInput) -> (String, bool) {
if input.is_empty() {
(input.placeholder_text().to_string(), true)
} else {
(input.display_text(), false)
}
}
fn plain_run(len: usize, window: &Window, color: Hsla) -> TextRun {
TextRun {
len,
font: window.text_style().font(),
color,
background_color: None,
underline: None,
strikethrough: None,
}
}
/// Splits `len` bytes into runs: highlights and the marked range over a base color.
fn runs(
len: usize,
base: TextRun,
spans: Vec<(std::ops::Range<usize>, Hsla, Option<Hsla>, bool)>,
thickness: Pixels,
) -> Vec<TextRun> {
let mut cuts: Vec<usize> = vec![0, len];
for (range, ..) in &spans {
cuts.extend([range.start.min(len), range.end.min(len)]);
}
cuts.sort_unstable();
cuts.dedup();
cuts.windows(2)
.map(|pair| {
let (start, end) = (pair[0], pair[1]);
let mut run = TextRun {
len: end - start,
..base.clone()
};
for (range, color, background, underline) in &spans {
if range.start <= start && end <= range.end {
run.color = *color;
if background.is_some() {
run.background_color = *background;
}
if *underline {
run.underline = Some(UnderlineStyle {
color: Some(*color),
thickness,
wavy: false,
});
}
}
}
run
})
.filter(|run| run.len > 0)
.collect()
}
/// Starts of the hard lines in `text`.
fn line_starts(text: &str) -> Vec<usize> {
std::iter::once(0)
.chain(text.match_indices('\n').map(|(ix, _)| ix + 1))
.collect()
}
impl Element for TextElement {
type RequestLayoutState = ();
type PrepaintState = Prepaint;
fn id(&self) -> Option<ElementId> {
None
}
fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
None
}
fn request_layout(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
window: &mut Window,
cx: &mut App,
) -> (LayoutId, ()) {
let input = self.input.read(cx);
let line_height = window.line_height();
let mut style = Style::default();
style.size.width = relative(1.).into();
let Some((min, max)) = input.rows() else {
style.size.height = line_height.into();
return (window.request_layout(style, [], cx), ());
};
let (text, _) = shown(input);
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
let run = plain_run(text.len(), window, Hsla::default());
let layout = window.request_measured_layout(style, move |known, available, window, _| {
let width = known.width.or(match available.width {
AvailableSpace::Definite(width) => Some(width),
_ => None,
});
let lines = window
.text_system()
.shape_text(
SharedString::from(text.clone()),
font_size,
std::slice::from_ref(&run),
width,
None,
)
.expect("text input shaping failed");
let rows: usize = lines
.iter()
.map(|line| line.wrap_boundaries().len() + 1)
.sum();
size(
width.unwrap_or_default(),
line_height * rows.clamp(min, max) as f32,
)
});
(layout, ())
}
fn prepaint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut (),
window: &mut Window,
cx: &mut App,
) -> Prepaint {
let theme = cx.theme();
let (selection_color, caret_color, subtle) = (
theme.colors.selection,
theme.colors.focus,
theme.colors.fg_subtle,
);
let caret_width = theme.caret_width().to_pixels(window.rem_size());
let thickness = theme.underline_thickness();
let input = self.input.read(cx);
let (text, placeholder) = shown(input);
let base = plain_run(
text.len(),
window,
if placeholder {
subtle
} else {
window.text_style().color
},
);
let mut spans = Vec::new();
if !placeholder {
for (range, highlight) in input.highlights(cx) {
spans.push((range, highlight.color, highlight.background, false));
}
if let Some(marked) = input.marked() {
let color = window.text_style().color;
let marked = input.display_offset(marked.start)..input.display_offset(marked.end);
spans.push((marked, color, None, true));
}
}
let font_size = window.text_style().font_size.to_pixels(window.rem_size());
let line_height = window.line_height();
let wrap = input.rows().map(|_| bounds.size.width);
let shaped = window
.text_system()
.shape_text(
text.clone().into(),
font_size,
&runs(text.len(), base, spans, thickness),
wrap,
None,
)
.expect("text input shaping failed");
let lines: Vec<(usize, WrappedLine)> = line_starts(&text).into_iter().zip(shaped).collect();
let focused = input.focus().is_focused(window);
let caret_on = input.caret_on();
self.input.update(cx, |input, _| {
input.layout = Some(Layout {
lines: lines.clone(),
bounds,
line_height,
placeholder,
});
});
let input = self.input.read(cx);
let caret_at = input
.position_for(input.display_offset(input.cursor()))
.expect("caret offset lies inside the laid-out text");
let scroll = keep_in_view(input, &lines, caret_at, caret_width, bounds, line_height);
let selection = input.selection();
let selections = if placeholder || selection.is_empty() {
Vec::new()
} else {
let (start, end) = (
input.display_offset(selection.start),
input.display_offset(selection.end),
);
selection_rects(&lines, start, end, line_height, bounds.size.width)
.into_iter()
.map(|rect| Bounds::new(rect.origin + bounds.origin - scroll, rect.size))
.collect()
};
let caret = (focused && caret_on && selection.is_empty()).then(|| {
Bounds::new(
bounds.origin + caret_at - scroll,
size(caret_width, line_height),
)
});
self.input.update(cx, |input, _| input.scroll = scroll);
Prepaint {
lines,
selections,
caret,
selection_color,
caret_color,
}
}
fn paint(
&mut self,
_: Option<&GlobalElementId>,
_: Option<&InspectorElementId>,
bounds: Bounds<Pixels>,
_: &mut (),
prepaint: &mut Prepaint,
window: &mut Window,
cx: &mut App,
) {
let input = self.input.read(cx);
let (focus, scroll, selecting) = (input.focus().clone(), input.scroll, input.selecting());
window.handle_input(
&focus,
ElementInputHandler::new(bounds, self.input.clone()),
cx,
);
let line_height = window.line_height();
window.with_content_mask(Some(ContentMask { bounds }), |window| {
for rect in prepaint.selections.drain(..) {
window.paint_quad(fill(rect, prepaint.selection_color));
}
let mut top = Pixels::ZERO;
for (_, line) in &prepaint.lines {
line.paint(
bounds.origin - scroll + point(Pixels::ZERO, top),
line_height,
TextAlign::Left,
None,
window,
cx,
)
.expect("text input paint failed");
top += line.size(line_height).height;
}
if let Some(caret) = prepaint.caret.take() {
window.paint_quad(fill(caret, prepaint.caret_color));
}
});
if selecting {
let input = self.input.clone();
window.on_mouse_event(move |event: &MouseMoveEvent, phase, _, cx| {
if phase == DispatchPhase::Bubble && event.dragging() {
input.update(cx, |input, cx| {
let at = input.offset_for_point(event.position);
input.select_to(at, cx);
});
}
});
}
}
}
/// Scroll that keeps the caret inside the box, clamped to the text.
fn keep_in_view(
input: &TextInput,
lines: &[(usize, WrappedLine)],
caret: Point<Pixels>,
caret_width: Pixels,
bounds: Bounds<Pixels>,
line_height: Pixels,
) -> Point<Pixels> {
let mut scroll = input.scroll;
if input.is_multi_line() {
let content = lines.iter().fold(Pixels::ZERO, |sum, (_, line)| {
sum + line.size(line_height).height
});
let view = bounds.size.height;
if caret.y < scroll.y {
scroll.y = caret.y;
} else if caret.y + line_height > scroll.y + view {
scroll.y = caret.y + line_height - view;
}
scroll.y = scroll
.y
.clamp(Pixels::ZERO, (content - view).max(Pixels::ZERO));
scroll.x = Pixels::ZERO;
} else {
let content = lines.first().map_or(Pixels::ZERO, |(_, line)| line.width()) + caret_width;
let view = bounds.size.width;
if caret.x < scroll.x {
scroll.x = caret.x;
} else if caret.x + caret_width > scroll.x + view {
scroll.x = caret.x + caret_width - view;
}
scroll.x = scroll
.x
.clamp(Pixels::ZERO, (content - view).max(Pixels::ZERO));
scroll.y = Pixels::ZERO;
}
scroll
}
/// Boxes covering display offsets `start..end`, relative to the text origin.
fn selection_rects(
lines: &[(usize, WrappedLine)],
start: usize,
end: usize,
line_height: Pixels,
width: Pixels,
) -> Vec<Bounds<Pixels>> {
let mut rects = Vec::new();
let mut top = Pixels::ZERO;
for (line_start, line) in lines {
let line_end = line_start + line.len();
let (from, to) = (start.max(*line_start), end.min(line_end));
if from <= to && start <= line_end && end >= *line_start {
let at = |ix: usize| {
line.position_for_index(ix - line_start, line_height)
.unwrap_or_default()
};
let (a, b) = (at(from), at(to));
let edge = if end > line_end { width } else { b.x };
if a.y == b.y {
rects.push(Bounds::from_corners(
point(a.x, top + a.y),
point(edge.max(a.x), top + a.y + line_height),
));
} else {
rects.push(Bounds::from_corners(
point(a.x, top + a.y),
point(width, top + a.y + line_height),
));
let mut y = a.y + line_height;
while y < b.y {
rects.push(Bounds::from_corners(
point(Pixels::ZERO, top + y),
point(width, top + y + line_height),
));
y += line_height;
}
rects.push(Bounds::from_corners(
point(Pixels::ZERO, top + b.y),
point(edge, top + b.y + line_height),
));
}
}
top += line.size(line_height).height;
}
rects
}
#[cfg(test)]
mod tests {
use super::line_starts;
#[test]
fn line_starts_follow_newlines() {
assert_eq!(line_starts("ab\ncd\n"), vec![0, 3, 6]);
assert_eq!(line_starts(""), vec![0]);
}
}
+139
View File
@@ -0,0 +1,139 @@
use std::ops::Range;
use gpui::{Bounds, Context, EntityInputHandler, Pixels, Point, UTF16Selection, Window};
use super::{
TextInput,
edit::{from_utf16, to_utf16},
};
impl TextInput {
fn range_from_utf16(&self, range: &Range<usize>) -> Range<usize> {
from_utf16(&self.text, range.start)..from_utf16(&self.text, range.end)
}
fn range_to_utf16(&self, range: &Range<usize>) -> Range<usize> {
to_utf16(&self.text, range.start)..to_utf16(&self.text, range.end)
}
}
impl EntityInputHandler for TextInput {
fn text_for_range(
&mut self,
range_utf16: Range<usize>,
actual_range: &mut Option<Range<usize>>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<String> {
let range = self.range_from_utf16(&range_utf16);
actual_range.replace(self.range_to_utf16(&range));
Some(self.text[range].to_string())
}
fn selected_text_range(
&mut self,
_: bool,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<UTF16Selection> {
Some(UTF16Selection {
range: self.range_to_utf16(&self.selection),
reversed: self.reversed,
})
}
fn marked_text_range(&self, _: &mut Window, _: &mut Context<Self>) -> Option<Range<usize>> {
self.marked.as_ref().map(|range| self.range_to_utf16(range))
}
fn unmark_text(&mut self, _: &mut Window, cx: &mut Context<Self>) {
let Some(marked) = self.marked.clone() else {
return;
};
let composed = self.text[marked.clone()].to_string();
self.replace(marked, &composed, true, cx);
}
fn replace_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
text: &str,
_: &mut Window,
cx: &mut Context<Self>,
) {
log::debug!(
"text input {:?}: replace {range_utf16:?} marked {:?} selection {:?} in {} bytes",
cx.entity_id(),
self.marked,
self.selection,
self.text.len()
);
let range = range_utf16
.map(|range| self.range_from_utf16(&range))
.or(self.marked.clone())
.unwrap_or(self.selection.clone());
self.replace(range, text, true, cx);
}
fn replace_and_mark_text_in_range(
&mut self,
range_utf16: Option<Range<usize>>,
text: &str,
selected_utf16: Option<Range<usize>>,
_: &mut Window,
cx: &mut Context<Self>,
) {
if self.disabled {
return;
}
let range = range_utf16
.map(|range| self.range_from_utf16(&range))
.or(self.marked.clone())
.unwrap_or(self.selection.clone());
if self.marked.is_none() {
self.composing = Some(self.snapshot());
}
self.text.replace_range(range.clone(), text);
self.marked = (!text.is_empty()).then(|| range.start..range.start + text.len());
if self.marked.is_none() {
self.composing = None;
}
self.selection = match selected_utf16 {
Some(inner) => {
let inner = from_utf16(text, inner.start)..from_utf16(text, inner.end);
range.start + inner.start..range.start + inner.end
}
None => range.start + text.len()..range.start + text.len(),
};
self.reversed = false;
cx.notify();
}
fn bounds_for_range(
&mut self,
range_utf16: Range<usize>,
bounds: Bounds<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<Bounds<Pixels>> {
let range = self.range_from_utf16(&range_utf16);
let line_height = self.layout.as_ref()?.line_height;
let start = self.position_for(self.display_offset(range.start))?;
let end = self.position_for(self.display_offset(range.end))?;
let origin = bounds.origin - self.scroll;
Some(Bounds::from_corners(
origin + start,
origin + gpui::point(end.x, end.y + line_height),
))
}
fn character_index_for_point(
&mut self,
point: Point<Pixels>,
_: &mut Window,
_: &mut Context<Self>,
) -> Option<usize> {
self.layout.as_ref()?;
Some(to_utf16(&self.text, self.offset_for_point(point)))
}
}
+496
View File
@@ -0,0 +1,496 @@
mod actions;
mod edit;
mod element;
mod ime;
use std::{ops::Range, rc::Rc, time::Duration};
use gpui::{
App, Bounds, Context, EventEmitter, FocusHandle, Focusable, Hsla, Pixels, Point, SharedString,
Subscription, Task, Window, WrappedLine,
};
pub(crate) use actions::{Backspace, Down, Enter, Up, bind_keys};
use edit::{History, Snapshot};
use crate::theme::ActiveTheme;
/// What a text input tells its owner.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum InputEvent {
Changed,
/// Enter in one line, or the submit key in several.
Submit,
Focus,
Blur,
}
/// Colors a span of the text: foreground, and an optional wash behind it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Highlight {
pub color: Hsla,
pub background: Option<Hsla>,
}
type Highlighter = Rc<dyn Fn(&str, &App) -> Vec<(Range<usize>, Highlight)>>;
type Filter = Rc<dyn Fn(char) -> bool>;
pub(crate) type Fit = Rc<dyn Fn(&str, Range<usize>, &str) -> (String, usize)>;
/// The last shaped text: one entry per hard line, with its display offset.
pub(crate) struct Layout {
pub lines: Vec<(usize, WrappedLine)>,
pub bounds: Bounds<Pixels>,
pub line_height: Pixels,
/// The lines show the placeholder, not content.
pub placeholder: bool,
}
const BULLET: char = '•';
const BLINK: Duration = Duration::from_millis(530);
/// Editable text: one line or many, with selection, undo, IME and clipboard.
pub struct TextInput {
focus: FocusHandle,
text: String,
placeholder: SharedString,
selection: Range<usize>,
reversed: bool,
marked: Option<Range<usize>>,
composing: Option<Snapshot>,
rows: Option<(usize, usize)>,
masked: bool,
filter: Option<Filter>,
fit: Option<Fit>,
max_len: Option<usize>,
highlighter: Option<Highlighter>,
disabled: bool,
history: History,
pub(crate) layout: Option<Layout>,
pub(crate) scroll: Point<Pixels>,
selecting: bool,
caret_on: bool,
blink_epoch: u64,
_blink: Option<Task<()>>,
_subscriptions: Vec<Subscription>,
}
impl EventEmitter<InputEvent> for TextInput {}
impl Focusable for TextInput {
fn focus_handle(&self, _: &App) -> FocusHandle {
self.focus.clone()
}
}
impl TextInput {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let focus = cx.focus_handle().tab_stop(true);
let subscriptions = vec![
cx.on_focus(&focus, window, |input, _, cx| {
input.restart_blink(cx);
cx.emit(InputEvent::Focus);
}),
cx.on_blur(&focus, window, |input, _, cx| {
input.selecting = false;
input._blink = None;
cx.emit(InputEvent::Blur);
cx.notify();
}),
];
Self {
focus,
text: String::new(),
placeholder: SharedString::default(),
selection: 0..0,
reversed: false,
marked: None,
composing: None,
rows: None,
masked: false,
filter: None,
fit: None,
max_len: None,
highlighter: None,
disabled: false,
history: History::default(),
layout: None,
scroll: Point::default(),
selecting: false,
caret_on: true,
blink_epoch: 0,
_blink: None,
_subscriptions: subscriptions,
}
}
/// Several lines: grows from `min` rows to `max`, then scrolls.
pub fn multi_line(mut self, min: usize, max: usize) -> Self {
assert!(0 < min && min <= max, "text rows {min}..={max} are empty");
self.rows = Some((min, max));
self
}
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
self.placeholder = text.into();
self
}
/// Shows bullets instead of the text, and keeps it off the clipboard.
pub fn masked(mut self) -> Self {
self.masked = true;
self
}
/// Drops typed or pasted characters `allow` rejects.
pub fn filter(mut self, allow: impl Fn(char) -> bool + 'static) -> Self {
self.filter = Some(Rc::new(allow));
self
}
/// Longest text, in characters.
pub fn max_len(mut self, len: usize) -> Self {
self.max_len = Some(len);
self
}
/// Colors spans of the text as it changes.
pub fn highlighter(
mut self,
highlight: impl Fn(&str, &App) -> Vec<(Range<usize>, Highlight)> + 'static,
) -> Self {
self.highlighter = Some(Rc::new(highlight));
self
}
pub fn text(&self) -> &str {
&self.text
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn is_masked(&self) -> bool {
self.masked
}
pub fn is_multi_line(&self) -> bool {
self.rows.is_some()
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
pub fn rows(&self) -> Option<(usize, usize)> {
self.rows
}
pub fn placeholder_text(&self) -> &SharedString {
&self.placeholder
}
pub fn selection(&self) -> Range<usize> {
self.selection.clone()
}
pub fn cursor(&self) -> usize {
if self.reversed {
self.selection.start
} else {
self.selection.end
}
}
pub fn set_placeholder(&mut self, text: impl Into<SharedString>, cx: &mut Context<Self>) {
self.placeholder = text.into();
cx.notify();
}
pub fn set_masked(&mut self, masked: bool, cx: &mut Context<Self>) {
self.masked = masked;
cx.notify();
}
pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
self.disabled = disabled;
self.focus = self.focus.clone().tab_stop(!disabled);
cx.notify();
}
/// Replaces all text and puts the caret at its end. Not an undo step.
pub fn set_text(&mut self, text: impl Into<String>, cx: &mut Context<Self>) {
let text = self.admit(&text.into(), 0);
let text = match &self.fit {
Some(fit) => fit(&self.text, 0..self.text.len(), &text).0,
None => text,
};
self.selection = text.len()..text.len();
self.text = text;
self.marked = None;
self.composing = None;
cx.emit(InputEvent::Changed);
cx.notify();
}
/// Reshapes every edit, as a mask does: text, replaced range and typed text in; text and caret out.
pub(crate) fn set_fit(&mut self, fit: Fit) {
self.fit = Some(fit);
}
/// Replaces the selection, as typing would. An undo step.
pub fn insert(&mut self, text: &str, cx: &mut Context<Self>) {
self.replace(self.selection.clone(), text, false, cx);
}
pub fn select(&mut self, range: Range<usize>, cx: &mut Context<Self>) {
assert!(
range.start <= range.end && range.end <= self.text.len(),
"selection {range:?} is outside {} bytes",
self.text.len()
);
self.selection = range;
self.reversed = false;
self.restart_blink(cx);
}
/// The caret's box in window coordinates, once laid out.
pub fn caret_bounds(&self) -> Option<Bounds<Pixels>> {
self.bounds_for(self.cursor())
}
/// An offset's box in the window, from the last layout; later offsets clamp to its end.
pub fn bounds_for(&self, offset: usize) -> Option<Bounds<Pixels>> {
let layout = self.layout.as_ref()?;
let laid = layout
.lines
.last()
.map_or(0, |(start, line)| start + line.len());
let at = self.position_for(self.display_offset(offset).min(laid))?;
Some(Bounds::new(
layout.bounds.origin + at - self.scroll,
gpui::size(Pixels::ZERO, layout.line_height),
))
}
/// Keeps what `filter` and `max_len` allow of `incoming`.
fn admit(&self, incoming: &str, keep: usize) -> String {
let allowed: String = incoming
.chars()
.filter(|ch| self.filter.as_ref().is_none_or(|allow| allow(*ch)))
.collect();
match self.max_len {
Some(max) => allowed.chars().take(max.saturating_sub(keep)).collect(),
None => allowed,
}
}
pub(crate) fn replace(
&mut self,
range: Range<usize>,
incoming: &str,
typing: bool,
cx: &mut Context<Self>,
) {
if self.disabled {
return;
}
let incoming = if self.rows.is_none() {
incoming.replace(['\n', '\r'], " ")
} else {
incoming.replace("\r\n", "\n")
};
let kept = self.text.chars().count() - self.text[range.clone()].chars().count();
let incoming = self.admit(&incoming, kept);
if incoming.is_empty() && range.is_empty() {
return;
}
let composed = self.composing.take();
let before = composed.clone().unwrap_or_else(|| self.snapshot());
let caret = match &self.fit {
Some(fit) => {
let (base, range) = match &composed {
Some(start) => (start.text.as_str(), start.selection.clone()),
None => (self.text.as_str(), range),
};
let (text, caret) = fit(base, range, &incoming);
self.text = text;
caret
}
None => {
self.text.replace_range(range.clone(), &incoming);
range.start + incoming.len()
}
};
self.selection = caret..caret;
self.reversed = false;
self.marked = None;
self.commit(before, typing, cx);
self.restart_blink(cx);
}
/// Makes `before` one undo step, if the text moved on from it.
fn commit(&mut self, before: Snapshot, typing: bool, cx: &mut Context<Self>) {
if before.text != self.text {
self.history.record(before, typing);
cx.emit(InputEvent::Changed);
}
}
fn snapshot(&self) -> Snapshot {
Snapshot {
text: self.text.clone(),
selection: self.selection.clone(),
}
}
pub(crate) fn restore(&mut self, snapshot: Snapshot, cx: &mut Context<Self>) {
self.text = snapshot.text;
self.selection = snapshot.selection;
self.reversed = false;
self.marked = None;
self.composing = None;
cx.emit(InputEvent::Changed);
self.restart_blink(cx);
}
pub(crate) fn move_to(&mut self, offset: usize, cx: &mut Context<Self>) {
self.selection = offset..offset;
self.reversed = false;
self.restart_blink(cx);
}
pub(crate) fn select_to(&mut self, offset: usize, cx: &mut Context<Self>) {
if self.reversed {
self.selection.start = offset;
} else {
self.selection.end = offset;
}
if self.selection.end < self.selection.start {
self.reversed = !self.reversed;
self.selection = self.selection.end..self.selection.start;
}
self.restart_blink(cx);
}
/// Shows the caret now and restarts its blink.
fn restart_blink(&mut self, cx: &mut Context<Self>) {
self.caret_on = true;
self.blink_epoch += 1;
let epoch = self.blink_epoch;
let still = cx.theme().reduced_motion;
self._blink = (!still).then(|| {
cx.spawn(async move |input, cx| {
loop {
cx.background_executor().timer(BLINK).await;
let alive = input.update(cx, |input, cx| {
if input.blink_epoch == epoch {
input.caret_on = !input.caret_on;
cx.notify();
}
});
if alive.is_err() {
return;
}
}
})
});
cx.notify();
}
/// What the element shows: bullets when masked.
pub(crate) fn display_text(&self) -> String {
if self.masked {
BULLET.to_string().repeat(self.grapheme_count())
} else {
self.text.clone()
}
}
fn grapheme_count(&self) -> usize {
unicode_segmentation::UnicodeSegmentation::graphemes(self.text.as_str(), true).count()
}
pub(crate) fn display_offset(&self, offset: usize) -> usize {
if self.masked {
edit::masked_offset(&self.text, offset, BULLET)
} else {
offset
}
}
pub(crate) fn content_offset(&self, display: usize) -> usize {
if self.masked {
edit::unmasked_offset(&self.text, display, BULLET)
} else {
display
}
}
/// Top-left of a display offset, relative to the text's origin.
pub(crate) fn position_for(&self, display: usize) -> Option<Point<Pixels>> {
let layout = self.layout.as_ref()?;
if layout.placeholder {
return Some(Point::default());
}
let mut top = Pixels::ZERO;
for (start, line) in &layout.lines {
if display <= start + line.len() {
let at = line.position_for_index(display - start, layout.line_height)?;
return Some(gpui::point(at.x, at.y + top));
}
top += line.size(layout.line_height).height;
}
None
}
/// The content offset nearest a window position.
pub(crate) fn offset_for_point(&self, point: Point<Pixels>) -> usize {
let Some(layout) = self.layout.as_ref() else {
return 0;
};
if layout.placeholder {
return 0;
}
let local = point - layout.bounds.origin + self.scroll;
if local.y < Pixels::ZERO {
return 0;
}
let mut top = Pixels::ZERO;
for (start, line) in &layout.lines {
let height = line.size(layout.line_height).height;
if local.y < top + height {
let inside = gpui::point(local.x.max(Pixels::ZERO), local.y - top);
let index = match line.closest_index_for_position(inside, layout.line_height) {
Ok(index) | Err(index) => index,
};
return self.content_offset(start + index.min(line.len()));
}
top += height;
}
self.text.len()
}
pub(crate) fn highlights(&self, cx: &App) -> Vec<(Range<usize>, Highlight)> {
match (&self.highlighter, self.masked) {
(Some(highlight), false) => highlight(&self.text, cx),
_ => Vec::new(),
}
}
pub(crate) fn marked(&self) -> Option<Range<usize>> {
self.marked.clone()
}
pub(crate) fn selecting(&self) -> bool {
self.selecting
}
pub(crate) fn caret_on(&self) -> bool {
self.caret_on
}
pub(crate) fn focus(&self) -> &FocusHandle {
&self.focus
}
}
+93
View File
@@ -0,0 +1,93 @@
use std::rc::Rc;
use gpui::{
App, ElementId, InteractiveElement, IntoElement, MouseButton, ParentElement, RenderOnce,
SharedString, StatefulInteractiveElement, Styled, Window, div,
};
use super::NumberInput;
use crate::theme::{ActiveTheme, Radius};
type OnUnit = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// A number with a unit you click through, such as px, % and em.
#[derive(IntoElement)]
pub struct UnitInput {
field: NumberInput,
units: Vec<SharedString>,
unit: SharedString,
on_unit: Option<OnUnit>,
}
impl UnitInput {
pub fn new(
id: impl Into<ElementId>,
value: f64,
units: impl IntoIterator<Item = impl Into<SharedString>>,
unit: impl Into<SharedString>,
) -> Self {
let units: Vec<SharedString> = units.into_iter().map(Into::into).collect();
let unit = unit.into();
assert!(units.contains(&unit), "unit {unit} is not among {units:?}");
Self {
field: NumberInput::new(id, value),
units,
unit,
on_unit: None,
}
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.field = self.field.range(min, max);
self
}
pub fn precision(mut self, places: usize) -> Self {
self.field = self.field.precision(places);
self
}
pub fn on_change(mut self, handler: impl Fn(f64, &mut Window, &mut App) + 'static) -> Self {
self.field = self.field.on_change(handler);
self
}
/// Runs with the next unit when the unit is clicked.
pub fn on_unit(
mut self,
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
) -> Self {
self.on_unit = Some(Rc::new(handler));
self
}
}
impl RenderOnce for UnitInput {
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let position = self
.units
.iter()
.position(|unit| *unit == self.unit)
.expect("checked at construction");
let next = self.units[(position + 1) % self.units.len()].clone();
let on_unit = self.on_unit;
let hover = theme.colors.hover;
let unit = div()
.id((self.field.id.clone(), "unit"))
.px_1p5()
.rounded(theme.radius(Radius::Sm))
.text_color(theme.colors.fg_muted)
.cursor_pointer()
.hover(|style| style.bg(hover))
.on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default())
.on_click(move |_, window, cx| {
log::info!("unit input: {next}");
if let Some(on_unit) = &on_unit {
on_unit(&next, window, cx);
}
})
.child(self.unit);
self.field.suffix(unit)
}
}