From a8cc4e8b7fe3667f9c911ede43274751c09d7836 Mon Sep 17 00:00:00 2001 From: LynithDev <61880709+LynithDev@users.noreply.github.com> Date: Sat, 19 Sep 2026 01:18:49 +0200 Subject: [PATCH] feat: better text inputs (#854) --- .../src/components/text_input.rs | 139 ++++++++++++++++-- .../src/view/app/cluster/cluster_settings.rs | 1 + packages/oneclient_app/src/view/app/debug.rs | 28 +++- .../src/view/app/settings/minecraft.rs | 4 + 4 files changed, 154 insertions(+), 18 deletions(-) diff --git a/packages/oneclient_app/src/components/text_input.rs b/packages/oneclient_app/src/components/text_input.rs index 6fad7509..86d822dc 100644 --- a/packages/oneclient_app/src/components/text_input.rs +++ b/packages/oneclient_app/src/components/text_input.rs @@ -1,10 +1,15 @@ use freya::prelude::*; +use crate::components::{Button, Icon, IconType, OverlayPopup}; use crate::theme::colors; +use crate::ui::clamp_to_window; const GAP_VERT: f32 = 8.0; const GAP_HORI: f32 = 12.0; +const EXPANDED_WIDTH: f32 = 440.; +const EXPANDED_HEIGHT: f32 = 180.; + fn text_input(value: impl Into>) -> Input { Input::new(value) .inner_margin(Gaps::new_symmetric(GAP_VERT, GAP_HORI)) @@ -40,6 +45,10 @@ pub fn validate_memory(validator: InputValidator) { #[derive(Clone, PartialEq)] pub struct TextInput { input: Input, + value: Writable, + on_submit: Option>, + multiline: bool, + expandable: bool, layout: LayoutData, style: StyleState, text_style: TextStyleData, @@ -50,12 +59,13 @@ pub struct TextInput { #[allow(dead_code)] impl TextInput { pub fn new(value: impl Into>) -> Self { - Self::from_input(text_input(value)) - } - - pub fn from_input(input: Input) -> Self { + let value = value.into(); Self { - input, + input: text_input(value.clone()), + value, + on_submit: None, + multiline: false, + expandable: false, layout: LayoutData::default(), style: StyleState::default(), text_style: TextStyleData::default(), @@ -81,7 +91,20 @@ impl TextInput { } pub fn on_submit(mut self, on_submit: impl Into>) -> Self { - self.input = self.input.on_submit(on_submit); + let on_submit = on_submit.into(); + self.input = self.input.on_submit(on_submit.clone()); + self.on_submit = Some(on_submit); + self + } + + pub fn multiline(mut self, multiline: bool) -> Self { + self.input = self.input.multiline(multiline); + self.multiline = multiline; + self + } + + pub fn expandable(mut self, expandable: bool) -> Self { + self.expandable = expandable; self } @@ -147,18 +170,114 @@ impl StyleExt for TextInput { } } +fn submit_on_modifier_enter( + value: Writable, + on_submit: EventHandler, +) -> Callback, bool> { + Callback::new(move |e: Event| match &e.key { + Key::Named(NamedKey::Enter) if e.modifiers.contains(Modifiers::ctrl_or_meta()) => { + e.stop_propagation(); + e.prevent_default(); + on_submit.call(value.read().clone()); + false + } + Key::Named(NamedKey::Escape) | Key::Named(NamedKey::Shift) => true, + Key::Named(NamedKey::Tab) => false, + _ => { + e.stop_propagation(); + e.prevent_default(); + true + } + }) +} + impl Component for TextInput { fn render(&self) -> impl IntoElement { + let mut expanded = use_state(|| false); + let mut area = use_state(Area::default); + let mut card = rect() .layout(self.layout.clone()) .text_style(self.text_style.clone()) - .children(self.elements.clone()); + .children(self.elements.clone()) + .maybe(self.expandable, |el| { + el.on_sized(move |e: Event| area.set_if_modified(e.area)) + }); card.get_style().clone_from(&self.style); - card.child( - self.input - .clone() + let mut input = self.input.clone(); + if self.multiline { + if let Some(on_submit) = self.on_submit.clone() { + input = + input.on_pre_key_down(submit_on_modifier_enter(self.value.clone(), on_submit)); + } + if !matches!(self.layout.height, Size::Inner) { + input = input.height(Size::fill()); + } + } + + let expanded_input = (self.expandable && expanded()).then(|| { + let area = area(); + let (left, top) = clamp_to_window( + area.max_x() - EXPANDED_WIDTH, + area.min_y(), + EXPANDED_WIDTH, + EXPANDED_HEIGHT, + ); + + OverlayPopup::new() + .backdrop(false) + .position(Position::new_global().left(left).top(top)) + .on_close(move |_| expanded.set(false)) + .child( + rect() + .corner_radius(CornerRadius::new_all(8.)) + .shadow(Shadow::from(( + 0., + 8., + 32., + 0., + Color::from_argb(120, 0, 0, 0), + ))) + .child( + self.input + .clone() + .multiline(true) + .auto_focus(true) + .width(Size::px(EXPANDED_WIDTH)) + .height(Size::px(EXPANDED_HEIGHT)), + ), + ) + }); + + if self.expandable { + input = input.trailing( + rect() + .on_pointer_down(|e: Event| e.stop_propagation()) + .child( + Button::new() + .ghost() + .icon() + .width(Size::px(22.)) + .height(Size::px(22.)) + .padding(3.) + .tooltip("Expand") + .on_press(move |e: Event| { + e.stop_propagation(); + expanded.set(true); + }) + .child( + Icon::new(IconType::Maximize01) + .size(14.) + .color(colors::fg_secondary()), + ), + ), + ); + } + + card.maybe_child(expanded_input).child( + input .map(self.text_style.text_align, |mut el, text_align| { el = el.text_align(text_align); diff --git a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs index e570b62e..7d06ceaf 100644 --- a/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs +++ b/packages/oneclient_app/src/view/app/cluster/cluster_settings.rs @@ -627,6 +627,7 @@ impl Component for TextRow { let control = TextInput::new(text) .placeholder(placeholder) + .expandable(true) .width(Size::px(220.)); settings_row( diff --git a/packages/oneclient_app/src/view/app/debug.rs b/packages/oneclient_app/src/view/app/debug.rs index b93a18c6..8e0f8eac 100644 --- a/packages/oneclient_app/src/view/app/debug.rs +++ b/packages/oneclient_app/src/view/app/debug.rs @@ -15,7 +15,7 @@ use crate::notifications::{ OptionalModsGroup, }; use crate::routes::Route; -use crate::theme::colors; +use crate::theme::{self, colors}; use crate::ui::border_all_color; type SqlResult = Option>; @@ -1093,25 +1093,37 @@ impl Component for SqlConsole { .vertical() .width(Size::fill()) .spacing(10.) + .child( + TextInput::new(query) + .multiline(true) + .placeholder("SELECT * FROM …") + .font_family(theme::MONO_FONT) + .width(Size::fill()) + .height(Size::px(140.)) + .on_submit(move |_| run_sql(query, result, running)), + ) .child( rect() .horizontal() .width(Size::fill()) .cross_align(Alignment::Center) .spacing(12.) - .child( - rect().width(Size::flex(1.0)).child( - TextInput::new(query) - .placeholder("SELECT * FROM …") - .on_submit(move |_| run_sql(query, result, running)), - ), - ) .child( Button::new() .primary() .child(Icon::new(IconType::Terminal).size(16.)) .text(if *running.read() { "Running…" } else { "Run" }) .on_press(move |_| run_sql(query, result, running)), + ) + .child( + label() + .text(if cfg!(target_os = "macos") { + "⌘ Enter" + } else { + "Ctrl+Enter" + }) + .font_size(12.) + .color(colors::fg_secondary()), ), ) .child(sql_result(&result.read())) diff --git a/packages/oneclient_app/src/view/app/settings/minecraft.rs b/packages/oneclient_app/src/view/app/settings/minecraft.rs index 22101ffa..9dfe16a0 100644 --- a/packages/oneclient_app/src/view/app/settings/minecraft.rs +++ b/packages/oneclient_app/src/view/app/settings/minecraft.rs @@ -108,6 +108,7 @@ impl Component for SettingsMinecraft { "Extra arguments passed to Java. Separate them with spaces; quote values containing spaces.", TextInput::new(jvm_args) .placeholder("-XX:+UseG1GC") + .expandable(true) .width(Size::px(220.)), )) .child(section_header("CONTENT")) @@ -127,6 +128,7 @@ impl Component for SettingsMinecraft { "Command to run before launching the game.", TextInput::new(pre_launch_command) .placeholder("echo 'Game started'") + .expandable(true) .width(Size::px(220.)), )) .child(settings_row( @@ -135,6 +137,7 @@ impl Component for SettingsMinecraft { "Command to run when launching the game.", TextInput::new(wrapper_command) .placeholder("gamescope") + .expandable(true) .width(Size::px(220.)), )) .child(settings_row( @@ -143,6 +146,7 @@ impl Component for SettingsMinecraft { "Command to run after exiting the game.", TextInput::new(post_exit_command) .placeholder("echo 'Game exited'") + .expandable(true) .width(Size::px(220.)), ));