Skip to content

Creating a New Right Sidebar Panel in Flux

Thiago Lucio edited this page Oct 8, 2026 · 1 revision

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.

Architecture Overview

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

Step-by-Step Guide

Step 1: Define the Panel Spec

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
    }
}

Step 2: Add Model State

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 revealer exists in the widget tree from startup but has no child until first opened. initialized tracks whether the child has been built. visible tracks the current show/hide state independently of GTK's internal revealer state.

Step 3: Define the Message

In src/model/msg.rs, add a toggle variant to AppMsg:

/// Toggles the right my-panel sidebar (Ctrl+Shift+M).
ToggleMyPanel,

Step 4: Build the Panel Widget

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 Capture phase so it wins over child widgets.
  • The builder takes only AsyncComponentSender<FluxApp> — never a parent window reference.

Step 5: Register the Panel Module

In src/ui/panels/mod.rs:

pub mod my_panel;
pub use my_panel::build_my_panel;

Step 6: Add the Revealer to the View

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,

Step 7: Wire Up the Toggle Logic

This is the most critical step. Three files must be updated:

A. src/utils/helpers/sidebar.rs — Mutual Exclusion

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 ToggleMyPanel fires, my_panel_initialized is false. The toggle handler calls build_my_panel(), sets the result as the revealer's child, then flips initialized to true. Subsequent toggles only change reveal_child/visible without rebuilding.

B. src/ui/update/navigation.rs — Message Routing

Add to the match msg block:

AppMsg::ToggleMyPanel => {
    app.toggle_sidebar_right_panel(crate::model::RightPanelType::MyPanel, sender)
}

C. Width Persistence (Optional)

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);
}

Step 8: Bind a Keyboard Shortcut

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.


Checklist

  • RightPanelType::MyPanel added to enum in model/types.rs
  • Three state fields added to FluxApp in model/app.rs
  • Fields initialized in ui/startup/components.rs
  • ToggleMyPanel message added to AppMsg in model/msg.rs
  • PanelSpec::my_panel() defined in ui/panels/spec.rs
  • build_my_panel() implemented in ui/panels/my_panel.rs
  • Module exported in ui/panels/mod.rs
  • Revealer added to view! macro in ui/view/component.rs
  • Widget ref stored in store_widget_refs() in ui/view/init.rs
  • Mutual exclusion added in hide_other_right_panels() in utils/helpers/sidebar.rs
  • Lazy init wired in toggle_sidebar_right_panel() match arms in utils/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

Common Pitfalls

  1. Forgetting mutual exclusion: Without the hide_other_right_panels entry, two panels can be visible simultaneously, overlapping each other.

  2. Missing set_visible(false) on the revealer: GTK4 revealers still occupy layout space when hidden but not invisible. Always pair set_reveal_child(false) with set_visible(false).

  3. Passing root: &adw::Window to the builder: Panel builders should never take a window reference. They receive only the AsyncComponentSender. Dialogs spawned from within the panel should use gtk::Application::default().active_window() at call time.

  4. Not handling Escape: Every panel must have its own Escape key controller in Capture phase, because the global shortcut controller won't fire when a child entry has focus.

  5. Rebuilding on every toggle: Always check !*initialized before calling the builder. The whole point of the three-field pattern is to build once and toggle cheaply thereafter.

  6. Forgetting to initialize fields in components.rs: If my_panel_revealer starts as None and is never set during startup, toggle_sidebar_right_panel will silently return early and the panel will never appear.