
Designing Gnome Ui
- 83 installs
- 2 repo stars
- Updated August 1, 2026
- mhagrelius/dotfiles
Helps with design & ui/ux tasks.
About
designing-gnome-ui is a Claude Code skill for design & ui/ux. It helps solo builders move faster with AI-assisted coding.
- designing-gnome-ui
- Design & UI/UX
- AI-coding skill
Designing Gnome Ui by the numbers
- 83 all-time installs (skills.sh)
- +4 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,167 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mhagrelius/dotfiles --skill designing-gnome-uiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 83 |
|---|---|
| repo stars | ★ 2 |
| Last updated | August 1, 2026 |
| Repository | mhagrelius/dotfiles ↗ |
What it does
Helps with design & ui/ux tasks.
Files
Designing GNOME UI
Design GNOME UIs that are HIG-compliant, polished, and user-centered.
Core principle: No UI code without design decisions. Pattern selection and quality verification happen before implementation.
Quality layers: Compliance (follows HIG) → Polish (feels premium) → Rigor (handles edge cases)
Companion skill: For app architecture (lifecycle, threading, GSettings, actions, packaging), use developing-gtk-apps.
What's New (libadwaita 1.6-1.8)
| Need | Widget/API | Notes |
|---|---|---|
| Exclusive toggles (view mode) | AdwToggleGroup | Replaces multiple GtkToggleButton |
| Loading indicator | AdwSpinner | Works with animations disabled |
| Persistent bottom controls | AdwBottomSheet | Music player, persistent actions |
| Wrapping content (tags) | AdwWrapBox | Auto-wraps like text |
| Inline view switching | AdwInlineViewSwitcher | For cards, sidebars |
| Keyboard shortcuts | AdwShortcutsDialog | Replaces deprecated GtkShortcutsWindow |
| System accent color | Automatic | Apps follow desktop preference via portal |
| System fonts | AdwStyleManager | Access monospace/document fonts |
Deprecations: .dim-label → use .dimmed class
# AdwToggleGroup - view mode switching
toggle_group = Adw.ToggleGroup()
toggle_group.add(Adw.Toggle(icon_name="view-grid-symbolic", name="grid"))
toggle_group.add(Adw.Toggle(icon_name="view-list-symbolic", name="list"))
toggle_group.connect("notify::active-name", lambda g, p: set_view(g.get_active_name()))
header.pack_start(toggle_group)
# AdwBottomSheet - music player controls
bottom_sheet = Adw.BottomSheet()
bottom_sheet.set_content(main_content)
bottom_sheet.set_sheet(player_controls)
bottom_sheet.set_open(True) # Show sheet
window.set_content(bottom_sheet)
# AdwWrapBox - tag display
wrap_box = Adw.WrapBox(spacing=6)
for tag in ["Python", "GTK", "GNOME", "libadwaita"]:
chip = Gtk.Label(label=tag)
chip.add_css_class("chip") # Custom styling
wrap_box.append(chip)
# System fonts (1.7+) - for code editors, document views
style_manager = Adw.StyleManager.get_default()
mono_font = style_manager.get_monospace_font_name() # User's preferred mono font
doc_font = style_manager.get_document_font_name() # User's preferred document font
# Also available as CSS: --monospace-font-family, --document-font-familyThe Process
digraph gnome_ui_process {
rankdir=LR;
node [shape=box];
"UI Task" -> "1. Context" -> "2. Patterns" -> "3. Details" -> "4. Checklist" -> "Implement";
"4. Checklist" -> "2. Patterns" [label="issues" style=dashed];
}1. Context: User goal, app type, constraints (screen size, input) 2. Patterns: Select containers, navigation, controls, feedback 3. Details: Typography, spacing, icons, writing style 4. Checklist: Verify compliance, polish, rigor before code
Container Selection
digraph containers {
rankdir=TB;
node [shape=box];
"Building what?" [shape=diamond];
"AdwApplicationWindow + HeaderBar" [style=filled fillcolor=lightgreen];
"AdwPreferencesWindow" [style=filled fillcolor=lightgreen];
"AdwDialog" [style=filled fillcolor=lightgreen];
"Building what?" -> "AdwApplicationWindow + HeaderBar" [label="main window"];
"Building what?" -> "AdwPreferencesWindow" [label="settings"];
"Building what?" -> "AdwDialog" [label="modal action"];
}| Scenario | Default | Notes |
|---|---|---|
| App window | AdwApplicationWindow + AdwHeaderBar | Remember user size, start ~800x600 |
| Settings | AdwPreferencesWindow | Handles groups, search, subpages |
| List of items | AdwPreferencesGroup with rows | Boxed list style |
| Primary action | Single button, header bar end | suggested-action class if emphasized |
| Destructive action | destructive-action class | Requires undo or confirmation |
Navigation Selection
| Structure | Default Pattern |
|---|---|
| Single view | None needed |
| 2-4 views | AdwViewSwitcher in header bar |
| Many/dynamic views | AdwNavigationSplitView (sidebar) |
| Hierarchical | AdwNavigationView (drill-down) |
Control Defaults
| Need | Default | Avoid |
|---|---|---|
| On/Off | AdwSwitchRow | Checkbox for settings |
| Choose one (few) | AdwComboRow | Radio buttons outside dialogs |
| Choose one (many) | AdwComboRow + search | Long unsearchable dropdowns |
| Text input | AdwEntryRow | Bare GtkEntry |
| Multiline text | GtkTextView + card class | Bare unstyled text view |
| Number | AdwSpinRow | Text entry for numbers |
| Date | GtkCalendar in popover | Text entry for dates |
| Action in list | AdwActionRow + suffix button | Multiple buttons per row |
| Search | GtkSearchBar + toggle button | Always-visible search box |
Search Bar Pattern
# Search bar slides down from header, toggle with button or Ctrl+F
search_bar = Gtk.SearchBar()
search_entry = Gtk.SearchEntry()
search_bar.set_child(search_entry)
search_bar.connect_entry(search_entry)
search_bar.set_key_capture_widget(window) # Type-to-search
# Toggle button in header bar
search_btn = Gtk.ToggleButton(icon_name="system-search-symbolic")
search_btn.set_tooltip_text("Search")
search_bar.bind_property("search-mode-enabled", search_btn, "active",
GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE)
header.pack_end(search_btn)
toolbar_view.add_top_bar(search_bar)Form Validation Pattern
# Use error CSS class on invalid fields
def validate_entry(row):
text = row.get_text()
if not text or len(text) < 3:
row.add_css_class("error")
row.set_tooltip_text("Name must be at least 3 characters")
return False
row.remove_css_class("error")
row.set_tooltip_text("")
return True
name_row.connect("changed", lambda r: validate_entry(r))Validation timing: On change for format checks, on focus-out for expensive checks, on submit for final validation.
List Widget Selection
| Content | Widget | Why |
|---|---|---|
| Settings/preferences | AdwPreferencesGroup | Boxed list style, handles rows |
| Navigation list (sidebar) | GtkListBox | Selection support, activatable rows |
| Large/dynamic data | GtkListView | Virtual scrolling, performance |
| Grid of items | GtkGridView | Thumbnail grids, icon views |
Selection modes: Use Gtk.SingleSelection for navigation, Gtk.MultiSelection for bulk actions. Toggle selection mode with header bar button + action bar for bulk operations. See reference for code patterns.
Iconography
Rules:
- Symbolic icons only (outline, monochrome) - never full-color in UI
- Source from GNOME Icon Library (
icon-libraryapp) - Header bar: icon-only buttons, always add tooltips
- Naming:
action-object-symbolic(e.g.,list-add-symbolic) - Dynamic icons: Update icon name based on state (e.g.,
user-trash-symbolic→user-trash-full-symbolic)
| Action | Icon |
|---|---|
| Add/New | list-add-symbolic |
| Delete | user-trash-symbolic |
| Settings | emblem-system-symbolic |
| Menu | open-menu-symbolic |
| Search | system-search-symbolic |
| Edit | document-edit-symbolic |
| Back | go-previous-symbolic |
| Drill-down | go-next-symbolic |
| Sync | emblem-synchronizing-symbolic |
| Offline | network-offline-symbolic |
| Warning | dialog-warning-symbolic |
| Error | dialog-error-symbolic |
| Select mode | selection-mode-symbolic |
| Check/Done | emblem-ok-symbolic |
| Close | window-close-symbolic |
| Refresh | view-refresh-symbolic |
Feedback Selection
digraph feedback {
rankdir=TB;
node [shape=box];
"What happened?" [shape=diamond];
"Transient or persistent?" [shape=diamond];
"AdwToast" [style=filled fillcolor=lightgreen label="AdwToast (default)"];
"AdwBanner" [style=filled fillcolor=lightyellow];
"AdwDialog" [style=filled fillcolor=lightpink];
"Progress/Spinner" [style=filled fillcolor=lightblue];
"What happened?" -> "Transient or persistent?" [label="state/error"];
"What happened?" -> "AdwDialog" [label="needs decision"];
"What happened?" -> "Progress/Spinner" [label="ongoing operation"];
"Transient or persistent?" -> "AdwToast" [label="transient event"];
"Transient or persistent?" -> "AdwBanner" [label="persistent state"];
}| Scenario | Default | Details |
|---|---|---|
| Action done | AdwToast | Short message, optional undo |
| Destructive action | AdwToast + undo | Prefer over confirmation dialog |
| Error (recoverable) | AdwToast | Brief, auto-retry silently |
| Error (blocking) | AdwDialog | Explain problem and required fix |
| Persistent state | AdwBanner | Offline, degraded mode, auth required |
| Needs decision | AdwDialog | Conflicts, irreversible actions |
| Short wait (<5s) | AdwSpinner | No progress bar |
| Long operation (>30s) | Progress bar + text | "13 of 42 processed" |
Error escalation: Toast (transient) → Banner (persists) → Dialog (requires action)
- Network blip: Toast, auto-retry
- Prolonged offline: Banner with "Retry" button
- Auth expired: Dialog + Banner until resolved
Dialog rules:
- Cancel button first (left), action button last (right)
- Specific verbs ("Delete", "Save"), never "OK" or "Yes"
- Destructive actions use
destructive-actionstyle
Context menus: Use GtkPopoverMenu for right-click actions (remove, rename, properties). Keep menus short; move complex actions to dialogs.
Empty State Pattern
# Show placeholder when list is empty
empty_state = Adw.StatusPage(
icon_name="folder-symbolic",
title="No Projects",
description="Create a project to get started"
)
create_btn = Gtk.Button(label="Create Project")
create_btn.add_css_class("pill")
create_btn.add_css_class("suggested-action")
empty_state.set_child(create_btn)
# Use stack to switch between list and empty state
stack.add_named(list_view, "content")
stack.add_named(empty_state, "empty")
stack.set_visible_child_name("empty" if model.get_n_items() == 0 else "content")Quality Checklist
Create TodoWrite items for each applicable check before implementing.
Layer 1: Compliance
- [ ] Correct container type and header bar structure
- [ ] Navigation pattern matches content structure
- [ ] Standard widgets used (not custom where native exists)
- [ ] Symbolic icons from GNOME Icon Library
- [ ] Typography uses style classes (
title-1,heading,body,caption) - [ ] Libadwaita spacing defaults (no custom margins)
- [ ] Header capitalization for labels, sentence for descriptions
Layer 2: Polish
- [ ] Clear visual hierarchy - important elements prominent
- [ ] Controls and text properly aligned
- [ ] Consistent patterns throughout
- [ ] Empty states have placeholder page (icon + message + action)
- [ ] Loading states show spinner/skeleton, never frozen UI
- [ ] Smooth resize and view transitions
- [ ] Comfortable density - not cramped, not sparse
Layer 3: Rigor
- [ ] All controls keyboard-accessible (Tab, Enter, Space)
- [ ] All elements have accessible names for screen readers
- [ ] Works with high contrast (
GTK_THEME=Adwaita:hc) - [ ] Works with 200% text scaling
- [ ] Error handling for every input/action
- [ ] Edge cases handled (empty lists, long text, missing data)
- [ ] Destructive actions have undo where possible
- [ ] Responsive: works at 800x600, adapts to larger
Accessibility Quick Check
# Test high contrast
GTK_THEME=Adwaita:hc ./myapp
# Test large text (set in GNOME Settings > Accessibility first)
# Test with screen reader
orca &
./myapp
# Keyboard-only: unplug mouse, navigate entire app with Tab/Enter/SpaceCode: Set accessible labels for icon-only buttons and images:
button.update_property([Gtk.AccessibleProperty.LABEL], ["Add new item"])
image.update_property([Gtk.AccessibleProperty.LABEL], ["Project thumbnail"])Red Flags - STOP
- Custom styling where libadwaita has a pattern
- Multiple "suggested" or "destructive" buttons per view
- Confirmation dialogs for reversible actions (use undo)
- Text over images or textured backgrounds
- Non-GNOME icons without strong justification
- Missing tooltips on icon-only header bar buttons
- Generic labels ("OK", "Yes", "No", "Submit")
- Frozen UI during operations (missing loading states)
Non-GTK Apps (Qt/PySide6)
When styling Qt apps for GNOME:
- Use Adwaita-qt or manual QSS matching Adwaita colors
- Follow same patterns conceptually (header bar → toolbar, etc.)
- Match spacing, typography scale, and icon style
- Test alongside native GNOME apps for consistency
Reference Files
| Need | File |
|---|---|
| Basic UI patterns | gnome-hig-reference.md |
| Advanced patterns | gnome-advanced-patterns.md |
gnome-hig-reference.md - Read for most apps:
- Container, navigation, control, feedback patterns with code
- Search bar, form validation, filter models, grid views, selection modes
- File chooser dialogs, dark/light mode, responsive breakpoints
- Primary menu structure, About dialog, Shortcuts window
- Typography, writing style, CSS color variables, common mistakes
- Accessibility testing commands (high contrast, screen reader)
- Phone/tablet breakpoints, adaptive layouts
gnome-advanced-patterns.md - Read when building:
- Drag & drop (reordering, file drops, cross-widget DnD)
- Undo/Redo (command pattern, history management)
- Tabs (AdwTabView, multi-document apps)
- System notifications (GNotification vs Toast)
- Media display (image viewers, video controls, pinch-to-zoom gestures)
- Split/Paned views (resizable panels)
- Welcome/Onboarding (first-run, feature callouts)
- Popovers (tool palettes, color pickers)
- Keyboard shortcuts (mnemonics, shortcut controllers)
GNOME Advanced UI Patterns
Advanced patterns for complex GNOME applications. Read this file when building apps with drag & drop, undo/redo, tabs, media display, or complex adaptive layouts.
When to Use This File
| Building | Read This |
|---|---|
| File manager, photo organizer | Drag & Drop, Grid Selection |
| Text/document editor | Undo/Redo, Tabs |
| Browser-style multi-document app | Tabs |
| Media player, image viewer | Media Display |
| Complex dashboard | Split Views, Popovers |
| First-run experience | Welcome/Onboarding |
Drag and Drop
Basic Drag Source
# Make a widget draggable
drag_source = Gtk.DragSource()
drag_source.set_actions(Gdk.DragAction.MOVE)
def on_prepare(source, x, y):
# Return the data to drag
item = get_item_at_position(x, y)
return Gdk.ContentProvider.new_for_value(item)
def on_drag_begin(source, drag):
# Create drag icon
icon = Gtk.DragIcon.get_for_drag(drag)
icon.set_child(create_drag_preview(source.get_widget()))
drag_source.connect("prepare", on_prepare)
drag_source.connect("drag-begin", on_drag_begin)
widget.add_controller(drag_source)Drop Target
# Accept drops on a widget
drop_target = Gtk.DropTarget.new(MyItem, Gdk.DragAction.MOVE)
def on_drop(target, value, x, y):
# Handle the dropped item
item = value
insert_at_position(item, x, y)
return True # Drop accepted
def on_motion(target, x, y):
# Show drop indicator
show_drop_indicator_at(x, y)
return Gdk.DragAction.MOVE
drop_target.connect("drop", on_drop)
drop_target.connect("motion", on_motion)
drop_target.connect("leave", lambda t: hide_drop_indicator())
container.add_controller(drop_target)Reorderable List
class ReorderableListBox(Gtk.ListBox):
def __init__(self):
super().__init__()
self.dragged_row = None
self.setup_dnd()
def setup_dnd(self):
# Each row needs drag source
pass # Set up in row creation
def create_row(self, item):
row = Adw.ActionRow(title=item.title)
# Drag handle
handle = Gtk.Image(icon_name="list-drag-handle-symbolic")
handle.add_css_class("drag-handle")
row.add_prefix(handle)
# Drag source on handle only
drag_source = Gtk.DragSource()
drag_source.set_actions(Gdk.DragAction.MOVE)
def on_prepare(source, x, y):
self.dragged_row = row
return Gdk.ContentProvider.new_for_value(item)
def on_end(source, drag, delete):
self.dragged_row = None
drag_source.connect("prepare", on_prepare)
drag_source.connect("drag-end", on_end)
handle.add_controller(drag_source)
# Drop target on row
drop_target = Gtk.DropTarget.new(type(item), Gdk.DragAction.MOVE)
def on_drop(target, value, x, y):
if self.dragged_row == row:
return False
# Reorder items
self.reorder_item(value, item)
return True
drop_target.connect("drop", on_drop)
row.add_controller(drop_target)
return row
def reorder_item(self, dragged_item, target_item):
# Update model order
passFile Drop from External Apps
# Accept file drops from file manager
drop_target = Gtk.DropTarget.new(Gdk.FileList, Gdk.DragAction.COPY)
def on_drop(target, value, x, y):
files = value.get_files()
for file in files:
path = file.get_path()
import_file(path)
return True
drop_target.connect("drop", on_drop)
window.add_controller(drop_target)Undo/Redo
Command Pattern
from abc import ABC, abstractmethod
from collections import deque
class Command(ABC):
@abstractmethod
def execute(self):
pass
@abstractmethod
def undo(self):
pass
@property
def description(self):
return "Action"
class UndoManager:
def __init__(self, max_history=100):
self.undo_stack = deque(maxlen=max_history)
self.redo_stack = deque(maxlen=max_history)
self.on_changed = None # Callback for UI updates
def execute(self, command):
command.execute()
self.undo_stack.append(command)
self.redo_stack.clear()
self._notify()
def undo(self):
if not self.can_undo():
return
command = self.undo_stack.pop()
command.undo()
self.redo_stack.append(command)
self._notify()
def redo(self):
if not self.can_redo():
return
command = self.redo_stack.pop()
command.execute()
self.undo_stack.append(command)
self._notify()
def can_undo(self):
return len(self.undo_stack) > 0
def can_redo(self):
return len(self.redo_stack) > 0
def _notify(self):
if self.on_changed:
self.on_changed()Example Commands
class InsertTextCommand(Command):
def __init__(self, buffer, position, text):
self.buffer = buffer
self.position = position
self.text = text
def execute(self):
iter = self.buffer.get_iter_at_offset(self.position)
self.buffer.insert(iter, self.text)
def undo(self):
start = self.buffer.get_iter_at_offset(self.position)
end = self.buffer.get_iter_at_offset(self.position + len(self.text))
self.buffer.delete(start, end)
@property
def description(self):
return "Insert text"
class DeleteItemCommand(Command):
def __init__(self, list_store, item, index):
self.list_store = list_store
self.item = item
self.index = index
def execute(self):
self.list_store.remove(self.index)
def undo(self):
self.list_store.insert(self.index, self.item)
@property
def description(self):
return f"Delete {self.item.title}"Connecting to UI
class App(Adw.Application):
def __init__(self):
super().__init__()
self.undo_manager = UndoManager()
self.undo_manager.on_changed = self.update_undo_actions
def setup_actions(self):
undo_action = Gio.SimpleAction.new("undo", None)
undo_action.connect("activate", lambda a, p: self.undo_manager.undo())
self.add_action(undo_action)
redo_action = Gio.SimpleAction.new("redo", None)
redo_action.connect("activate", lambda a, p: self.undo_manager.redo())
self.add_action(redo_action)
# Keyboard shortcuts
self.set_accels_for_action("app.undo", ["<Control>z"])
self.set_accels_for_action("app.redo", ["<Control><Shift>z"])
def update_undo_actions(self):
self.lookup_action("undo").set_enabled(self.undo_manager.can_undo())
self.lookup_action("redo").set_enabled(self.undo_manager.can_redo())Tabs (AdwTabView)
Basic Tab Setup
# Tab view for multi-document interface
tab_view = Adw.TabView()
# Tab bar in header
tab_bar = Adw.TabBar()
tab_bar.set_view(tab_view)
header.set_title_widget(tab_bar)
# Tab overview (grid view of all tabs)
tab_overview = Adw.TabOverview()
tab_overview.set_view(tab_view)
tab_overview.set_child(toolbar_view) # Wrap main content
# Overview button
overview_btn = Adw.TabButton()
overview_btn.set_view(tab_view)
header.pack_end(overview_btn)Creating Tabs
def new_tab(title="New Tab", content=None):
if content is None:
content = create_default_content()
page = tab_view.append(content)
page.set_title(title)
page.set_icon(Gio.ThemedIcon.new("text-x-generic-symbolic"))
# Make closable
tab_view.set_page_pinned(page, False)
# Select the new tab
tab_view.set_selected_page(page)
return page
def close_tab(page):
tab_view.close_page(page)Tab Signals
# Handle tab close request (confirm if unsaved)
def on_close_page(view, page):
if page_has_unsaved_changes(page):
show_save_dialog(page)
return Gdk.EVENT_STOP # Prevent close
return Gdk.EVENT_PROPAGATE # Allow close
tab_view.connect("close-page", on_close_page)
# Handle tab selection change
def on_selected_changed(view, param):
page = view.get_selected_page()
if page:
update_window_title(page.get_title())
tab_view.connect("notify::selected-page", on_selected_changed)
# Handle tab reorder
tab_view.connect("page-reordered", lambda v, p, pos: save_tab_order())Tab Context Menu
def on_setup_menu(view, page):
if page is None:
return
menu = Gio.Menu()
menu.append("Duplicate Tab", "tab.duplicate")
menu.append("Pin Tab", "tab.pin")
menu.append("Close Other Tabs", "tab.close-others")
view.set_menu_model(menu)
tab_view.connect("setup-menu", on_setup_menu)System Notifications
When to Use
| Feedback Type | Use Case |
|---|---|
| Toast | In-app events, user is actively using app |
| Banner | Persistent in-app state |
| Notification | Events when app is backgrounded, important alerts |
Basic Notification
def send_notification(title, body, action_name=None):
notification = Gio.Notification.new(title)
notification.set_body(body)
notification.set_priority(Gio.NotificationPriority.NORMAL)
# Optional action when clicked
if action_name:
notification.set_default_action(f"app.{action_name}")
# Send with unique ID (for updating/withdrawing)
app.send_notification("download-complete", notification)
# Example: Download complete
send_notification(
"Download Complete",
"report.pdf has finished downloading",
"show-downloads"
)Notification with Actions
notification = Gio.Notification.new("New Message")
notification.set_body("Alice: Hey, are you free?")
# Action buttons
notification.add_button("Reply", "app.reply::alice")
notification.add_button("Mark Read", "app.mark-read::alice")
# Click action (opens conversation)
notification.set_default_action("app.open-conversation::alice")
app.send_notification("message-alice", notification)Withdrawing Notifications
# Remove notification (e.g., when user views the content)
app.withdraw_notification("message-alice")Notification Priority
# Low: Background info, can wait
notification.set_priority(Gio.NotificationPriority.LOW)
# Normal: Default
notification.set_priority(Gio.NotificationPriority.NORMAL)
# High: Time-sensitive (shows as banner)
notification.set_priority(Gio.NotificationPriority.HIGH)
# Urgent: Critical alerts (persistent until dismissed)
notification.set_priority(Gio.NotificationPriority.URGENT)Popovers (Non-Menu)
Tool Palette
def create_tool_popover():
popover = Gtk.Popover()
grid = Gtk.Grid(column_spacing=6, row_spacing=6)
grid.set_margin_top(12)
grid.set_margin_bottom(12)
grid.set_margin_start(12)
grid.set_margin_end(12)
tools = [
("edit-select-symbolic", "Select"),
("edit-cut-symbolic", "Cut"),
("draw-brush-symbolic", "Brush"),
("draw-eraser-symbolic", "Eraser"),
]
for i, (icon, tooltip) in enumerate(tools):
btn = Gtk.ToggleButton()
btn.set_icon_name(icon)
btn.set_tooltip_text(tooltip)
btn.add_css_class("flat")
grid.attach(btn, i % 2, i // 2, 1, 1)
popover.set_child(grid)
return popoverColor Picker Popover
def create_color_popover():
popover = Gtk.Popover()
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_margin_top(12)
box.set_margin_bottom(12)
box.set_margin_start(12)
box.set_margin_end(12)
# Color chooser
color_chooser = Gtk.ColorChooserWidget()
color_chooser.set_use_alpha(True)
box.append(color_chooser)
# Apply button
apply_btn = Gtk.Button(label="Apply")
apply_btn.add_css_class("suggested-action")
box.append(apply_btn)
popover.set_child(box)
return popoverPopover Best Practices
# Size constraints - keep small
popover.set_size_request(200, -1) # Max width, natural height
# No close button needed - click outside dismisses
# Position relative to button
button = Gtk.MenuButton(icon_name="color-select-symbolic")
button.set_popover(color_popover)
# Or manual positioning
popover.set_parent(widget)
popover.set_position(Gtk.PositionType.BOTTOM)
popover.popup()Media Display
Image Viewer
class ImageViewer(Gtk.Box):
def __init__(self):
super().__init__(orientation=Gtk.Orientation.VERTICAL)
# Scrollable picture
self.scrolled = Gtk.ScrolledWindow()
self.scrolled.set_vexpand(True)
self.picture = Gtk.Picture()
self.picture.set_can_shrink(True)
self.picture.set_content_fit(Gtk.ContentFit.CONTAIN)
self.scrolled.set_child(self.picture)
self.append(self.scrolled)
# Zoom controls
self.zoom_level = 1.0
self.setup_zoom_gestures()
def load_image(self, path):
self.picture.set_filename(path)
def setup_zoom_gestures(self):
# Scroll to zoom
scroll = Gtk.EventControllerScroll()
scroll.set_flags(Gtk.EventControllerScrollFlags.VERTICAL)
def on_scroll(controller, dx, dy):
if controller.get_current_event_state() & Gdk.ModifierType.CONTROL_MASK:
self.zoom(1.0 - dy * 0.1)
return True
return False
scroll.connect("scroll", on_scroll)
self.add_controller(scroll)
# Pinch to zoom
zoom_gesture = Gtk.GestureZoom()
zoom_gesture.connect("scale-changed", lambda g, s: self.zoom(s))
self.add_controller(zoom_gesture)
def zoom(self, factor):
self.zoom_level = max(0.1, min(10.0, self.zoom_level * factor))
# Apply zoom via CSS transform or picture sizingVideo Player Controls
class VideoControls(Gtk.Box):
def __init__(self, media_stream):
super().__init__(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
self.add_css_class("toolbar")
self.stream = media_stream
# Play/Pause
self.play_btn = Gtk.Button(icon_name="media-playback-start-symbolic")
self.play_btn.connect("clicked", self.toggle_play)
self.append(self.play_btn)
# Progress slider
self.progress = Gtk.Scale.new_with_range(
Gtk.Orientation.HORIZONTAL, 0, 100, 1
)
self.progress.set_hexpand(True)
self.progress.set_draw_value(False)
self.progress.connect("value-changed", self.on_seek)
self.append(self.progress)
# Time label
self.time_label = Gtk.Label(label="0:00 / 0:00")
self.time_label.add_css_class("numeric")
self.append(self.time_label)
# Volume
self.volume_btn = Gtk.VolumeButton()
self.volume_btn.set_value(1.0)
self.append(self.volume_btn)
# Fullscreen
self.fullscreen_btn = Gtk.Button(icon_name="view-fullscreen-symbolic")
self.append(self.fullscreen_btn)
def toggle_play(self, button):
if self.stream.get_playing():
self.stream.pause()
self.play_btn.set_icon_name("media-playback-start-symbolic")
else:
self.stream.play()
self.play_btn.set_icon_name("media-playback-pause-symbolic")Split/Paned Views
Resizable Split View
# Horizontal split (side by side)
paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
# Left panel
left_panel = Gtk.Box()
paned.set_start_child(left_panel)
paned.set_shrink_start_child(False) # Don't allow shrinking to 0
paned.set_resize_start_child(False) # Fixed width left panel
# Right panel
right_panel = Gtk.Box()
paned.set_end_child(right_panel)
paned.set_shrink_end_child(False)
paned.set_resize_end_child(True) # Right panel gets extra space
# Set initial position (pixels from start)
paned.set_position(250)
# Save/restore position
def on_position_changed(paned, param):
settings.set_int("pane-position", paned.get_position())
paned.connect("notify::position", on_position_changed)Three-Pane Layout
# Main horizontal split
outer_paned = Gtk.Paned(orientation=Gtk.Orientation.HORIZONTAL)
# Sidebar
sidebar = create_sidebar()
outer_paned.set_start_child(sidebar)
# Inner vertical split (content + details)
inner_paned = Gtk.Paned(orientation=Gtk.Orientation.VERTICAL)
content = create_content_view()
inner_paned.set_start_child(content)
details = create_details_panel()
inner_paned.set_end_child(details)
outer_paned.set_end_child(inner_paned)Welcome/Onboarding
First-Run Welcome
def show_welcome_if_first_run():
if settings.get_boolean("first-run-complete"):
return
welcome = Adw.Window(title="Welcome")
welcome.set_default_size(600, 500)
welcome.set_modal(True)
welcome.set_transient_for(main_window)
carousel = Adw.Carousel()
carousel.set_allow_long_swipes(True)
# Page 1: Welcome
page1 = Adw.StatusPage(
icon_name="application-x-executable-symbolic",
title="Welcome to App Name",
description="A brief description of what your app does"
)
carousel.append(page1)
# Page 2: Feature highlight
page2 = Adw.StatusPage(
icon_name="emblem-photos-symbolic",
title="Organize Your Photos",
description="Easily sort and find your memories"
)
carousel.append(page2)
# Page 3: Get started
page3 = Adw.StatusPage(
icon_name="go-next-symbolic",
title="Ready to Start",
description="Click below to begin"
)
start_btn = Gtk.Button(label="Get Started")
start_btn.add_css_class("pill")
start_btn.add_css_class("suggested-action")
start_btn.connect("clicked", lambda b: finish_onboarding(welcome))
page3.set_child(start_btn)
carousel.append(page3)
# Dots indicator
dots = Adw.CarouselIndicatorDots()
dots.set_carousel(carousel)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
box.append(carousel)
box.append(dots)
welcome.set_content(box)
welcome.present()
def finish_onboarding(window):
settings.set_boolean("first-run-complete", True)
window.close()Feature Callouts
# Highlight a new feature after update
def show_feature_callout(widget, title, description):
popover = Gtk.Popover()
popover.set_autohide(False) # Require explicit dismiss
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
box.set_margin_top(12)
box.set_margin_bottom(12)
box.set_margin_start(12)
box.set_margin_end(12)
title_label = Gtk.Label(label=title)
title_label.add_css_class("heading")
box.append(title_label)
desc_label = Gtk.Label(label=description)
desc_label.set_wrap(True)
desc_label.set_max_width_chars(30)
box.append(desc_label)
dismiss_btn = Gtk.Button(label="Got It")
dismiss_btn.connect("clicked", lambda b: popover.popdown())
box.append(dismiss_btn)
popover.set_child(box)
popover.set_parent(widget)
popover.popup()Keyboard Shortcuts (Access Keys)
Setting Up Mnemonics
# Underlined letter activated with Alt+letter
label = Gtk.Label.new_with_mnemonic("_File") # Alt+F
button = Gtk.Button.new_with_mnemonic("_Save") # Alt+S
# For menu items (in .ui file)
<item>
<attribute name="label" translatable="yes">_Preferences</attribute>
<attribute name="action">app.preferences</attribute>
</item>Custom Shortcuts
class App(Adw.Application):
def setup_shortcuts(self):
# Single shortcut
self.set_accels_for_action("app.new", ["<Control>n"])
# Multiple shortcuts for same action
self.set_accels_for_action("app.save", ["<Control>s", "<Control><Shift>s"])
# View-specific shortcuts
self.set_accels_for_action("win.zoom-in", ["<Control>plus", "<Control>equal"])
self.set_accels_for_action("win.zoom-out", ["<Control>minus"])
self.set_accels_for_action("win.zoom-reset", ["<Control>0"])Shortcut Controller
# Handle shortcuts in a specific widget
shortcut_controller = Gtk.ShortcutController()
# Escape to cancel
shortcut = Gtk.Shortcut.new(
Gtk.ShortcutTrigger.parse_string("Escape"),
Gtk.CallbackAction.new(lambda w, a: cancel_action())
)
shortcut_controller.add_shortcut(shortcut)
# Delete key
shortcut = Gtk.Shortcut.new(
Gtk.ShortcutTrigger.parse_string("Delete"),
Gtk.CallbackAction.new(lambda w, a: delete_selected())
)
shortcut_controller.add_shortcut(shortcut)
widget.add_controller(shortcut_controller)GNOME HIG Reference
Complete pattern catalog with code snippets for GTK 4/libadwaita (Python) and Qt/PySide6 equivalents.
Design Principles (GNOME HIG Foundation)
1. Design for People - Inclusive across abilities, cultures, devices 2. Make it Simple - One thing done well, layered disclosure, frequent features prominent 3. Reduce User Effort - Automate, minimize steps, reduce cognitive load 4. Be Considerate - Prevent errors, enable undo, respect attention
Container Patterns
Application Window
When: Main app window, primary functionality
Structure:
AdwApplicationWindowas rootAdwHeaderBarat top- Content area below (often
AdwToolbarViewfor bottom bars)
GTK 4/libadwaita (Python):
import gi
gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1')
from gi.repository import Gtk, Adw
class MainWindow(Adw.ApplicationWindow):
def __init__(self, app):
super().__init__(application=app, title="My App")
self.set_default_size(800, 600)
# Header bar
header = Adw.HeaderBar()
# Primary action button (end of header)
add_btn = Gtk.Button(icon_name="list-add-symbolic")
add_btn.set_tooltip_text("Add Item")
header.pack_end(add_btn)
# Menu button
menu_btn = Gtk.MenuButton(icon_name="open-menu-symbolic")
menu_btn.set_tooltip_text("Main Menu")
header.pack_end(menu_btn)
# Main content
content = Adw.Clamp(maximum_size=600) # Constrain width
# ... add content widgets
# Assemble with toolbar view
toolbar_view = Adw.ToolbarView()
toolbar_view.add_top_bar(header)
toolbar_view.set_content(content)
self.set_content(toolbar_view)Qt/PySide6 equivalent:
from PySide6.QtWidgets import QMainWindow, QToolBar, QWidget, QVBoxLayout
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("My App")
self.resize(800, 600)
# Toolbar mimics header bar
toolbar = QToolBar()
toolbar.setMovable(False)
self.addToolBar(toolbar)
# Use Adwaita-qt theme or custom QSS for stylingPreferences Window
When: App settings, configuration options
Structure:
AdwPreferencesWindowwith pagesAdwPreferencesPagefor major sectionsAdwPreferencesGroupfor related settings (boxed lists)- Row widgets for individual settings
GTK 4/libadwaita (Python):
class PreferencesWindow(Adw.PreferencesWindow):
def __init__(self, parent):
super().__init__(transient_for=parent, modal=True)
self.set_title("Preferences")
# General page
general_page = Adw.PreferencesPage(
title="General",
icon_name="emblem-system-symbolic"
)
# Appearance group
appearance_group = Adw.PreferencesGroup(title="Appearance")
# Dark mode switch
dark_row = Adw.SwitchRow(
title="Dark Mode",
subtitle="Use dark color scheme"
)
appearance_group.add(dark_row)
# Font size combo
font_row = Adw.ComboRow(title="Font Size")
font_row.set_model(Gtk.StringList.new(["Small", "Medium", "Large"]))
font_row.set_selected(1) # Default to Medium
appearance_group.add(font_row)
general_page.add(appearance_group)
self.add(general_page)Dialog (Alert/Action)
When: Needs user decision, blocking errors, confirmations for irreversible actions
Structure:
- Heading describes action (not "Warning" or "Confirm")
- Body explains consequences
- Cancel button first, action button last
- Specific verb labels
GTK 4/libadwaita (Python):
def show_delete_dialog(parent, item_name):
dialog = Adw.AlertDialog(
heading=f"Delete {item_name}?",
body="This item will be permanently deleted. This cannot be undone."
)
dialog.add_response("cancel", "Cancel")
dialog.add_response("delete", "Delete")
dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE)
dialog.set_default_response("cancel")
dialog.set_close_response("cancel")
dialog.connect("response", on_delete_response)
dialog.present(parent)Boxed Lists (Preferences Groups)
When: Displaying lists of settings, actions, or selectable items
Row types:
AdwActionRow- clickable row, optional suffix widgetAdwSwitchRow- row with toggle switchAdwComboRow- row with dropdownAdwEntryRow- row with text entryAdwSpinRow- row with numeric spinnerAdwExpanderRow- collapsible row with children
GTK 4/libadwaita (Python):
# Action row with navigation arrow
row = Adw.ActionRow(
title="Account Settings",
subtitle="Manage your account"
)
row.add_suffix(Gtk.Image(icon_name="go-next-symbolic"))
row.set_activatable(True)
row.connect("activated", lambda r: open_account_settings())
# Entry row for text input
name_row = Adw.EntryRow(title="Display Name")
name_row.connect("changed", on_name_changed)
# Spin row for numbers
port_row = Adw.SpinRow.new_with_range(1, 65535, 1)
port_row.set_title("Port")
port_row.set_value(8080)Navigation Patterns
View Switcher (2-4 views)
GTK 4/libadwaita:
# In header bar for desktop, bottom bar for mobile
view_switcher = Adw.ViewSwitcher()
view_switcher.set_stack(view_stack)
view_switcher.set_policy(Adw.ViewSwitcherPolicy.WIDE)
header.set_title_widget(view_switcher)
# View stack with pages
view_stack = Adw.ViewStack()
view_stack.add_titled_with_icon(page1, "page1", "Overview", "view-grid-symbolic")
view_stack.add_titled_with_icon(page2, "page2", "Details", "view-list-symbolic")Sidebar Navigation (Many views)
GTK 4/libadwaita:
split_view = Adw.NavigationSplitView()
# Sidebar
sidebar = Adw.NavigationPage(title="Projects")
sidebar_content = Gtk.ListBox()
sidebar_content.set_selection_mode(Gtk.SelectionMode.SINGLE)
# ... populate list
sidebar.set_child(sidebar_content)
# Content area
content = Adw.NavigationPage(title="Project Details")
# ... set content
split_view.set_sidebar(sidebar)
split_view.set_content(content)Feedback Patterns
Toast
When: Action completed, recoverable errors, undo opportunities
GTK 4/libadwaita:
# Simple toast
toast = Adw.Toast(title="Document saved")
toast_overlay.add_toast(toast)
# Toast with undo
toast = Adw.Toast(title="Item deleted")
toast.set_button_label("Undo")
toast.connect("button-clicked", on_undo_delete)
toast_overlay.add_toast(toast)Setup toast overlay:
# Wrap main content in toast overlay
toast_overlay = Adw.ToastOverlay()
toast_overlay.set_child(main_content)
self.set_content(toast_overlay)Progress Indicators
GTK 4/libadwaita:
# Spinner for short/unknown duration
spinner = Gtk.Spinner()
spinner.start()
# Progress bar for long operations
progress = Gtk.ProgressBar()
progress.set_fraction(0.42) # 42%
progress.set_text("Processing 13 of 31 items")
progress.set_show_text(True)
# Thin progress bar in header (for background tasks)
progress = Gtk.ProgressBar()
progress.add_css_class("osd") # Overlay style
header.pack_end(progress)Banner (Persistent state)
When: Ongoing state that affects app functionality (offline, degraded mode, auth required)
GTK 4/libadwaita:
banner = Adw.Banner(title="You are offline")
banner.set_button_label("Retry")
banner.set_revealed(True)
banner.connect("button-clicked", on_retry)
# Place at top of content area (inside AdwToolbarView or prepend to box)
toolbar_view.add_top_bar(banner)Error escalation pattern:
class SyncManager:
def __init__(self, toast_overlay, banner):
self.toast_overlay = toast_overlay
self.banner = banner
self.retry_count = 0
def on_sync_error(self, error):
self.retry_count += 1
if self.retry_count <= 3:
# Transient: Toast + auto-retry
toast = Adw.Toast(title="Sync paused, retrying...")
toast.set_timeout(2)
self.toast_overlay.add_toast(toast)
GLib.timeout_add_seconds(5, self.retry_sync)
elif self.retry_count <= 10:
# Persistent: Banner
self.banner.set_title("Offline - changes saved locally")
self.banner.set_button_label("Retry Now")
self.banner.set_revealed(True)
else:
# Blocking: Dialog
dialog = Adw.AlertDialog(
heading="Unable to Sync",
body="Check your internet connection and try again."
)
dialog.add_response("ok", "OK")
dialog.present(self.window)Context Menus
When: Right-click/long-press actions on items (remove, rename, properties)
GTK 4/libadwaita:
# Create menu model
menu = Gio.Menu()
menu.append("Rename", "item.rename")
menu.append("Remove", "item.remove")
menu.append("Properties", "item.properties")
# Create popover menu
popover = Gtk.PopoverMenu.new_from_model(menu)
popover.set_parent(widget)
popover.set_has_arrow(False)
# Show on right-click
gesture = Gtk.GestureClick(button=3) # Right button
gesture.connect("pressed", lambda g, n, x, y: show_context_menu(popover, x, y))
widget.add_controller(gesture)
def show_context_menu(popover, x, y):
rect = Gdk.Rectangle()
rect.x, rect.y = int(x), int(y)
rect.width = rect.height = 1
popover.set_pointing_to(rect)
popover.popup()For list rows:
# Add secondary click to each row
row = Adw.ActionRow(title="Item Name")
gesture = Gtk.GestureClick(button=3)
gesture.connect("pressed", lambda g, n, x, y: show_item_menu(row))
row.add_controller(gesture)Sidebar Patterns
Sidebar Structure
When: Many/dynamic navigation destinations (file manager, email, chat)
GTK 4/libadwaita:
split_view = Adw.NavigationSplitView()
# Sidebar with sections
sidebar_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
sidebar_box.set_margin_top(12)
sidebar_box.set_margin_bottom(12)
# Section: Favorites
favorites_label = Gtk.Label(label="Favorites", xalign=0)
favorites_label.add_css_class("heading")
favorites_label.set_margin_start(12)
sidebar_box.append(favorites_label)
favorites_list = Gtk.ListBox()
favorites_list.set_selection_mode(Gtk.SelectionMode.SINGLE)
favorites_list.add_css_class("navigation-sidebar")
# Add rows...
sidebar_box.append(favorites_list)
# Section: Locations
locations_label = Gtk.Label(label="Locations", xalign=0)
locations_label.add_css_class("heading")
locations_label.set_margin_start(12)
sidebar_box.append(locations_label)
locations_list = Gtk.ListBox()
locations_list.set_selection_mode(Gtk.SelectionMode.SINGLE)
locations_list.add_css_class("navigation-sidebar")
sidebar_box.append(locations_list)
# Wrap in scroll
scrolled = Gtk.ScrolledWindow()
scrolled.set_child(sidebar_box)
sidebar_page = Adw.NavigationPage(title="Files")
sidebar_page.set_child(scrolled)
split_view.set_sidebar(sidebar_page)Sidebar Row with Icon
def create_sidebar_row(title, icon_name, subtitle=None):
row = Adw.ActionRow(title=title)
row.add_prefix(Gtk.Image(icon_name=icon_name))
if subtitle:
row.set_subtitle(subtitle)
row.set_activatable(True)
return row
# Example rows
home_row = create_sidebar_row("Home", "user-home-symbolic")
docs_row = create_sidebar_row("Documents", "folder-documents-symbolic")
trash_row = create_sidebar_row("Trash", "user-trash-symbolic")Search Patterns
Search Bar Integration
When: Content filtering by text, type-to-search activation
GTK 4/libadwaita:
# Search bar slides down from header bar
search_bar = Gtk.SearchBar()
search_entry = Gtk.SearchEntry()
search_entry.set_hexpand(True)
search_bar.set_child(search_entry)
search_bar.connect_entry(search_entry)
# Toggle button in header bar
search_button = Gtk.ToggleButton(icon_name="system-search-symbolic")
search_button.set_tooltip_text("Search")
header.pack_end(search_button)
# Bind toggle to search bar
search_bar.bind_property(
"search-mode-enabled",
search_button, "active",
GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE
)
# Place search bar below header
toolbar_view.add_top_bar(search_bar)
# Enable Ctrl+F and type-to-search
search_bar.set_key_capture_widget(window)Search activation methods:
- Ctrl+F keyboard shortcut (standard)
- Toggle button in header bar
- Type-to-search (typing activates search automatically)
Live Search Results
def on_search_changed(entry):
query = entry.get_text().lower()
if not query:
# Show all items
filter_model.set_filter(None)
return
def match_func(item):
return query in item.title.lower()
custom_filter = Gtk.CustomFilter.new(match_func)
filter_model.set_filter(custom_filter)
search_entry.connect("search-changed", on_search_changed)Search Empty State
# "No results" placeholder
no_results = Adw.StatusPage(
icon_name="system-search-symbolic",
title="No Results Found",
description="Try a different search term"
)
# Show/hide based on results count
stack.add_named(results_view, "results")
stack.add_named(no_results, "no-results")
def update_results_view():
if filter_model.get_n_items() == 0 and search_entry.get_text():
stack.set_visible_child_name("no-results")
else:
stack.set_visible_child_name("results")Form Validation Patterns
Entry Row with Validation
GTK 4/libadwaita:
# Entry row with error styling
name_row = Adw.EntryRow(title="Name")
def validate_name(row):
text = row.get_text()
if not text:
row.add_css_class("error")
row.set_tooltip_text("Name is required")
return False
elif len(text) < 3:
row.add_css_class("error")
row.set_tooltip_text("Name must be at least 3 characters")
return False
else:
row.remove_css_class("error")
row.set_tooltip_text("")
return True
# Validate on change (real-time) or on focus-out
name_row.connect("changed", lambda r: validate_name(r))Form Submission with Validation
def on_save_clicked(button):
# Validate all fields
errors = []
if not validate_name(name_row):
errors.append("name")
if not validate_email(email_row):
errors.append("email")
if errors:
# Focus first error field
if "name" in errors:
name_row.grab_focus()
# Show error toast
toast = Adw.Toast(title="Please fix the errors above")
toast_overlay.add_toast(toast)
return
# Proceed with save
do_save()Validation Timing
| Timing | Use When |
|---|---|
| On change (real-time) | Format validation (email, URL), character limits |
| On focus out | Expensive validation, API checks |
| On submit | Final validation, show all errors |
Best practice: Show positive feedback when valid rather than only errors. Use success CSS class for valid fields if helpful.
Grid View Patterns
Basic Grid View
GTK 4:
# Create grid view with selection
grid_view = Gtk.GridView()
grid_view.set_model(selection_model)
# Factory for grid items
factory = Gtk.SignalListItemFactory()
def setup_item(factory, list_item):
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
image = Gtk.Picture()
image.set_size_request(150, 150)
image.set_content_fit(Gtk.ContentFit.COVER)
label = Gtk.Label()
label.set_ellipsize(Pango.EllipsizeMode.END)
box.append(image)
box.append(label)
list_item.set_child(box)
def bind_item(factory, list_item):
box = list_item.get_child()
image = box.get_first_child()
label = box.get_last_child()
item = list_item.get_item()
image.set_filename(item.thumbnail_path)
label.set_label(item.title)
factory.connect("setup", setup_item)
factory.connect("bind", bind_item)
grid_view.set_factory(factory)Selection Model
# Single selection (default)
selection_model = Gtk.SingleSelection(model=list_store)
# Multi-selection
selection_model = Gtk.MultiSelection(model=list_store)
# Connect to selection changes
selection_model.connect("selection-changed", on_selection_changed)
def on_selection_changed(model, position, n_items):
selected = get_selected_items(model)
update_action_bar(selected)Selection Mode Toggle
# Action bar for selection mode
action_bar = Gtk.ActionBar()
action_bar.set_revealed(False)
select_all_btn = Gtk.Button(label="Select All")
select_all_btn.connect("clicked", lambda b: selection_model.select_all())
delete_btn = Gtk.Button(label="Delete")
delete_btn.add_css_class("destructive-action")
cancel_btn = Gtk.Button(label="Cancel")
cancel_btn.connect("clicked", lambda b: exit_selection_mode())
action_bar.pack_start(select_all_btn)
action_bar.pack_end(delete_btn)
action_bar.pack_end(cancel_btn)
# Toggle button in header bar
select_btn = Gtk.ToggleButton(icon_name="selection-mode-symbolic")
select_btn.set_tooltip_text("Select Items")
def on_select_mode_toggled(button):
if button.get_active():
selection_model = Gtk.MultiSelection(model=list_store)
grid_view.set_model(selection_model)
action_bar.set_revealed(True)
else:
selection_model = Gtk.SingleSelection(model=list_store)
grid_view.set_model(selection_model)
action_bar.set_revealed(False)
select_btn.connect("toggled", on_select_mode_toggled)Primary Menu Structure
Standard Primary Menu
Every GNOME app should include these items:
menu = Gio.Menu()
# App-specific items first (optional)
menu.append("Import...", "app.import")
menu.append("Export...", "app.export")
# Separator (implicit by creating new section)
section = Gio.Menu()
section.append("Preferences", "app.preferences")
section.append("Keyboard Shortcuts", "win.show-help-overlay")
section.append("Help", "app.help")
section.append("About App Name", "app.about")
menu.append_section(None, section)
# Menu button setup
menu_button = Gtk.MenuButton(icon_name="open-menu-symbolic")
menu_button.set_tooltip_text("Main Menu")
menu_button.set_menu_model(menu)
header.pack_end(menu_button)Menu organization:
- 3-12 items maximum in primary menu
- Group related items in sections
- Preferences, Shortcuts, Help, About at bottom
- Never include Quit (use Ctrl+Q shortcut)
Shortcuts Window
# In app class
def do_activate(self):
# Set up shortcuts window
builder = Gtk.Builder.new_from_resource("/com/example/app/shortcuts.ui")
self.set_help_overlay(builder.get_object("shortcuts"))
# In shortcuts.ui
<object class="GtkShortcutsWindow" id="shortcuts">
<child>
<object class="GtkShortcutsSection">
<property name="title">General</property>
<child>
<object class="GtkShortcutsGroup">
<property name="title">Navigation</property>
<child>
<object class="GtkShortcutsShortcut">
<property name="accelerator"><Control>f</property>
<property name="title">Search</property>
</object>
</child>
</object>
</child>
</object>
</child>
</object>About Dialog
def show_about(app):
about = Adw.AboutDialog(
application_name="App Name",
application_icon="com.example.AppName",
version="1.0.0",
developer_name="Developer Name",
copyright="© 2024 Developer Name",
license_type=Gtk.License.GPL_3_0,
website="https://example.com",
issue_url="https://github.com/example/app/issues"
)
about.set_developers(["Developer Name"])
about.set_designers(["Designer Name"])
about.present(window)Additional Control Patterns
Multiline Text Entry
# Use GtkTextView for multiline, styled to match Adwaita
text_view = Gtk.TextView()
text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
text_view.set_top_margin(12)
text_view.set_bottom_margin(12)
text_view.set_left_margin(12)
text_view.set_right_margin(12)
# Wrap in scrolled window with frame
scrolled = Gtk.ScrolledWindow()
scrolled.set_child(text_view)
scrolled.set_min_content_height(100)
scrolled.add_css_class("card") # Boxed appearance
# Add to preferences group with custom widget
group = Adw.PreferencesGroup(title="Description")
group.add(scrolled)Date Selection
# Use GtkCalendar in a popover
calendar = Gtk.Calendar()
calendar.connect("day-selected", on_date_selected)
popover = Gtk.Popover()
popover.set_child(calendar)
# Date entry button
date_button = Gtk.MenuButton(label="Select Date")
date_button.set_popover(popover)
def on_date_selected(calendar):
date = calendar.get_date()
date_button.set_label(date.format("%Y-%m-%d"))
popover.popdown()Cancellable Operations
class ImportOperation:
def __init__(self, toast_overlay):
self.cancelled = False
self.toast_overlay = toast_overlay
def start(self, files):
# Show progress dialog
self.dialog = Adw.AlertDialog(heading="Importing Photos")
self.progress = Gtk.ProgressBar()
self.progress.set_show_text(True)
self.dialog.set_extra_child(self.progress)
self.dialog.add_response("cancel", "Cancel")
self.dialog.connect("response", self.on_cancel)
self.dialog.present(window)
# Start import in thread
threading.Thread(target=self.do_import, args=(files,)).start()
def on_cancel(self, dialog, response):
if response == "cancel":
self.cancelled = True
def do_import(self, files):
for i, f in enumerate(files):
if self.cancelled:
GLib.idle_add(self.on_cancelled, i, len(files))
return
# Import file...
GLib.idle_add(self.update_progress, i + 1, len(files))
GLib.idle_add(self.on_complete, len(files))
def update_progress(self, current, total):
self.progress.set_fraction(current / total)
self.progress.set_text(f"Importing {current} of {total}")
def on_complete(self, count):
self.dialog.close()
toast = Adw.Toast(title=f"Imported {count} photos")
self.toast_overlay.add_toast(toast)
def on_cancelled(self, completed, total):
self.dialog.close()
toast = Adw.Toast(title=f"Import cancelled ({completed} of {total} imported)")
self.toast_overlay.add_toast(toast)File Chooser Dialogs
Open File
def on_open_clicked(button):
dialog = Gtk.FileDialog(title="Open Document")
# Filter for specific file types
filters = Gio.ListStore.new(Gtk.FileFilter)
text_filter = Gtk.FileFilter()
text_filter.set_name("Text Files")
text_filter.add_mime_type("text/plain")
text_filter.add_pattern("*.txt")
filters.append(text_filter)
all_filter = Gtk.FileFilter()
all_filter.set_name("All Files")
all_filter.add_pattern("*")
filters.append(all_filter)
dialog.set_filters(filters)
dialog.set_default_filter(text_filter)
dialog.open(window, None, on_open_response)
def on_open_response(dialog, result):
try:
file = dialog.open_finish(result)
path = file.get_path()
# Load file...
except GLib.Error as e:
if e.code != Gtk.DialogError.DISMISSED:
show_error_toast(f"Could not open file: {e.message}")Save File
def on_save_clicked(button):
dialog = Gtk.FileDialog(
title="Save Document",
initial_name="Untitled.txt"
)
dialog.save(window, None, on_save_response)
def on_save_response(dialog, result):
try:
file = dialog.save_finish(result)
path = file.get_path()
# Save to path...
toast = Adw.Toast(title="Document saved")
toast_overlay.add_toast(toast)
except GLib.Error as e:
if e.code != Gtk.DialogError.DISMISSED:
show_error_toast(f"Could not save file: {e.message}")Select Folder
def on_select_folder_clicked(button):
dialog = Gtk.FileDialog(title="Select Folder")
dialog.select_folder(window, None, on_folder_response)
def on_folder_response(dialog, result):
try:
folder = dialog.select_folder_finish(result)
path = folder.get_path()
# Use folder...
except GLib.Error:
pass # User cancelledDark/Light Mode
Following System Preference (Default)
# Apps automatically follow system preference - no code needed
# Libadwaita handles this automaticallyPer-App Style Switching
# Get style manager
style_manager = Adw.StyleManager.get_default()
# Check current mode
is_dark = style_manager.get_dark()
# Force specific mode (use sparingly - respect user preference)
style_manager.set_color_scheme(Adw.ColorScheme.FORCE_DARK)
style_manager.set_color_scheme(Adw.ColorScheme.FORCE_LIGHT)
# Return to system preference
style_manager.set_color_scheme(Adw.ColorScheme.DEFAULT)Style Toggle in Preferences
# In preferences window
appearance_group = Adw.PreferencesGroup(title="Appearance")
style_row = Adw.ComboRow(title="Style")
style_row.set_model(Gtk.StringList.new(["System", "Light", "Dark"]))
def on_style_changed(row, param):
selected = row.get_selected()
style_manager = Adw.StyleManager.get_default()
schemes = [
Adw.ColorScheme.DEFAULT, # System
Adw.ColorScheme.FORCE_LIGHT, # Light
Adw.ColorScheme.FORCE_DARK, # Dark
]
style_manager.set_color_scheme(schemes[selected])
style_row.connect("notify::selected", on_style_changed)
appearance_group.add(style_row)Reacting to Style Changes
# Update custom elements when style changes
def on_dark_changed(style_manager, param):
is_dark = style_manager.get_dark()
# Update any custom-styled elements
update_custom_colors(is_dark)
style_manager = Adw.StyleManager.get_default()
style_manager.connect("notify::dark", on_dark_changed)Responsive Layouts (Breakpoints)
Basic Sidebar Collapse
# Collapse sidebar on narrow windows
split_view = Adw.NavigationSplitView()
breakpoint = Adw.Breakpoint.new(
Adw.BreakpointCondition.parse("max-width: 600sp")
)
breakpoint.add_setter(split_view, "collapsed", True)
window.add_breakpoint(breakpoint)Multiple Breakpoints
# Different layouts at different sizes
class AdaptiveWindow(Adw.ApplicationWindow):
def __init__(self, app):
super().__init__(application=app)
# Phone: single column, bottom bar
phone_bp = Adw.Breakpoint.new(
Adw.BreakpointCondition.parse("max-width: 400sp")
)
phone_bp.add_setter(self.split_view, "collapsed", True)
phone_bp.add_setter(self.view_switcher_bar, "reveal", True)
phone_bp.add_setter(self.header_switcher, "visible", False)
# Tablet: collapsed sidebar, header switcher
tablet_bp = Adw.Breakpoint.new(
Adw.BreakpointCondition.parse("max-width: 800sp")
)
tablet_bp.add_setter(self.split_view, "collapsed", True)
self.add_breakpoint(phone_bp)
self.add_breakpoint(tablet_bp)Adaptive View Switcher
# Header bar switcher for wide, bottom bar for narrow
header = Adw.HeaderBar()
view_switcher = Adw.ViewSwitcher()
view_switcher.set_stack(stack)
view_switcher.set_policy(Adw.ViewSwitcherPolicy.WIDE)
header.set_title_widget(view_switcher)
# Bottom bar for narrow windows
switcher_bar = Adw.ViewSwitcherBar()
switcher_bar.set_stack(stack)
# Breakpoint to toggle
breakpoint = Adw.Breakpoint.new(
Adw.BreakpointCondition.parse("max-width: 550sp")
)
breakpoint.add_setter(view_switcher, "visible", False)
breakpoint.add_setter(switcher_bar, "reveal", True)
window.add_breakpoint(breakpoint)
# Layout
toolbar_view = Adw.ToolbarView()
toolbar_view.add_top_bar(header)
toolbar_view.add_bottom_bar(switcher_bar)
toolbar_view.set_content(stack)Content Width Constraints
# Prevent content from becoming too wide on large screens
clamp = Adw.Clamp()
clamp.set_maximum_size(800) # Max width in pixels
clamp.set_tightening_threshold(600) # Start padding at this width
clamp.set_child(content)
# Use ClampScrollable for scrolled content
scrolled = Gtk.ScrolledWindow()
clamp = Adw.ClampScrollable()
clamp.set_maximum_size(800)
clamp.set_child(content_box)
scrolled.set_child(clamp)Typography
Style classes (use instead of custom CSS):
| Class | Use For |
|---|---|
title-1 to title-4 | Display headings, welcome screens |
heading | Section headings, group titles |
body | Default text, descriptions |
caption | Secondary info, timestamps |
monospace | Code, technical values |
dim-label | De-emphasized text |
numeric | Tabular numbers |
GTK 4:
label = Gtk.Label(label="Welcome")
label.add_css_class("title-1")
subtitle = Gtk.Label(label="Get started by creating a project")
subtitle.add_css_class("body")
subtitle.add_css_class("dim-label")Writing Style Quick Reference
| Context | Style | Example |
|---|---|---|
| Button labels | Header caps, imperative verb | "Save Document", "Add Item" |
| Menu items | Header caps | "Find and Replace" |
| Checkbox/switch | Sentence caps | "Show notifications" |
| Descriptions | Sentence caps, no period | "Changes will take effect after restart" |
| Toast messages | Informal heading | "Document saved" |
| Dialog headings | Describe action | "Delete project?" not "Warning" |
Header capitalization: Capitalize words 4+ letters, all verbs, all nouns, first/last words
Spacing & Layout
Use libadwaita defaults - avoid custom margins:
AdwClampconstrains content width (default max: 600px)AdwPreferencesGrouphandles internal spacing- Box spacing: 12px between major sections, 6px between related items
Responsive breakpoints:
# Check width for adaptive layouts
breakpoint = Adw.Breakpoint.new(
Adw.BreakpointCondition.parse("max-width: 500sp")
)
breakpoint.add_setter(split_view, "collapsed", True)
self.add_breakpoint(breakpoint)Common Mistakes
1. Overloaded Header Bar
Wrong:
[Back] [Title] [Search] [Filter] [Sort] [Add] [Menu]Right:
[Back] [ Title ] [Add] [Menu]Put secondary actions in menu or use view-specific controls below header.
2. Wrong Dialog Type
Wrong: Confirmation dialog for "Delete" when undo is possible Right: Toast with "Undo" button - less intrusive, equally safe
Wrong: Toast for "You have unsaved changes" Right: Dialog - requires acknowledgment before data loss
3. Missing Empty States
Wrong: Blank area when list is empty Right: Placeholder page with:
- Relevant symbolic icon (48px+)
- Clear message ("No projects yet")
- Primary action button ("Create Project")
status_page = Adw.StatusPage(
icon_name="folder-symbolic",
title="No Projects",
description="Create a project to get started"
)
button = Gtk.Button(label="Create Project")
button.add_css_class("pill")
button.add_css_class("suggested-action")
status_page.set_child(button)4. Generic Button Labels
Wrong: "OK", "Yes", "No", "Submit", "Confirm" Right: Specific verbs: "Save", "Delete", "Send", "Create Project"
5. Custom Controls for Native Patterns
Wrong: Custom toggle widget Right: AdwSwitchRow
Wrong: Custom dropdown Right: AdwComboRow
Wrong: Custom tabs Right: AdwViewSwitcher or AdwTabView
6. Frozen UI During Operations
Wrong: No feedback during network requests Right:
- Short operations (<5s): Spinner
- Long operations: Progress bar with status text
- Background operations: Thin progress in header bar
7. Poor Keyboard Navigation
Wrong: Click-only interactions, no focus indicators Right:
- All controls Tab-focusable
- Enter/Space activate buttons
- Escape closes dialogs/popovers
- Visible focus rings (libadwaita provides automatically)
8. Accessibility Gaps
Testing checklist:
# High contrast
GTK_THEME=Adwaita:hc ./my-app
# Large text (set in GNOME Settings > Accessibility)
# Screen reader
orca &
./my-app
# Keyboard only - unplug mouse, navigate entire appColor Reference
Never hardcode colors. Use CSS variables for automatic light/dark/high-contrast support:
| Variable | Use |
|---|---|
@accent_color | Interactive elements |
@accent_bg_color | Accent backgrounds |
@destructive_color | Destructive actions |
@success_color | Success states |
@warning_color | Warnings |
@error_color | Errors |
@view_bg_color | Content backgrounds |
@headerbar_bg_color | Header bar |
@card_bg_color | Card/boxed list backgrounds |
Keyboard Shortcuts
Standard shortcuts to implement:
| Shortcut | Action |
|---|---|
| Ctrl+W | Close window |
| Ctrl+Q | Quit app |
| Ctrl+N | New item |
| Ctrl+S | Save |
| Ctrl+Z | Undo |
| Ctrl+Shift+Z | Redo |
| Ctrl+F | Search/Find |
| Escape | Close dialog/popover, cancel |
| F1 | Help |