Skip to content
Draft
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Cargo.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4107,6 +4107,14 @@ dependencies = [
"env_logger",
]

[[package]]
name = "text_validation"
version = "0.1.0"
dependencies = [
"eframe",
"env_logger",
]

[[package]]
name = "thiserror"
version = "1.0.66"
Expand Down
6 changes: 3 additions & 3 deletions crates/egui/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2038,7 +2038,7 @@ impl Ui {
/// No newlines (`\n`) allowed. Pressing enter key will result in the [`TextEdit`] losing focus (`response.lost_focus`).
///
/// See also [`TextEdit`].
pub fn text_edit_singleline<S: widgets::text_edit::TextBuffer>(
pub fn text_edit_singleline<S: widgets::text_edit::TextType>(
&mut self,
text: &mut S,
) -> Response {
Expand All @@ -2048,7 +2048,7 @@ impl Ui {
/// A [`TextEdit`] for multiple lines. Pressing enter key will create a new line.
///
/// See also [`TextEdit`].
pub fn text_edit_multiline<S: widgets::text_edit::TextBuffer>(
pub fn text_edit_multiline<S: widgets::text_edit::TextType>(
&mut self,
text: &mut S,
) -> Response {
Expand All @@ -2060,7 +2060,7 @@ impl Ui {
/// This will be multiline, monospace, and will insert tabs instead of moving focus.
///
/// See also [`TextEdit::code_editor`].
pub fn code_editor<S: widgets::text_edit::TextBuffer>(&mut self, text: &mut S) -> Response {
pub fn code_editor<S: widgets::text_edit::TextType>(&mut self, text: &mut S) -> Response {
self.add(TextEdit::multiline(text).code_editor())
}

Expand Down
2 changes: 1 addition & 1 deletion crates/egui/src/widgets/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ pub use self::{
separator::Separator,
slider::{Slider, SliderClamping, SliderOrientation},
spinner::Spinner,
text_edit::{TextBuffer, TextEdit},
text_edit::{TextBuffer, TextEdit, TextType},
};

// ----------------------------------------------------------------------------
Expand Down
99 changes: 68 additions & 31 deletions crates/egui/src/widgets/text_edit/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ use crate::{
vec2,
};

use super::{TextEditOutput, TextEditState};
use super::{TextEditOutput, TextEditState, text_type::TextType};

type LayouterFn<'t> = &'t mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley>;

Expand Down Expand Up @@ -65,8 +65,8 @@ type LayouterFn<'t> = &'t mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley
/// ## Other
/// The background color of a [`crate::TextEdit`] is [`crate::Visuals::text_edit_bg_color`] or can be set with [`crate::TextEdit::background_color`].
#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
pub struct TextEdit<'t> {
text: &'t mut dyn TextBuffer,
pub struct TextEdit<'t, Value: TextType = String> {
represents: &'t mut Value,
hint_text: WidgetText,
hint_text_font: Option<FontSelection>,
id: Option<Id>,
Expand All @@ -91,11 +91,13 @@ pub struct TextEdit<'t> {
background_color: Option<Color32>,
}

impl WidgetWithState for TextEdit<'_> {
impl<Value: TextType> WidgetWithState for TextEdit<'_, Value> {
type State = TextEditState;
}

impl TextEdit<'_> {
// This doesn't have to be a string.
// It's just to prevent having specify the generic type, as it's not used by these functions
impl TextEdit<'_, String> {
pub fn load_state(ctx: &Context, id: Id) -> Option<TextEditState> {
TextEditState::load(ctx, id)
}
Expand All @@ -105,21 +107,21 @@ impl TextEdit<'_> {
}
}

impl<'t> TextEdit<'t> {
impl<'t, Value: TextType> TextEdit<'t, Value> {
/// No newlines (`\n`) allowed. Pressing enter key will result in the [`TextEdit`] losing focus (`response.lost_focus`).
pub fn singleline(text: &'t mut dyn TextBuffer) -> Self {
pub fn singleline(value: &'t mut Value) -> Self {
Self {
desired_height_rows: 1,
multiline: false,
clip_text: true,
..Self::multiline(text)
..Self::multiline(value)
}
}

/// A [`TextEdit`] for multiple lines. Pressing enter key will create a new line by default (can be changed with [`return_key`](TextEdit::return_key)).
pub fn multiline(text: &'t mut dyn TextBuffer) -> Self {
pub fn multiline(value: &'t mut Value) -> Self {
Self {
text,
represents: value,
hint_text: Default::default(),
hint_text_font: None,
id: None,
Expand Down Expand Up @@ -277,7 +279,6 @@ impl<'t> TextEdit<'t> {
layouter: &'t mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley>,
) -> Self {
self.layouter = Some(layouter);

self
}

Expand Down Expand Up @@ -400,13 +401,13 @@ impl<'t> TextEdit<'t> {

// ----------------------------------------------------------------------------

impl Widget for TextEdit<'_> {
impl<Value: TextType> Widget for TextEdit<'_, Value> {
fn ui(self, ui: &mut Ui) -> Response {
self.show(ui).response
}
}

impl TextEdit<'_> {
impl<Value: TextType> TextEdit<'_, Value> {
/// Show the [`TextEdit`], returning a rich [`TextEditOutput`].
///
/// ```
Expand All @@ -423,7 +424,7 @@ impl TextEdit<'_> {
/// # });
/// ```
pub fn show(self, ui: &mut Ui) -> TextEditOutput {
let is_mutable = self.text.is_mutable();
let is_mutable = Value::is_mutable();
let frame = self.frame;
let where_to_put_background = ui.painter().add(Shape::Noop);
let background_color = self
Expand Down Expand Up @@ -470,7 +471,7 @@ impl TextEdit<'_> {

fn show_content(self, ui: &mut Ui) -> TextEditOutput {
let TextEdit {
text,
represents,
hint_text,
hint_text_font,
id,
Expand All @@ -495,6 +496,21 @@ impl TextEdit<'_> {
background_color: _,
} = self;

// An Id is required, as the displayed string is owned by the state.
let id = id.unwrap_or_else(|| {
if let Some(id_salt) = id_salt {
ui.make_persistent_id(id_salt)
} else {
ui.next_auto_id()
}
});

let mut state = TextEditState::load(ui.ctx(), id).unwrap_or_default();
let mut text = state
.text
.clone()
.unwrap_or_else(|| represents.string_representation());

let text_color = text_color
.or(ui.visuals().override_text_color)
// .unwrap_or_else(|| ui.style().interact(&response).text_color()); // too bright
Expand Down Expand Up @@ -527,7 +543,7 @@ impl TextEdit<'_> {

let layouter = layouter.unwrap_or(&mut default_layouter);

let mut galley = layouter(ui, text, wrap_width);
let mut galley = layouter(ui, &text, wrap_width);

let desired_inner_width = if clip_text {
wrap_width // visual clipping with scroll in singleline input.
Expand All @@ -537,18 +553,9 @@ impl TextEdit<'_> {
let desired_height = (desired_height_rows.at_least(1) as f32) * row_height;
let desired_inner_size = vec2(desired_inner_width, galley.size().y.max(desired_height));
let desired_outer_size = (desired_inner_size + margin.sum()).at_least(min_size);
let (auto_id, outer_rect) = ui.allocate_space(desired_outer_size);
let outer_rect = ui.allocate_space(desired_outer_size).1;
let rect = outer_rect - margin; // inner rect (excluding frame/margin).

let id = id.unwrap_or_else(|| {
if let Some(id_salt) = id_salt {
ui.make_persistent_id(id_salt)
} else {
auto_id // Since we are only storing the cursor a persistent Id is not super important
}
});
let mut state = TextEditState::load(ui.ctx(), id).unwrap_or_default();

// On touch screens (e.g. mobile in `eframe` web), should
// dragging select text, or scroll the enclosing [`ScrollArea`] (if any)?
// Since currently copying selected text in not supported on `eframe` web,
Expand All @@ -575,7 +582,7 @@ impl TextEdit<'_> {

if interactive {
if let Some(pointer_pos) = response.interact_pointer_pos() {
if response.hovered() && text.is_mutable() {
if response.hovered() && Value::is_mutable() {
ui.output_mut(|o| o.mutable_text_under_cursor = true);
}

Expand Down Expand Up @@ -629,7 +636,7 @@ impl TextEdit<'_> {
let (changed, new_cursor_range) = events(
ui,
&mut state,
text,
&mut text,
&mut galley,
layouter,
id,
Expand Down Expand Up @@ -773,7 +780,7 @@ impl TextEdit<'_> {
ui.scroll_to_rect(primary_cursor_rect + margin, None);
}

if text.is_mutable() && interactive {
if Value::is_mutable() && interactive {
let now = ui.ctx().input(|i| i.time);
if response.changed() || selection_changed {
state.last_interaction_time = now;
Expand Down Expand Up @@ -820,9 +827,36 @@ impl TextEdit<'_> {
ui.input_mut(|i| i.events.retain(|e| !matches!(e, Event::Ime(_))));
}

state.clone().store(ui.ctx(), id);
// TODO(tye-exe): Simplify once https://github.com/emilk/egui/issues/2142 is fixed
if response.lost_focus() || response.clicked_elsewhere() {
// TODO(tye-exe): Parsing can be skipped if the text has not changed
// since the value is updated in real time.
// However, the reason for parsing failure would not get logged.
match Value::read_from_string(&represents, &text) {
Some(Ok(var)) => *represents = var,
Some(Err(err)) => {
#[cfg(feature = "log")]
log::info!("Failed to parse value for text edit: {err}");
}
None =>
{
#[cfg(feature = "log")]
if Value::is_mutable() {
log::warn!("Incorrectly marked unparsable TextType as mutable.",)
}
}
}

// The user might have changed the text
text = represents.string_representation()
}

if response.changed() {
// Update represented value if the current state is valid
if let Some(Ok(var)) = Value::read_from_string(&represents, &text) {
*represents = var
};

response.widget_info(|| {
WidgetInfo::text_edit(
ui.is_enabled(),
Expand Down Expand Up @@ -871,6 +905,9 @@ impl TextEdit<'_> {
);
}

state.text = Some(text);
state.clone().store(ui.ctx(), id);

TextEditOutput {
response,
galley,
Expand Down Expand Up @@ -905,7 +942,7 @@ fn mask_if_password(is_password: bool, text: &str) -> String {
fn events(
ui: &crate::Ui,
state: &mut TextEditState,
text: &mut dyn TextBuffer,
text: &mut String,
galley: &mut Arc<Galley>,
layouter: &mut dyn FnMut(&Ui, &dyn TextBuffer, f32) -> Arc<Galley>,
id: Id,
Expand Down
3 changes: 2 additions & 1 deletion crates/egui/src/widgets/text_edit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ mod builder;
mod output;
mod state;
mod text_buffer;
mod text_type;

pub use {
crate::text_selection::TextCursorState, builder::TextEdit, output::TextEditOutput,
state::TextEditState, text_buffer::TextBuffer,
state::TextEditState, text_buffer::TextBuffer, text_type::TextType,
};
5 changes: 5 additions & 0 deletions crates/egui/src/widgets/text_edit/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ pub struct TextEditState {
/// Controls the text selection.
pub cursor: TextCursorState,

// TODO(tye-exe): Should this be public?
/// Displayed string.
/// This may differ from the value represented if the user is actively editing the string.
pub text: Option<String>,

/// Wrapped in Arc for cheaper clones.
#[cfg_attr(feature = "serde", serde(skip))]
pub(crate) undoer: Arc<Mutex<TextEditUndoer>>,
Expand Down
62 changes: 0 additions & 62 deletions crates/egui/src/widgets/text_edit/text_buffer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,6 @@ use crate::{
///
/// Most likely you will use a [`String`] which implements [`TextBuffer`].
pub trait TextBuffer {
/// Can this text be edited?
fn is_mutable(&self) -> bool;

/// Returns this buffer as a `str`.
fn as_str(&self) -> &str;

Expand Down Expand Up @@ -212,10 +209,6 @@ pub trait TextBuffer {
}

impl TextBuffer for String {
fn is_mutable(&self) -> bool {
true
}

fn as_str(&self) -> &str {
self.as_ref()
}
Expand Down Expand Up @@ -260,58 +253,3 @@ impl TextBuffer for String {
std::any::TypeId::of::<Self>()
}
}

impl TextBuffer for Cow<'_, str> {
fn is_mutable(&self) -> bool {
true
}

fn as_str(&self) -> &str {
self.as_ref()
}

fn insert_text(&mut self, text: &str, char_index: usize) -> usize {
<String as TextBuffer>::insert_text(self.to_mut(), text, char_index)
}

fn delete_char_range(&mut self, char_range: Range<usize>) {
<String as TextBuffer>::delete_char_range(self.to_mut(), char_range);
}

fn clear(&mut self) {
<String as TextBuffer>::clear(self.to_mut());
}

fn replace_with(&mut self, text: &str) {
*self = Cow::Owned(text.to_owned());
}

fn take(&mut self) -> String {
std::mem::take(self).into_owned()
}

fn type_id(&self) -> std::any::TypeId {
std::any::TypeId::of::<Cow<'_, str>>()
}
}

/// Immutable view of a `&str`!
impl TextBuffer for &str {
fn is_mutable(&self) -> bool {
false
}

fn as_str(&self) -> &str {
self
}

fn insert_text(&mut self, _text: &str, _ch_idx: usize) -> usize {
0
}

fn delete_char_range(&mut self, _ch_range: Range<usize>) {}

fn type_id(&self) -> std::any::TypeId {
std::any::TypeId::of::<&str>()
}
}
Loading