Building Native Desktop Interfaces with Rust GPUI: Part 2
Real Input Handling: Beyond Hello World Part 1 covered the basics: components, rendering, event handlers, window management. We built a dialog that appeared, responded to clicks, and looked native. That foundation matters, but it only scratched the surface of what real applications demand. I was originally planning to dip into cross platform aspects to realize that a simple dialog box would not provide enough depth. Real applications need text input. They need validation. They need keyboard navigation that feels natural, with TAB moving forward and SHIFT+TAB moving backward. They need visual feedback where cursors blink, fields highlight when focused, and error messages appear inline. They need multiple windows that communicate with each other, passing data back and forth as the user works. High-level frameworks hide this complexity behind abstractions. You get an input element and the browser handles everything: cursor rendering, selection, copy-paste, undo-redo, accessibility, internationalization. The abstraction is powerful, but it’s also opaque. When you need behavior the abstraction doesn’t provide, you’re stuck working around it. GPUI takes a different approach. It gives you the primitives: a render loop, a layout engine, event dispatch. Everything else you build yourself. That sounds harder, and it is initially, but the directness pays off. You understand exactly what your interface does because you wrote it. When you need custom behavior, you don’t fight the framework. You just implement it. This post builds a complete application with real input handling: a biorhythm calculator. The concept itself is unscientific. There’s no credible evidence that human performance follows predictable sinusoidal cycles based on birthdate. But that doesn’t diminish its value as a teaching example. The interface is interesting enough to demonstrate real techniques without overwhelming you with domain complexity. We’ll focus on what matters: building input fields from scratch, implementing smooth cursor blinking, handling keyboard navigation, and coordinating multiple windows. By the end, you’ll understand what frameworks usually hide. You’ll see why cursor blinking requires continuous animation, why keyboard navigation needs explicit state management, and why validation feedback demands careful coordination between events and rendering. These aren’t abstract concepts. They’re concrete problems you’ll solve with working code.
What You’re Building A two-window application. The first window displays a biorhythm chart showing three sinusoidal curves plotted over 33 days, representing physical, emotional, and intellectual cycles. The second window is a modal dialog where users enter their birthdate. When they submit, the chart updates to reflect the new date. The dialog contains three text input fields for year, month, and day. Each field accepts only numeric input with length limits, displays a blinking cursor when focused, shows a blue border when focused and gray otherwise, and responds to TAB and SHIFT+TAB navigation. The system validates input on submission and displays inline errors when something’s wrong. The chart window displays three colored curves with a legend, shows the current birthdate in the header, opens the input dialog when double-clicked, and updates immediately when the user submits a new date. This isn’t a toy. The patterns here scale. Text input, validation, focus management, cross-window communication are the building blocks of every desktop application. Master them in this context and you’ll apply them everywhere.
The Reality of Text Input Text input feels simple until you build it. You need to track which field has focus, display a cursor at the insertion point, and make the cursor blink at regular intervals. Typically that’s 500ms on, 500ms off. Then you handle character input with validation for numeric-only content and length limits. Backspace needs to delete from the cursor position. TAB needs to move to the next field. SHIFT+TAB needs to move to the previous field. ENTER needs to submit the form. Through all of this, you provide visual feedback with border colors and cursor visibility. Browsers do all this automatically. GPUI does none of it automatically. You implement every piece. This isn’t a limitation. It’s a design choice. GPUI provides the event loop, the render cycle, and the layout engine. You provide the behavior. The result is interfaces that do exactly what you want, no more, no less.
Building Custom Input Fields Start with state. Each input field needs to track its value, cursor position, and focus state. For three fields, that’s three sets of state: struct DateInputDialog {
year: String,
month: String,
day: String,
year_focus: FocusHandle,
month_focus: FocusHandle,
day_focus: FocusHandle,
year_cursor: usize,
month_cursor: usize,
day_cursor: usize,
validation_error: Option<String>,
// For cursor blinking
last_blink: Instant,
} FocusHandle is GPUI’s primitive for tracking keyboard focus. Create them in your constructor: fn new(cx: &mut Context<Self>) -> Self {
Self {
year: String::from("1990"),
month: String::from("1"),
day: String::from("1"),
year_focus: cx.focus_handle(),
month_focus: cx.focus_handle(),
day_focus: cx.focus_handle(),
year_cursor: 4,
month_cursor: 1,
day_cursor: 1,
validation_error: None,
last_blink: Instant::now(),
}
} Each field gets its own FocusHandle. You’ll use these to move focus between fields and check which field is currently focused.
Rendering Input Fields An input field is just a styled div() with event handlers attached. The trick is knowing which handlers you need and how to connect them to state. Here’s the structure: fn editable_input_field(
&mut self, label: &'static str, focus_handle: &FocusHandle, value: &str, cursor_pos: usize, caret_visible: bool, window: &Window, cx: &mut Context<Self>,
) -> impl IntoElement {
let is_focused = focus_handle.is_focused(window);
// Insert cursor character if focused and visible
let display_text = if is_focused && caret_visible {
let mut chars: Vec<char> = value.chars().collect();
let safe_cursor = cursor_pos.min(chars.len());
chars.insert(safe_cursor, '|');
chars.into_iter().collect::<String>()
} else {
value.to_string()
};
div()
.border_1()
.border_color(if is_focused {
rgb(0x007AFF) // Blue when focused
} else {
rgb(0xCCCCCC) // Gray otherwise
})
.rounded(px(4.0))
.track_focus(focus_handle)
.on_mouse_down(MouseButton::Left, {
let focus_handle = focus_handle.clone();
cx.listener(move |this, _event, window, cx| {
focus_handle.focus(window);
// Set cursor position, update state, trigger re-render
cx.notify();
})
})
.on_key_down(cx.listener(move |this, event, window, cx| {
// Handle keyboard input (covered next)
}))
.child(div().child(display_text))
} The cursor is rendered as a text character, a pipe symbol inserted into the string at the cursor position. This is simple but effective. When the field is focused and caret_visible is true, you insert the character. Otherwise, you render the plain text. Focus changes the border color. GPUI’s conditional styling makes this straightforward: blue if focused, gray otherwise.
Implementing Cursor Blink Cursor blinking requires continuous animation. You need to toggle visibility every 500ms and trigger re-renders even when the user isn’t interacting with the interface. The naive approach doesn’t work. You can’t just toggle a boolean in the render method because render() only runs when something triggers it: a mouse click, a keyboard press, a call to cx.notify(). If the user isn’t interacting, render doesn’t run. The cursor won’t blink. The solution is to schedule a re-render on every frame using on_next_frame(): fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// Calculate cursor visibility based on elapsed time
let elapsed_ms = self.last_blink.elapsed().as_millis();
let caret_visible = (elapsed_ms / 500) % 2 == 0;
// Schedule next render
cx.on_next_frame(window, |_this, _window, cx| {
cx.notify();
});
// Build UI with current caret_visible state
// ...
} This creates a render loop. Each frame schedules the next one. The calculation elapsed_ms / 500 gives you the number of 500ms intervals since the cursor started blinking. The modulo operation % 2 toggles between 0 for visible and 1 for hidden. The cursor blinks smoothly, synchronized to real time, not to user input.
This pattern is fundamental to GPUI animation. Want smooth motion? Schedule cx.notify() on every frame and update your state based on elapsed time. Want to stop animating? Don’t schedule the next frame. Full control, zero magic.
Keyboard Navigation: TAB and SHIFT+TAB TAB moves focus forward through fields. SHIFT+TAB moves backward. Both should wrap around: TAB on the last field moves to the first, SHIFT+TAB on the first moves to the last. Handle this in your key handler: .on_key_down(cx.listener(move |this, event, window, cx| {
let (field_value, cursor) = match label {
"Year" => (&mut this.year, &mut this.year_cursor),
"Month" => (&mut this.month, &mut this.month_cursor),
"Day" => (&mut this.day, &mut this.day_cursor),
_ => return,
};
match event.keystroke.key.as_str() {
"backspace" => {
if *cursor > 0 && !field_value.is_empty() {
field_value.pop();
*cursor = field_value.len();
cx.notify();
}
}
"tab" => {
match label {
"Year" => this.month_focus.focus(window),
"Month" => this.day_focus.focus(window),
"Day" => this.year_focus.focus(window), // Wrap
_ => {}
}
}
"shift-tab" => {
match label {
"Year" => this.day_focus.focus(window), // Wrap
"Month" => this.year_focus.focus(window),
"Day" => this.month_focus.focus(window),
_ => {}
}
}
"enter" => {
this.submit_date(window, cx);
}
key if key.len() == 1 && key.chars().all(|c| c.is_ascii_digit()) => {
let max_len = match label {
"Year" => 4,
"Month" | "Day" => 2,
_ => 4,
};
if field_value.len() < max_len {
field_value.push_str(key);
*cursor = field_value.len();
cx.notify();
}
}
_ => {}
}
})) GPUI represents modified keys as strings like "shift-tab", "cmd-q", or "ctrl-c". Match on these strings to handle key combinations. The event.keystroke.key field contains the key name. Check its length and content to filter numeric input. Numeric input has a length limit per field: four characters for year, two for month and day. Before appending, check the current length. This prevents users from entering invalid values like “999999” in the year field. Moving focus is explicit. Call this.month_focus.focus(window) to move from year to month. GPUI doesn’t assume focus order. You define it. Year moves to Month, Month moves to Day, Day wraps to Year. Reverse the order for SHIFT+TAB.
Validation and Error Display Validation runs when the user submits. Check the date components and set an error message if something’s wrong: fn validate_date(&mut self) -> bool {
let year = match self.year.parse::<i32>() {
Ok(y) => y,
Err(_) => {
self.validation_error = Some("Year must be a number".to_string());
return false;
}
};
let month = match self.month.parse::<u32>() {
Ok(m) => m,
Err(_) => {
self.validation_error = Some("Month must be a number".to_string());
return false;
}
};
// Check ranges
if year < 1900 || year > 2100 {
self.validation_error = Some("Year must be between 1900 and 2100".to_string());
return false;
}
if month < 1 || month > 12 {
self.validation_error = Some("Month must be between 1 and 12".to_string());
return false;
}
// Check days in month (accounting for leap years)
let max_days = match month {
2 => if (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) {
29
} else {
28
},
4 | 6 | 9 | 11 => 30,
_ => 31,
};
if day > max_days {
self.validation_error = Some(format!(
"Invalid day for month {}. Maximum is {}",
month,
max_days
));
return false;
}
self.validation_error = None;
true
} Display the error inline, below the input fields: .when_some(self.validation_error.clone(), |el, error| {
el.child(
div()
.text_size(px(12.0))
.text_color(rgb(0xCC0000)) // Red text
.child(error)
)
}) The when_some() method conditionally includes a child element. If validation_error contains a message, the error displays. If it’s None, the element doesn’t render. This keeps your render code clean without manual conditional chains scattered throughout.
Cross-Window Communication The dialog needs to update the chart when the user submits a valid date. This requires passing a handle from the chart window to the dialog window. When creating the dialog, pass the chart’s window handle: // In the chart window's double-click handler fn on_double_click(&mut self, _event: &MouseDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
let chart_handle = self.self_handle.clone();
cx.open_window(
WindowOptions { /* ... */ },
move |_, cx| cx.new(|cx| {
DateInputDialog::new(false, chart_handle, cx)
}),
)
.ok();
} The dialog stores this handle: struct DateInputDialog {
chart_window: Option<WindowHandle<BiorhythmChart>>, // ...
} On submission, use the handle to update the chart: fn submit_date(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.validate_date() {
let year = self.year.parse::<i32>().unwrap();
let month = self.month.parse::<u32>().unwrap();
let day = self.day.parse::<u32>().unwrap();
if let Some(chart_window) = &self.chart_window {
chart_window.update(cx, |chart, _window, cx| {
chart.update_birthdate(year, month, day, cx);
}).ok();
}
window.remove_window(); // Close the dialog
} else {
cx.notify(); // Re-render to show error
}
} The WindowHandle::update() method gives you mutable access to the other window’s component. You call its methods directly. The chart updates its state and triggers a re-render. The user sees the new curve immediately. This pattern scales naturally. Need to coordinate three windows? Pass three handles. Need bidirectional communication? Pass handles both ways. GPUI doesn’t enforce a particular architecture. You decide how your components communicate.
The Chart Window The chart window displays three curves representing physical, emotional, and intellectual cycles with periods of 23, 28, and 33 days respectively. Each curve is a sine wave starting from the user’s birthdate. Render it by plotting points and connecting them with small rectangular “pixels”: fn create_cycle_lines(
&self, days_since_birth: i32, cycle_length: f64, color: Hsla, width: f32, height: f32, days: i32,
) -> Vec<impl IntoElement> {
let mut lines = Vec::new();
let x_step = width / (days as f32);
for i in 0..(days - 1) {
let day1 = days_since_birth + i;
let day2 = days_since_birth + i + 1;
let value1 = calculate_biorhythm(day1, cycle_length);
let value2 = calculate_biorhythm(day2, cycle_length);
let y1 = (height / 2.0) - (value1 as f32 * height / 2.5);
let y2 = (height / 2.0) - (value2 as f32 * height / 2.5);
let x1 = i as f32 * x_step;
let x2 = (i + 1) as f32 * x_step;
// Draw line segments using positioned divs
let steps = 10;
for step in 0..steps {
let t = step as f32 / steps as f32;
let x = x1 + t * (x2 - x1);
let y = y1 + t * (y2 - y1);
lines.push(
div()
.absolute()
.left(px(x))
.top(px(y))
.w(px(2.0))
.h(px(2.0))
.bg(color)
);
}
}
lines
} Each curve is a collection of absolutely positioned div() elements, 2×2 pixels each. You calculate the Y position from the sine value, interpolate between points, and render a small square at each position. It’s primitive, but it works. The curves are smooth, the colors distinct, the layout predictable. More sophisticated rendering would use custom drawing APIs, which GPUI supports. For this example, divs are enough. They demonstrate the principle: when you need custom visuals, build them from primitives.
What This Reveals Building input fields from scratch clarifies what high-level frameworks hide. Text input isn’t automatic. You track the value, the cursor position, and the focused state. You handle each keystroke individually, filtering invalid input and updating state. Cursor blinking requires animation. You can’t just toggle a boolean. You need continuous rendering, scheduled via on_next_frame(), synchronized to elapsed time. Keyboard navigation is explicit. TAB and SHIFT+TAB don’t automatically cycle through fields. You write the logic for which field comes next, where to wrap, and when to submit. Validation needs coordination. You validate on submission, store the result, conditionally render errors, and trigger re-renders at the right moments. Multi-window communication is direct. No message passing, no serialization, no IPC. You hold a handle, you call a method, you update state. The type system ensures correctness. These aren’t abstract patterns. They’re concrete implementations. The code you wrote defines the behavior. There’s no framework layer translating your intent. The directness is the point.
Why This Matters High-level UI frameworks succeed because they provide working defaults. Text inputs that handle basic editing. Focus management that follows tab order. Validation that triggers on blur. These defaults cover most use cases. The cost is flexibility: when you need behavior the abstraction doesn’t provide, you’re stuck reverse-engineering the framework’s assumptions or working around its limitations. GPUI inverts this. It provides no defaults. You build everything explicitly. This is more work up front, but it’s honest work. You understand what your interface does because you implemented it. When you need custom behavior, you don’t fight the framework. You just write it. For a biorhythm calculator, the effort is educational. For production tools like code editors, design software, or data visualization platforms, the control is essential. You can’t build Zed on top of Electron. The performance requirements, the interaction patterns, the customization needs are incompatible with browser abstractions. You need direct access to the GPU, immediate response to input, and rendering that does exactly what you specify. GPUI provides that. The learning curve is real. You implement cursor blinking manually. You handle TAB navigation explicitly. You manage focus state yourself. But once you internalize the patterns, you’re not learning a framework’s idiosyncrasies. You’re learning how desktop interfaces actually work. That knowledge transfers. It compounds. It makes you effective across platforms, across frameworks, across paradigms.
What Comes Next Part 3 will cover adaptive theming: detecting the platform, selecting appropriate styles, and rendering interfaces that feel native everywhere. Same code, different presentation. macOS gets its traffic lights and accent colors. Windows gets its own chrome. Linux respects the user’s GTK or KDE theme. After that, we’ll explore state management for complex interactions, custom rendering for performance-critical visualizations, and accessibility patterns that work without framework scaffolding. For now, you have input handling. Fields that accept text. Cursors that blink. Validation that provides feedback. Multiple windows that coordinate. These are the building blocks. Everything else builds on them. The complete source code for this tutorial is available here. Run it. Modify it. Break it deliberately. That’s how you learn what’s essential versus what’s convention. This is how native interfaces work when there’s nothing between your code and the screen.