Repository navigation
Creating a New Right Sidebar Panel in Flux
Flux uses a unified right-sidebar architecture where panels (Search, Tags, Diff, Location, Connect) are mutually exclusive and lazy-initialized. This tutorial walks you through adding a new panel using the Connect panel as the reference implementation.
Every right sidebar panel requires changes in 8 layers:
| Layer | File(s) | Purpose |
|---|---|---|
| 1. Model State |
model/app.rs, model/types.rs
|
Store revealer widget, visibility flag, init flag |
| 2. Messages | model/msg.rs |
Define toggle message |
| 3. Panel Spec | ui/panels/spec.rs |
Define min/max/default width |
| 4. Panel UI | ui/panels/<name>.rs |
Build the actual widget tree |
| 5. Panel Registry | ui/panels/mod.rs |
Export the builder function |
| 6. View Layout |
ui/view/component.rs, ui/view/init.rs
|
Add revealer to window, store widget ref |
| 7. Toggle Logic |
utils/helpers/sidebar.rs, ui/update/navigation.rs
|
Wire up open/close/mutual exclusion |
| 8. Keyboard Shortcut | utils/helpers/input.rs |
Bind key combination |
In src/ui/panels/spec.rs, add a constructor for your panel's dimensions:
impl PanelSpec {
// ... existing specs ...
pub const fn my_panel() -> Self {
Self::new(250, 800, 350) // min, max, default width
}
}In src/model/types.rs, add your panel to the RightPanelType enum:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RightPanelType {
Tag,
Search,
Diff,
Location,
Connect,
MyPanel, // ← NEW
}In src/model/app.rs, add three fields to FluxApp:
/// Revealer widget wrapping the right my-panel sidebar.
pub my_panel_revealer: Option<gtk::Revealer>,
/// Whether the right my-panel sidebar is currently visible.
pub my_panel_visible: bool,
/// Whether the right my-panel widgets have been instantiated.
pub my_panel_initialized: bool,Why three fields? Flux lazy-initializes panels. The
revealerexists in the widget tree from startup but has no child until first opened.initializedtracks whether the child has been built.visibletracks the current show/hide state independently of GTK's internal revealer state.
In src/model/msg.rs, add a toggle variant to AppMsg:
/// Toggles the right my-panel sidebar (Ctrl+Shift+M).
ToggleMyPanel,Create src/ui/panels/my_panel.rs. Follow this template based on connect.rs:
use super::header::panel_header;
use super::resize::resizable_panel;
use super::spec::PanelSpec;
use crate::i18n::tr;
use crate::model::{AppMsg, FluxApp};
use adw::prelude::*;
use relm4::AsyncComponentSender;
/// Builds the my-panel right sidebar.
pub fn build_my_panel(sender: AsyncComponentSender<FluxApp>) -> gtk::Box {
let spec = PanelSpec::my_panel();
let panel = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
.width_request(spec.effective())
.hexpand(false)
.build();
panel.add_css_class("sidebar");
// ── Header with close button ────────────────────────────────
{
let s = sender.clone();
let header = panel_header(
Some("my-icon-symbolic"), // header icon
&tr("My Panel Title"), // header title
&[], // extra header widgets
move || s.input(AppMsg::ToggleMyPanel), // close callback
);
panel.append(&header);
}
// ── Content area ────────────────────────────────────────────
let content_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.margin_start(12)
.margin_end(12)
.margin_top(4)
.margin_bottom(12)
.vexpand(true)
.build();
// TODO: Add your panel widgets here
let label = gtk::Label::new(Some(&tr("Hello World")));
content_box.append(&label);
let scrolled = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vscrollbar_policy(gtk::PolicyType::Automatic)
.propagate_natural_width(false)
.hexpand(false)
.vexpand(true)
.child(&content_box)
.build();
panel.append(&scrolled);
// ── Wrap in resizable handle ────────────────────────────────
let root = resizable_panel(&spec, &panel, move |w| {
sender.input(AppMsg::SetMyPanelWidth(w)); // persist width on resize
});
// ── Escape key closes panel ─────────────────────────────────
let esc_ctrl = gtk::EventControllerKey::new();
esc_ctrl.set_propagation_phase(gtk::PropagationPhase::Capture);
{
let s = sender.clone();
esc_ctrl.connect_key_pressed(move |_, keyval, _, _| {
if keyval == adw::gdk::Key::Escape {
s.input(AppMsg::ToggleMyPanel);
return gtk::glib::Propagation::Stop;
}
gtk::glib::Propagation::Proceed
});
}
root.add_controller(esc_ctrl);
root
}Key patterns to note:
-
panel_header()provides a consistent header with icon, title, optional extra widgets, and a close button. -
resizable_panel()wraps your content with a drag handle on the left edge. The closure receives the new width after drag ends. -
Escape key controller uses
Capturephase so it wins over child widgets. - The builder takes only
AsyncComponentSender<FluxApp>— never a parent window reference.
In src/ui/panels/mod.rs:
pub mod my_panel;
pub use my_panel::build_my_panel;In src/ui/view/component.rs, inside the view! macro after the other revealers:
/// Right my-panel revealer (lazy initialized).
#[name = "my_panel_revealer"]
gtk::Revealer {
set_transition_type: gtk::RevealerTransitionType::SlideLeft,
set_transition_duration: 200,
set_reveal_child: false,
set_visible: false,
},In src/ui/view/init.rs, inside store_widget_refs():
model.my_panel_revealer = Some(widgets.my_panel_revealer.clone());In src/ui/startup/components.rs, initialize the fields in the FluxApp { .. } literal:
my_panel_revealer: None,
my_panel_visible: false,
my_panel_initialized: false,This is the most critical step. Three files must be updated:
In hide_other_right_panels(), add before the existing blocks:
if keep != RightPanelType::MyPanel && self.my_panel_visible {
if let Some(ref r) = self.my_panel_revealer {
r.set_reveal_child(false);
r.set_visible(false);
}
self.my_panel_visible = false;
}In toggle_sidebar_right_panel(), add to the (revealer, initialized, visible, is_search) match:
RightPanelType::MyPanel => (
self.my_panel_revealer.clone(),
&mut self.my_panel_initialized,
&mut self.my_panel_visible,
false, // true only for search panel
),In the same function, inside the if !*initialized panel-building match:
RightPanelType::MyPanel => crate::ui::panels::build_my_panel(sender.clone()),How lazy initialization works: The first time
ToggleMyPanelfires,my_panel_initializedisfalse. The toggle handler callsbuild_my_panel(), sets the result as the revealer's child, then flipsinitializedtotrue. Subsequent toggles only changereveal_child/visiblewithout rebuilding.
Add to the match msg block:
AppMsg::ToggleMyPanel => {
app.toggle_sidebar_right_panel(crate::model::RightPanelType::MyPanel, sender)
}If you added a width-persist message, also add it to the appropriate update module:
AppMsg::SetMyPanelWidth(val) => app.handle_set_my_panel_width(val),And implement the handler in src/ui/config_handlers/search.rs (or create a new handler file):
pub fn handle_set_my_panel_width(&mut self, val: i32) {
let clamped = val.clamp(250, 800);
self.config.ui.my_panel_width = clamped;
utils::save_config(&self.config);
}In src/utils/helpers/input.rs, inside setup_shortcuts():
("<Control><Shift>m", AppMsg::ToggleMyPanel),Also add it to the help window in src/ui/help/page/system_view.rs so users can discover it.
-
RightPanelType::MyPaneladded to enum inmodel/types.rs - Three state fields added to
FluxAppinmodel/app.rs - Fields initialized in
ui/startup/components.rs -
ToggleMyPanelmessage added toAppMsginmodel/msg.rs -
PanelSpec::my_panel()defined inui/panels/spec.rs -
build_my_panel()implemented inui/panels/my_panel.rs - Module exported in
ui/panels/mod.rs - Revealer added to
view!macro inui/view/component.rs - Widget ref stored in
store_widget_refs()inui/view/init.rs - Mutual exclusion added in
hide_other_right_panels()inutils/helpers/sidebar.rs - Lazy init wired in
toggle_sidebar_right_panel()match arms inutils/helpers/sidebar.rs - Message routed in
ui/update/navigation.rs - Keyboard shortcut bound in
utils/helpers/input.rs - Help entry added in
ui/help/page/system_view.rs
-
Forgetting mutual exclusion: Without the
hide_other_right_panelsentry, two panels can be visible simultaneously, overlapping each other. -
Missing
set_visible(false)on the revealer: GTK4 revealers still occupy layout space when hidden but not invisible. Always pairset_reveal_child(false)withset_visible(false). -
Passing
root: &adw::Windowto the builder: Panel builders should never take a window reference. They receive only theAsyncComponentSender. Dialogs spawned from within the panel should usegtk::Application::default().active_window()at call time. -
Not handling Escape: Every panel must have its own Escape key controller in
Capturephase, because the global shortcut controller won't fire when a child entry has focus. -
Rebuilding on every toggle: Always check
!*initializedbefore calling the builder. The whole point of the three-field pattern is to build once and toggle cheaply thereafter. -
Forgetting to initialize fields in
components.rs: Ifmy_panel_revealerstarts asNoneand is never set during startup,toggle_sidebar_right_panelwill silently return early and the panel will never appear.