T06a forms text: text input core, fields, numbers, masks, pin, inline, groups, tags, pairs, path, regex, expression, hotkey, mention
This commit is contained in:
41 files changed
+6233
-45
No files matched your search
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user