Building Native Desktop Interfaces with Rust GPUI: Part 3
Native Look Parts 1 and 2 built a functional application. We have windows, input fields, validation, keyboard navigation, and smooth cursor blinking. The interface works, but it doesn’t quite belong. The colors are hardcoded. The accent blue we chose looks fine in light mode but clashes in dark mode. Users who’ve set their accent color to purple or green see our blue anyway. The application ignores the preferences they’ve already expressed through their system settings and a simple dialog box will look completely out of place on Windows:
This matters more than it seems. Users don’t consciously notice when applications respect their preferences, but they immediately feel when something is off. A light gray dialog in dark mode stands out like a switched-on lamp at midnight. An accent color that doesn’t match creates subtle cognitive dissonance. These aren’t dramatic failures, but they accumulate. An interface that ignores system preferences feels foreign, regardless of how well it functions.
Web applications get some of this for free. The browser queries prefers-color-scheme and exposes system fonts through CSS. You still have to implement dark mode, but at least you can detect it. Desktop applications need to go deeper. They need to read accent colors, query theme preferences, and adapt their appearance to match not just light versus dark, but the specific visual language of each operating system. GPUI doesn’t abstract this away. That’s deliberate. Each platform exposes theme information differently. macOS uses NSAppearance and NSColor. Windows uses the Desktop Window Manager and registry settings. Linux uses GTK or Qt theme files. These aren’t variations on a common abstraction. They’re fundamentally different approaches to the same problem. Any unified API would either lose platform-specific nuances or expose them through awkward compromises.
Instead, GPUI gives you the tools to query each platform directly. You write platform-specific code using #[cfg] attributes, and the compiler strips out everything irrelevant to the target platform. The result is a single codebase that produces truly native behavior on each system, zero runtime overhead for unused code, and explicit control over every platform’s quirks.
This post explores what that looks like in practice. We’ll read theme colors from macOS, Windows, and Linux. We’ll detect dark mode. We’ll integrate native OS dialogs for standard interactions. We’ll build menus that follow each platform’s conventions. By the end, the application will feel like it was written specifically for each operating system, even though the core logic remains completely platform-agnostic.
The Problem with Hardcoded Colors Before we solve the problem, let’s visualize the theme detection flow we’re building:
Look at the theme implementation from Part 2: impl Theme {
fn macos() -> Self {
Self {
background: rgb(0xEFEFEF).into(),
button_primary_bg: rgb(0x007AFF).into(),
input_border_focused: rgb(0x007AFF).into(),
text_primary: rgb(0x000000).into(),
// ... more hardcoded colors
}
}
}
This creates a consistent appearance, but it’s static. The blue is always #007AFF, the background is always #EFEFEF, the text is always black. That works if every user runs in light mode with default accent colors. But users don’t. Some work in dark mode. Some have set their accent to purple because they prefer it. Some have vision impairments and use high contrast themes. When your application ignores these preferences, you’re not just failing to adapt. You’re actively working against choices users have already made. The OS provides this information. Reading it isn’t optional for applications that want to feel native.
Reading macOS System Preferences macOS stores theme information in several places. The current appearance (light or dark) lives in NSAppearance. The user’s accent color comes from NSColor.controlAccentColor. To access these, we need to use Objective-C interop through the objc and cocoa crates. Here’s the sequence of API calls needed:
Add the dependencies with platform-specific configuration: [target.'cfg(target_os = "macos")'.dependencies] cocoa = "0.25" objc = "0.2"
These dependencies only compile on macOS. Windows and Linux builds never see them, which keeps binary sizes small and compilation fast.
Now implement the detection:
- [cfg(target_os = "macos")]
fn macos_system() -> Self {
use cocoa::appkit::NSAppearanceNameVibrantDark;
use cocoa::base::id;
use objc::{class, msg_send, sel, sel_impl};
unsafe {
// Get current appearance
let app: id = msg_send![class!(NSApplication), sharedApplication];
let appearance: id = msg_send![app, effectiveAppearance];
// Check if dark mode is active
let dark_name: id = NSAppearanceNameVibrantDark;
let best_match: id = msg_send![
appearance,
bestMatchFromAppearancesWithNames: &[dark_name]
];
let is_dark = !best_match.is_null();
// Get user's accent color
let accent_color = Self::get_macos_accent_color();
Self::macos_with_preferences(is_dark, accent_color)
}
}
This code uses msg_send! to call Objective-C methods. The class! macro looks up class objects by name. The pattern might look unusual if you’re not familiar with Objective-C interop, but it’s straightforward once you understand the structure: get the shared application, ask for its effective appearance, check if it matches the dark appearance name.
Reading the accent color is similar:
- [cfg(target_os = "macos")]
fn get_macos_accent_color() -> Option<u32> {
use cocoa::base::id;
use objc::{class, msg_send, sel, sel_impl};
unsafe {
// Get system accent color
let color: id = msg_send![class!(NSColor), controlAccentColor];
if color.is_null() {
return None;
}
// Convert to sRGB color space
let color_space: id = msg_send![class!(NSColorSpace), sRGBColorSpace];
let rgb_color: id = msg_send![color, colorUsingColorSpace: color_space];
if rgb_color.is_null() {
return None;
}
// Extract RGB components
let mut r: f64 = 0.0;
let mut g: f64 = 0.0;
let mut b: f64 = 0.0;
let _: () = msg_send![
rgb_color,
getRed:&mut r
green:&mut g
blue:&mut b
alpha:std::ptr::null_mut::<f64>()
];
// Convert to hex
let r_int = (r * 255.0) as u32;
let g_int = (g * 255.0) as u32;
let b_int = (b * 255.0) as u32;
Some((r_int << 16) | (g_int << 8) | b_int)
}
}
NSColor stores colors in various color spaces. We convert to sRGB, extract the red, green, and blue components as floats between 0 and 1, then convert to a hex integer. If the user has set their accent to purple, we get purple. If they’ve chosen green, we get green. The application adapts automatically. Now build themes based on this information: fn macos_with_preferences(is_dark: bool, accent_color: Option<u32>) -> Self {
let accent = accent_color.unwrap_or(0x007AFF); // Default to iOS blue
let accent_hover = Self::darken_color(accent, 0.9);
if is_dark {
Self {
background: rgb(0x1E1E1E).into(),
input_bg: rgb(0x2D2D2D).into(),
input_border: rgb(0x404040).into(),
input_border_focused: rgb(accent).into(),
text_primary: rgb(0xFFFFFF).into(),
button_primary_bg: rgb(accent).into(),
button_primary_bg_hover: rgb(accent_hover).into(),
// ... dark mode colors
}
} else {
Self {
background: rgb(0xEFEFEF).into(),
input_bg: rgb(0xFFFFFF).into(),
input_border: rgb(0xCCCCCC).into(),
input_border_focused: rgb(accent).into(),
text_primary: rgb(0x000000).into(),
button_primary_bg: rgb(accent).into(),
button_primary_bg_hover: rgb(accent_hover).into(),
// ... light mode colors
}
}
} The structure stays the same, but the values change based on what we learned from the system. Dark mode gets dark backgrounds and light text. Light mode gets light backgrounds and dark text. Both modes use the user’s chosen accent color for interactive elements.
Windows Theme Detection Windows stores theme information differently. The Desktop Window Manager provides colorization information. Dark mode detection requires reading registry keys or querying the DWM API directly. The implementation follows a similar pattern but uses different APIs: [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.58", features = [
"Win32_UI_WindowsAndMessaging", "Win32_Graphics_Dwm", "Win32_System_Registry", "Win32_Foundation"
] }
- [cfg(target_os = "windows")]
fn windows_system() -> Self {
use windows::Win32::Graphics::Dwm::DwmGetColorizationColor;
use windows::Win32::Foundation::BOOL;
unsafe {
let mut colorization: u32 = 0;
let mut opaque_blend: BOOL = BOOL(0);
let accent_color = if DwmGetColorizationColor(
&mut colorization,
&mut opaque_blend
).is_ok() {
// Extract RGB from 0xAARRGGBB format
Some(colorization & 0x00FFFFFF)
} else {
None
};
Self::windows_with_preferences(false, accent_color)
}
}
Windows colorization uses a different format than macOS. The value comes as 0xAARRGGBB where AA is alpha, RR is red, GG is green, BB is blue. We mask off the alpha channel to get the RGB components. Note that DwmGetColorizationColor requires a BOOL type for the second parameter (opaque_blend), not a standard i32. This is important—using the wrong type will cause compilation errors. The Windows API bindings are strongly typed to prevent misuse. Dark mode detection on Windows is more complex. You can read the AppsUseLightTheme registry key under HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize. The implementation depends on your requirements. For this example, we’ll default to light mode and let users extend it if they need full dark mode detection.
Linux and GTK Themes Linux is more fragmented. GNOME uses GTK, KDE uses Qt, and window managers often have their own theming systems. The most common approach is targeting GTK since it covers GNOME and many other environments: [target.'cfg(target_os = "linux")'.dependencies] gtk4 = "0.10"
- [cfg(target_os = "linux")]
fn linux_system() -> Self {
let accent_color = Self::get_gtk_accent_color(); let is_dark = Self::get_gtk_dark_mode(); Self::linux_with_preferences(is_dark, accent_color)
}
- [cfg(target_os = "linux")]
fn get_gtk_accent_color() -> Option<u32> {
use gtk4::prelude::*;
use gtk4::Settings;
if gtk4::init().is_err() {
return None;
}
let settings = Settings::default()?;
let theme_name = settings.gtk_theme_name()?;
// Map known themes to accent colors
if theme_name.contains("Adwaita") {
Some(0x3584E4) // GNOME blue
} else if theme_name.contains("elementary") {
Some(0x3689E6) // elementary OS blue
} else {
None
}
}
- [cfg(target_os = "linux")]
fn get_gtk_dark_mode() -> bool {
use gtk4::prelude::*;
use gtk4::Settings;
if gtk4::init().is_err() {
return false;
}
Settings::default()
.and_then(|s| s.gtk_application_prefer_dark_theme())
.unwrap_or(false)
}
GTK stores theme preferences in settings. We check the theme name and map known themes to their characteristic colors. This isn’t perfect — themes can be customized extensively — but it provides reasonable defaults for common configurations.
Fallback Strategies Every platform detection function needs a fallback. What happens when the API call fails? When we’re cross-compiling for a platform we’re not running on? When the user’s configuration is unusual? The pattern is consistent: provide sensible defaults and make the failure invisible to users. If we can’t read the accent color, use the platform’s standard blue. If we can’t detect dark mode, assume light mode. If we’re compiling for a platform we’re not on, provide static values that look reasonable.
- [cfg(not(target_os = "macos"))]
fn macos_system() -> Self {
Self::macos_with_preferences(false, None)
}
- [cfg(not(target_os = "windows"))]
fn windows_system() -> Self {
Self::windows_with_preferences(false, None)
}
- [cfg(not(target_os = "linux"))]
fn linux_system() -> Self {
Self::linux_with_preferences(false, None)
}
When you compile for macOS, the real macos_system() is used and the stub is stripped. When you compile for Windows, the stub is used and the real implementation disappears. The code in your repository contains all platforms, but each binary contains only what it needs.
Native Dialogs Theme detection shows one approach to OS integration: query system APIs and adapt your custom UI. Sometimes the right approach is different: skip custom UI entirely and use native OS dialogs.
Consider an About box. Every application has one. It shows the name, version, copyright, and maybe a brief description. The content changes, but the structure is universal. Building a custom About dialog with GPUI is straightforward, but why? Users expect About dialogs to look like system dialogs. They expect them to respect accessibility settings, support screen readers, and follow platform conventions automatically. The native-dialog crate provides cross-platform access to native OS dialogs: [dependencies] native-dialog = "0.7" use native_dialog::{MessageDialog, MessageType};
fn show_about(_: &ShowAbout, _cx: &mut App) {
MessageDialog::new()
.set_type(MessageType::Info)
.set_title("About Biorhythm Calculator")
.set_text(
"GPUI Biorhythm Calculator v0.1.0\n\n\
A demonstration of cross-platform UI with GPUI.\n\n\
Features:\n\
• Adaptive theming with OS color detection\n\
• Native menu integration\n\
• Platform-specific styling\n\
• Dark mode support\n\n\
Built with GPUI\n\
https://github.com/zed-industries/gpui"
)
.show_alert()
.unwrap();
}
This produces a native alert on every platform:
- macOS: NSAlert with the system alert icon, rounded corners, and the standard blue button
- Windows: MessageBox with the information icon and OK button
- Linux: GTK MessageDialog following the active theme
The appearance adapts automatically. No conditional styling needed. No platform-specific layout code. The dialog just looks right because it’s the actual system dialog.
When should you use native dialogs versus custom GPUI views? Use native dialogs for:
- About boxes: Standard content, no custom interaction needed
- Simple alerts: “Are you sure?” confirmations, error messages
- File pickers: Open/Save dialogs with system integration
- Standard interactions: Anything users expect to look like every other app
Use custom GPUI views for:
- Complex forms: Multiple fields, custom validation, rich interaction
- Branded experiences: When consistent appearance across platforms matters more than native feel
- Custom controls: Anything beyond what native dialogs provide
- Integrated UI: When the dialog needs to match your application’s visual language
The decision isn’t ideological. It’s practical. Native dialogs give you accessibility, platform conventions, and zero maintenance for standard interactions. Custom views give you control, consistency, and the ability to create interactions native dialogs can’t provide. Use both where they make sense.
Platform-Specific Menus Menus demonstrate platform differences more clearly than almost any other UI element. The differences aren’t subtle variations. They’re fundamental disagreements about where menus live, how they’re organized, and what they contain.
macOS Menus On macOS, the menu bar sits at the top of the screen. It’s global. All windows in your application share it. The first menu is always the Application menu, named after your app, containing app-level commands:
The system adds some items automatically (Hide, Show All). You provide the rest. Services is a special submenu that macOS populates with system-wide services like “New Email with Selection” or “Search with Google.”
After the Application menu come document and editing menus: File, Edit, Format, View, Window, Help. This structure is so standard that users navigate by muscle memory. Put Preferences anywhere except the Application menu and users won’t find it.
Windows Menus Windows has no application menu. Menu bars live in each window, below the title bar. Standard menus are File, Edit, View, Insert, Format, Tools, Help. App-level items scatter:
- Exit: In File menu (last item after a separator)
- Options/Preferences: In Tools or Edit menu
- About: In Help menu (rightmost menu)
Keyboard access works differently. Alt activates the menu bar. Underlined letters provide shortcuts: Alt+F opens File, Alt+H opens Help. Within menus, just press the letter — no modifier needed. The visual style is different too. Menus sit inside the window chrome. When you maximize a window, the menu bar stretches with it. When you have multiple windows, each has its own menu bar showing its own state.
Linux Menus Linux is fragmented. GNOME moved to a macOS-style global menu bar in Unity, then moved back to app menus in the overview, then moved to hamburger menus in many apps. KDE keeps traditional Windows-style menu bars. Xfce follows Windows conventions. There’s no single answer. The safest approach for cross-platform applications: follow Windows conventions on Linux. Put Exit in File, Preferences in Edit, About in Help. Most desktop environments handle this correctly even if they don’t look like Windows.
GPUI Menu Implementation GPUI provides cx.set_menus() which abstracts the platform-specific APIs: cx.set_menus(vec![Menu {
name: "Biorhythm Calculator".into(),
items: vec![
MenuItem::action("About Biorhythm Calculator", ShowAbout),
MenuItem::separator(),
MenuItem::os_submenu("Services", SystemMenuType::Services),
MenuItem::separator(),
MenuItem::action("Quit", Quit),
],
}]);
On macOS, this creates an NSMenu with the Application menu structure. On Windows and Linux, you’d typically split this into File and Help menus. The right approach is platform-specific configuration:
- [cfg(target_os = "macos")]
fn setup_menus(cx: &mut App) {
cx.set_menus(vec![
Menu {
name: "Biorhythm Calculator".into(),
items: vec![
MenuItem::action("About Biorhythm Calculator", ShowAbout),
MenuItem::separator(),
MenuItem::os_submenu("Services", SystemMenuType::Services),
MenuItem::separator(),
MenuItem::action("Quit", Quit),
],
},
// Additional menus...
]);
}
- [cfg(not(target_os = "macos"))]
fn setup_menus(cx: &mut App) {
cx.set_menus(vec![
Menu {
name: "File".into(),
items: vec![
MenuItem::action("Exit", Quit),
],
},
Menu {
name: "Help".into(),
items: vec![
MenuItem::action("About Biorhythm Calculator", ShowAbout),
],
},
]);
} This creates the right structure for each platform. macOS users get their Application menu. Windows and Linux users get File and Help menus. Both groups see what they expect. Actions connect menus to behavior: actions!(biorhythm, [Quit, ShowAbout]);
cx.on_action(quit); cx.on_action(show_about); cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
When a user clicks the menu item or presses the keyboard shortcut, GPUI dispatches the action to your handler. The same code handles both input methods. You don’t write separate logic for menu clicks versus keyboard shortcuts. The action system unifies them. Here’s how the action system connects everything:
What This Teaches About Native Development Reading system preferences, using native dialogs, and organizing menus correctly aren’t just polish. They’re fundamental to what makes applications feel native. Users don’t think in terms of “this respects my accent color” or “this menu follows platform conventions.” They think in terms of “this feels right” or “this feels wrong.”
The web taught developers that consistent appearance across platforms is paramount. Use the same colors, the same fonts, the same layouts everywhere. This makes sense for web applications where the platform is the browser. Desktop applications run on operating systems. Users have already made choices about how their system should look. Ignoring those choices doesn’t create consistency. It creates dissonance.
Platform-specific code isn’t a burden to be minimized. It’s an opportunity to respect user preferences and follow established conventions. GPUI’s approach — write once where logic is universal, write three times where behavior should differ — produces applications that feel native precisely because they adapt to each environment instead of imposing a foreign visual language.
The techniques here scale beyond theme colors and menu organization. File system access differs by platform. Clipboard handling has platform-specific quirks. Window management follows different rules. Font rendering uses different APIs. Every desktop framework faces these differences. The question is whether the framework hides them behind a lowest-common-denominator abstraction or gives you the tools to handle them correctly.
GPUI chooses direct access. That means more code in some cases. It also means applications that feel appropriate on every platform instead of foreign on all of them. That’s not an academic distinction. It’s the difference between interfaces users tolerate and interfaces they trust. On Windows our little app will look like this: Press enter or click to view image in full size
Not bad for a single code base, uh?
What’s Next Parts 1 through 3 built a complete application: windows, input handling, validation, keyboard navigation, theme detection, native dialogs, and platform-specific menus. We’ve covered the foundational techniques that apply to almost every desktop application. The next post will explore what makes this architecture interesting for complex applications: state management that spans multiple windows, efficient rendering when data changes frequently, and techniques for keeping interfaces responsive when computations take time. We’ll look at how GPUI’s reactivity model handles state updates, when to use entities versus plain Rust types, and how to structure applications that scale beyond simple dialogs. The goal remains the same: build interfaces that feel native, respond instantly, and give you complete control over behavior. GPUI provides the primitives. You decide what to build with them.