Text Input
The TextInput widget provides single-line text editing with support for selection, clipboard operations and undo/redo. Its sibling, password_input, is the same field for a password: masked, and holding the text where it cannot be copied, swapped or dumped.
Basic Usage
extern crate guido;
use guido::prelude::*;
fn main() {
let username = create_signal(String::new());
text_input(username)
;
}
TextInput uses two-way binding with signals:
- When the user types, the signal is automatically updated
- When the signal changes programmatically, the input reflects the new value
No manual synchronization is needed - just pass a Signal<String> and the binding works automatically.
Styling
Text Color
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container().child(text_input(value).color(Color::WHITE))
;
}
Cursor Color
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
text_input(value).cursor_color(Color::rgb(0.4, 0.8, 1.0))
;
}
Selection Color
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
text_input(value).selection_color(Color::rgba(0.4, 0.6, 1.0, 0.4))
;
}
Font Size
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container().child(text_input(value).font_size(16.0))
;
}
Font Family
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
// Predefined families
container().child(text_input(value).font_family(FontFamily::Monospace));
// Shorthand for monospace
container().child(text_input(value));
// Custom font
container().child(text_input(value).font_family(FontFamily::name("JetBrains Mono")))
;
}
Font Weight
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
// Using constants
container().child(text_input(value).font_weight(FontWeight::BOLD));
// Shorthand for bold
container().child(text_input(value))
;
}
Italic and OpenType Features
As on a text: italic(), font_style and
font_features. A field of numbers with tabular figures keeps its caret
between digits of one width:
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container().child(text_input(value).font_features(FontFeatures::new().enable(b"tnum")))
;
}
Letter Spacing
Logical pixels after every character, as on a text. The caret, the selection and a click all land where the spaced letters are, and a password field spaces the dots it draws:
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
let password = create_password();
container().child(text_input(value).letter_spacing(1.0));
container().child(password_input(password).letter_spacing(4.0))
;
}
Password Input
A password gets its own field, bound to a Password rather than a
RwSignal<String>:
extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
password_input(password)
.placeholder("Password")
.on_submit(move || {
// Moved out, and the field is empty again. Hand it to PAM;
// dropping it wipes it.
let secret: Secret = password.take();
let _ = secret;
})
;
}
A String is the wrong home for a password: every edit and every get of one
leaves a copy on the heap that nothing wipes, its pages can be swapped to disk,
and a crash writes it into a core dump. So the field keeps its text in a
Secret — pages of its own, locked in memory, left out of core dumps, and
zeroed whenever they are given back — and edits it there, in place. It keeps
no undo history, since every entry would be a copy; nothing can be copied or
cut out of it; and Ctrl+Left/Right goes to the edge rather than revealing
where the words end, as a double click selects all of it rather than a word.
Pasting in still works, for password managers.
Password is a signal in everything but the copy. Reads subscribe, so state
that follows the field is an ordinary closure, and the application reaches the
text whenever it needs to — a Sign In button, a form’s Save, a clear after an
idle timeout — by borrowing it:
extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
let sign_in = |_: &str| {};
container()
.enabled(move || !password.is_empty())
.on_click(move || password.with(|secret| sign_in(secret.expose())))
;
password.set(Secret::from(String::from("from the keyring"))); // pre-fill; the String is wiped
password.clear(); // wipe in place
}
Submitting is the application’s to spell, and Enter only says it happened.
take moves the secret out and empties the field. To keep the dots on screen
while the password is checked, hand over a duplicate instead — a second
Secret in pages of its own, as protected as the first — and clear the field
when the answer arrives. Enter and a submit button can then share one function:
extern crate guido;
use guido::prelude::*;
fn check(_: Secret) {}
fn main() {
let password = create_password();
let submit = move || check(password.with_untracked(Secret::duplicate));
container()
.child(password_input(password).on_submit(submit))
.child(container().on_click(submit).child(text("→")))
;
}
There is no get: whatever the application copies out of expose() is the
application’s to wipe.
By default, characters are masked with •. Customize the mask character:
extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
password_input(password).mask_char('*')
;
}
Showing the password
An eye button beside the field is a signal the field is told:
extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
let shown = create_signal(false);
container()
.layout(Flex::row().spacing(8.0))
.child(password_input(password).reveal(shown))
.child(
container()
.on_click(move || shown.update(|s| *s = !*s))
.child(text(move || if shown.get() { "Hide" } else { "Show" }.to_string())),
)
;
}
Declared rather than rebuilt, so the caret, the selection and the focus stay where they were. Everything else stays as it was: nothing can be copied or cut out, and there is no undo.
The catch: a shown password is drawn like any other text, and drawing text
copies it. Shaping makes temporary copies of the text and frees them without
wiping them, and the measurement and render caches keep what they shaped. That
is ordinary memory, outside what the field controls, so while the password is
shown — and afterwards, until those caches let go of it — it can be found in
the process’s memory the way any String can. The Secret itself is untouched,
and the field’s own copy of what it drew is wiped when it changes. GTK 4’s peek
icon makes the same trade. If the threat you are guarding against is a core
dump or a memory read, leave reveal off.
Hardening the process
The field keeps the password out of places guido controls. Some copies are outside that, and a lock screen should know about them:
- Each key press. smithay-client-toolkit, which guido reads the keyboard
through, hands every key over as a one-character
Stringand frees it unwiped. That leaves single characters, one per allocation — not the password, but pieces of it. - The clipboard. guido reads the clipboard only when something pastes, and wipes what it read. The password manager’s own copy is its business.
- A revealed password, as above.
A lock screen can close the core-dump route to all of it with one call at
startup, before the password is ever typed: a process that is not dumpable
leaves no core dump for coredumpctl to read back, and cannot be attached to
with ptrace by other processes of the same user.
// not compiled: uses the `libc` crate, which the book's samples do not link.
// SAFETY: a plain prctl with no pointers.
unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0) };
Do not reach for mlockall to keep the process out of swap. It counts against
the locked-memory limit, which is usually a few megabytes, and a GPU-rendered
application maps far more than that — with MCL_FUTURE, later allocations
start failing. The Secret already locks the pages that hold the password,
which is what swaylock does too; for the rest, swap is the system’s to
encrypt.
Everything else here — placeholder, caret, pointer shape, colours, focus,
readonly — is the same on both fields.
Initial Focus
A screen that exists to be typed into should not make the user click first — and on a surface with no pointer there may be nothing to click with:
extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
password_input(password)
.autofocus()
;
}
The input takes the keyboard at its first layout, unless a widget on its own surface already holds focus. That condition is what makes it safe: an input appearing later never pulls focus off the one being typed into beside it, and two autofocusing inputs do not fight (the first laid out wins).
When the focus is on another surface, the input waits for the keyboard. A popup opened from a focused field has its field focused once the keyboard reaches the popup, and the field below has the focus back when the popup closes. The same view built once per output — a lock screen with two monitors — ends up with the field on the monitor holding the keyboard focused, and one built for a monitor plugged in mid-lock takes nothing from the password being typed.
The offer is made once. A relayout does not ask again, so a resize or a scale change cannot drag focus back from wherever the user has since put it.
Losing Focus
The surface losing the keyboard does not take the focus off the field. Switching to another window, or opening a popup that grabs the keyboard, leaves the field focused with no caret, and when the keyboard comes back the caret does too and the next key is typed where it was going. A surface remembers the widget it had focused when the focus moved to another surface — the field inside that popup, say — and has it back when the keyboard returns, if it is still there.
A press that no widget claimed takes the keyboard off the field. Clicking the surface behind it, or a decorative panel, or empty space in a row: nothing there wanted the press, so there is nothing left for the focus to belong to, and the caret stops.
A press that something did claim leaves the focus alone. A container with an
on_click, on_mouse_down or on_mouse_up claims the left presses that land
on it, so a toolbar button that acts on the field it sits beside does not blur
it, and neither does the field’s own scrollbar. The other two buttons are
claimed by the handlers that name them, on_right_click and on_middle_click:
a container with only an on_click does not claim a right press, and a right
press it did not claim is a press on nothing like any other.
A press inside the focused field is always the field’s, whichever button it was — and it keeps the keyboard without consuming the press, so a row wrapping the field still gets its own click or context menu.
The box drawn around a field is the third case, and it is the one worth knowing:
a container that declares when_focused and currently holds that focus keeps
that focus against a press inside itself. Clicking its padding, its border, the
space beside the caret, is clicking the field it draws.
The press still travels. The box keeps the keyboard and consumes nothing, so a clickable ancestor wrapping the field is clicked as it would be anywhere else.
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container()
.padding(8.0)
.border(1.0, Color::rgb(0.3, 0.3, 0.4))
// Lights up for the focus, and keeps it: a click on the padding is a
// click on the field. A ring outside the box, so the field does not move.
.when_focused(|s| s.outline(2.0, Color::rgb(0.4, 0.8, 1.0), 2.0))
.child(text_input(value))
;
}
The focus also leaves when the whole surface does — the compositor moving the keyboard elsewhere — and when the field is taken out of the tree.
Placeholder
What the field says while it is empty:
extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
password_input(password)
.placeholder("Password")
;
}
It is a label, not a value: never masked, so a password field shows the word rather than eight bullets, and it stands in place of the text rather than beside it — there is nothing to overlap, because it is only drawn when the value is empty.
Reactive, so a prompt that changes changes the empty field with it:
extern crate guido;
use guido::prelude::*;
fn main() {
let answer = create_signal(String::new());
let prompt = create_signal(String::from("Type here"));
text_input(answer).placeholder(move || prompt.get())
;
}
The colour is the field’s own text colour at reduced alpha — a placeholder is the
same text, quieter. Declare placeholder_color on the field itself: only a
field draws a placeholder, so only a field says what colour it is.
extern crate guido;
use guido::prelude::*;
#[derive(Clone, Copy)]
struct Theme { text: Color, text_weak: Color, weak: Color, strong: Color, line: Color, accent: Color, error: Color, danger: Color, surface: Color }
impl Default for Theme { fn default() -> Self { let c = Color::WHITE; Self { text: c, text_weak: c, weak: c, strong: c, line: c, accent: c, error: c, danger: c, surface: c } } }
fn main() {
let theme = Theme::default();
let value = create_signal(String::new());
container()
.child(text_input(value).placeholder_color(theme.text_weak).color(theme.text).placeholder("Search"))
;
}
No Caret
extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
password_input(password)
.no_caret()
;
}
Keeps the focus and everything it carries — click to position, drag to select, the keyboard — and draws no caret. For a field where the caret says nothing: a masked one, where every character looks the same and you are always at the end. swaylock draws none for that reason.
It is also the cheapest field there is. A blinking caret is the one thing a still screen redraws on its own, twice a second, for as long as it is focused; without one an idle surface asks the loop for nothing at all.
Pointer Shape
A field shows the I-beam under the pointer unless it says otherwise, and it takes
the same cursor a container does — a value, a closure or a signal:
extern crate guido;
use guido::prelude::*;
fn main() {
let password = create_password();
password_input(password)
.no_caret()
.cursor(CursorIcon::Hidden)
;
}
A masked field with no caret has nothing a click could place, so on a lock screen whose root hides the pointer the I-beam would promise editing that is not there. The field is the innermost widget under the pointer, so a shape declared around it never reaches it: this is the one place to say it.
Callbacks
On Change
Called whenever the text content changes:
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
text_input(value)
.on_change(|new_text| {
println!("Text changed: {}", new_text);
})
;
}
On Submit
Called when the user presses Enter:
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
text_input(value)
.on_submit(|text| {
println!("Submitted: {}", text);
})
;
}
Keyboard Shortcuts
The TextInput widget supports standard text editing shortcuts:
| Shortcut | Action |
|---|---|
Ctrl+A | Select all |
Ctrl+C | Copy selection (not in a password field) |
Ctrl+X | Cut selection (not in a password field) |
Ctrl+V | Paste |
Ctrl+Z | Undo (not in a password field) |
Ctrl+Shift+Z or Ctrl+Y | Redo (not in a password field) |
Left/Right | Move cursor |
Ctrl+Left/Right | Move to the start or the end of a word (to the edge in a password field) |
Shift+Left/Right | Extend selection |
Home/End | Move to start/end |
Backspace | Delete before cursor |
Delete | Delete after cursor |
A character here is what is drawn as one: a letter and its combining accent, or an emoji made of several code points, such as a flag. The arrows step over it and both deletes take all of it. A password field steps one code point at a time, because its mask draws one bullet for each.
Styling with Container
TextInput handles text editing but not visual styling like backgrounds and borders. Wrap it in a Container for full styling:
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container()
.padding([8.0, 12.0])
.background(Color::rgb(0.15, 0.15, 0.2))
.border(1.0, Color::rgb(0.3, 0.3, 0.4))
.corners(4.0)
.child(
container().child(text_input(value).font_size(14.0).color(Color::WHITE))
)
;
}
With Focus State
Add visual feedback when the input is focused:
extern crate guido;
use guido::prelude::*;
fn main() {
let value = create_signal(String::new());
container()
.padding([8.0, 12.0])
.background(Color::rgb(0.15, 0.15, 0.2))
.border(1.0, Color::rgb(0.3, 0.3, 0.4))
.corners(4.0)
.when_focused(|s| s.border(1.0, Color::rgb(0.4, 0.6, 1.0)))
.child(
container().child(text_input(value).color(Color::WHITE))
)
;
}
Complete Example
A login form with username and password fields:
extern crate guido;
use guido::prelude::*;
fn login_form() -> Container {
let username = create_signal(String::new());
let password = create_password();
container()
.padding(24.0)
.background(Color::rgb(0.1, 0.1, 0.15))
.corners(12.0)
.layout(Flex::column().spacing(16.0))
.children([
// Username field
container()
.layout(Flex::column().spacing(4.0))
.children([
container().child(text("Username").font_size(12.0).color(Color::rgb(0.6, 0.6, 0.7))),
container()
.padding([8.0, 12.0])
.background(Color::rgb(0.15, 0.15, 0.2))
.border(1.0, Color::rgb(0.3, 0.3, 0.4))
.corners(4.0)
.when_focused(|s| s.border(1.0, Color::rgb(0.4, 0.6, 1.0)))
.child(
container().child(text_input(username).font_size(14.0).color(Color::WHITE))
),
]),
// Password field
container()
.layout(Flex::column().spacing(4.0))
.children([
container().child(text("Password").font_size(12.0).color(Color::rgb(0.6, 0.6, 0.7))),
container()
.padding([8.0, 12.0])
.background(Color::rgb(0.15, 0.15, 0.2))
.border(1.0, Color::rgb(0.3, 0.3, 0.4))
.corners(4.0)
.when_focused(|s| s.border(1.0, Color::rgb(0.4, 0.6, 1.0)))
.child(
container().child(password_input(password).font_size(14.0).color(Color::WHITE))
),
]),
// Submit button
container()
.padding([10.0, 16.0])
.background(Color::rgb(0.3, 0.5, 0.9))
.corners(6.0)
.when_hovered(|s| s.lighter(0.1))
.when_pressed(|s| s.darker(0.1))
.on_click(move || {
password.with(|secret| {
println!("Login: {} / {} characters", username.get(), secret.expose().chars().count());
});
})
.child(
container().child(text("Sign In").font_size(14.0).color(Color::WHITE))
),
])
}
fn main() {}
Features
- Selection: Click and drag to select text, or use Shift+Arrow keys. A
double click selects the word under the pointer, and dragging after it
extends the selection a word at a time; a triple click selects everything.
A word is what Unicode’s word segmentation (UAX #29) makes one, as in GTK,
Qt and the browsers: a path or an address divides at its slashes and its
@, while a full stop or an apostrophe between two letters, as in
example.com or don’t, stays inside the word. A double click on spaces or
punctuation selects only those.
Ctrl+Left/Rightstops at the same ends of words, stepping over the spaces and punctuation between them. - Clipboard: Full copy/cut/paste support via Ctrl+C/X/V. Another application’s copy is read only when you paste, off the UI thread, so a paste never blocks it: the text appears once that application answers. The field is one line, so a pasted line break is removed, as a browser’s text input removes it: a line copied from a terminal with its newline arrives as the line alone, and two lines arrive joined
- Primary selection: selecting text with the mouse sets the primary selection (paste it elsewhere with middle click); middle-clicking the input pastes the primary selection at the click position
- Undo/Redo: History with intelligent coalescing of rapid edits
- Scrolling: Long text scrolls horizontally to keep cursor visible
- Cursor Blinking: Standard blinking cursor when focused. It asks the loop to
wake at its next toggle rather than animating, so a focused field costs two
wakeups a second instead of a frame at the compositor’s rate;
no_caret()drops it entirely and costs nothing - Key Repeat: Hold keys for continuous input, at the rate and delay the compositor reports — the same settings every other application on the session obeys
API Reference
// not compiled: a signature listing — these declarations have no bodies.
text_input(signal: RwSignal<String>) -> TextInput
password_input(password: Password) -> PasswordInput // TextInput<Masked>
// Both fields
impl<C> TextInput<C> {
pub fn autofocus(self) -> Self;
pub fn caret<M>(self, caret: impl IntoSignal<bool, M>) -> Self;
pub fn no_caret(self) -> Self; // Shorthand for caret(false)
pub fn cursor<M>(self, cursor: impl IntoSignal<CursorIcon, M>) -> Self; // Text by default
pub fn placeholder<M>(self, text: impl IntoSignal<String, M>) -> Self;
pub fn readonly<M>(self, readonly: impl IntoSignal<bool, M>) -> Self;
}
impl TextInput {
pub fn on_change<F: Fn(&str) + 'static>(self, callback: F) -> Self;
pub fn on_submit<F: Fn(&str) + 'static>(self, callback: F) -> Self;
}
impl PasswordInput {
pub fn mask_char<M>(self, c: impl IntoSignal<char, M>) -> Self;
pub fn reveal<M>(self, reveal: impl IntoSignal<bool, M>) -> Self; // copies while shown
pub fn on_submit<F: Fn() + 'static>(self, callback: F) -> Self;
}
create_password() -> Password
impl Password {
pub fn with<R>(&self, f: impl FnOnce(&Secret) -> R) -> R; // tracked
pub fn is_empty(&self) -> bool; // tracked
pub fn set(&self, secret: Secret);
pub fn take(&self) -> Secret; // empties the field
pub fn clear(&self);
}
impl Secret {
pub fn expose(&self) -> &str;
pub fn is_empty(&self) -> bool;
pub fn duplicate(&self) -> Secret; // its own locked pages
}
impl From<String> for Secret // wipes the String
Note: The on_change callback is optional and is called in addition to the automatic signal update. Use it for side effects like validation or logging, not for updating the signal (that happens automatically).