[PR #1349] [MERGED] Standardised profiles system #5542

Closed
opened 2026-05-23 00:57:30 +01:00 by JakeStanger · 0 comments
Owner

📋 Pull Request Information

Original PR: https://github.com/JakeStanger/ironbar/pull/1349
Author: @JakeStanger
Created: 1/27/2026
Status: Merged
Merged: 2/3/2026
Merged by: @JakeStanger

Base: masterHead: feat/profiles


📝 Commits (3)

  • 775b1b1 feat(config): standardised profiles system
  • bb9b337 feat(volume): introduce profiles support
  • 1d0b966 feat(battery): introduce profiles support

📊 Changes

12 files changed (+970 additions, -258 deletions)

View changed files

📝 docs/Development guide.md (+108 -0)
docs/Profiles.md (+125 -0)
📝 docs/_Sidebar.md (+1 -0)
📝 docs/modules/Battery.md (+8 -7)
📝 docs/modules/Volume.md (+20 -20)
📝 src/config/mod.rs (+2 -0)
src/config/profiles.rs (+379 -0)
📝 src/modules/battery.rs (+107 -85)
📝 src/modules/mod.rs (+4 -0)
src/modules/volume/config.rs (+113 -0)
📝 src/modules/volume/mod.rs (+74 -144)
📝 test-configs/battery.corn (+29 -2)

📄 Description

Adds a new standard configuration system for setting up module "profiles", which allow a defined subset of the configuration to change reactively when a certain value (volume, battery, brightness) reaches defined thresholds.

The primary use of this system is to allow for dynamic icons (ie the volume indicator, wifi strength indicator) to be fully configurable. This approach also allows for any options included in a profile definition to be part of the same system.

It also introduces a more declarative reactive UI approach via the profile update callback, which should in time lead to cleaner module code.

Included in this PR is support for the following modules:

  • volume (configure volume icon)
  • battery (configure format)

The system would additionally apply to the following modules:

  • backlight (configure brightness icon, #1235)
  • network_manager (configure wifi strength icon, #1233)

The following locations would also expand to use this with future updates:

  • battery (configure battery icon, #935)
  • music (configure volume icon)

In future, sys_info may also be supported, although this is not being considered for now.

Configuration

Profiles always include a "default" which is merged flat into the config. This means setting eg icons.volume outside of the profiles object will apply if no other profile definition takes precedent.

Each profile must include a when key, which indicates the threshold. Once the monitored value (eg volume %) is less than or equal to the threshold, this profile activates.

Only one profile is active at a time. In the example below, if the volume was <= 33%, the low profile would activate.

let {
  $volume = {
    // default - applies if no other profile is active
    icons.volume = "󰕾"
    
    profiles = {
        // medium profile activates when volume <= 67%
        medium.when = 67
        medium.icons.volume = "󰖀"

        // low profile activates when volume <= 33%
        low.when = 33
        low.icons.volume = "󰕿"

        // you can also override other icons
        low.icons.muted = "icons:volume-low-muted" 
    }
  }
} in {
  end = [ $volume ]
}
Same config in JSON for clarity & highlighting
{
  "end": [
    {
      "icons": {
        "volume": "󰕾"
      },
      "profiles": {
        "medium": {
          "value": 67,
          "icons": {
            "volume": "󰖀"
          }
        },
        "low": {
          "value": 33,
          "icons": {
            "volume": "󰕿",
            "muted": "icons:volume-low-muted"
          }
        }
      }
    }
  ]
}

The system also supports compound matchers, which is suitable where a single state value is not considered sufficient. These should be kept simple and contain a minimal amount of values, two or three at an absolute push.

An example below for the battery module. A percent value must be provided. A charging value can also be provided, which takes higher priority.

let {
  $battery = {
    type = "battery"
  
    format = "HIGH {percentage}%"
  
    profiles = {
        low.when = { percent = 20 }
        low.format = "LOW {percentage}%"
  
        low-charging.when = { percent = 20 charging = true }
        low-charging.format = "LOW (CHARGING) {percentage}%"
  
        medium.when = { percent = 50 charging = false }
        medium.format = "MEDIUM {percentage}%"
  
        medium-charging.when = { percent = 50 }
        medium-charging.format = "MEDIUM (CHARGING) {percentage}%"
  
        good.when = { percent = 75 charging = false }
        good.format = "GOOD {percentage}%"
  
        good-charging.when = { percent = 75 charging = true }
        good-charging.format = "GOOD (CHARGING) {percentage}%"
  
        empty.when = { percent = 1 charging = true }
    } 
} in {
  end [ $battery ]
}
JSON
{
  "end": [
    {
      "type": "battery",
      "format": "HIGH {percentage}%",
      "profiles": {
        "low": {
          "when": {
            "percent": 20
          },
          "format": "LOW {percentage}%"
        },
        "low-charging": {
          "when": {
            "percent": 20,
            "charging": true
          },
          "format": "LOW (CHARGING) {percentage}%"
        },
        "medium": {
          "when": {
            "percent": 50,
            "charging": false
          },
          "format": "MEDIUM {percentage}%"
        },
        "medium-charging": {
          "when": {
            "percent": 50
          },
          "format": "MEDIUM (CHARGING) {percentage}%"
        },
        "good": {
          "when": {
            "percent": 75,
            "charging": false
          },
          "format": "GOOD {percentage}%"
        },
        "good-charging": {
          "when": {
            "percent": 75,
            "charging": true
          },
          "format": "GOOD (CHARGING) {percentage}%"
        },
        "empty": {
          "when": {
            "percent": 1,
            "charging": true
          }
        }
      }
    }
  ]
}

Each profile is named according to its key (low, medium). When a profile is active, a class name is appended to the relevant widget in the form profile-{name}. This allows for dynamic styling (#415), and for replacing the battery thresholds option.

Config API

The API is more involved than the original thresholds design.

Within a configuration struct you'd place a profiles field of type Profiles<S, T>. S represents the state/matcher type, and T represents the configuration contained within a profile.

You must include #[serde(flatten)] on the field to avoid a double profiles.profiles syntax and ensure the default is merged at the top-level correctly.

struct VolumeModule {
  #[serde(flatten)]
  pub(super) profiles: Profiles<f64, VolumeProfile>,
}
struct BatteryModule {
    #[serde(flatten)]
    profiles: Profiles<ProfileState, BatteryProfile>,
}

Compound state

Complex state objects must manually implement the PartialOrd and State traits. Deriving PartialOrd will likely produce an incorrect sorter.

The PartialOrd implementation must obey the following rules:

  • The primary field must be sorted first (eg battery percentage)
  • For optional fields, a Some variant is less than a None. This is because internally the first matching profile is used.
impl PartialOrd for ProfileState {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        if self.percent == other.percent {
            match (self.charging, other.charging) {
                (Some(_), Some(_)) | (None, None) => Some(Ordering::Equal),
                (None, Some(_)) => Some(Ordering::Greater),
                (Some(_), None) => Some(Ordering::Less),
            }
        } else {
            self.percent.partial_cmp(&other.percent)
        }
    }
}

The state implementation requires a single method, which checks whether an input state matches the profile matcher. Again optional fields need to be considered here - if omitted in &self (the config value), matching should ignore this field.

impl State for ProfileState {
    fn matches(&self, value: &Self) -> bool {
        match self.charging {
            Some(charging) => {
                charging == value.charging.expect("value should exist")
                    && value.percent <= self.percent
            }
            None => value.percent <= self.percent,
        }
    }
}

Profiles

Each module should provide a default implementation for its profile type, and a set of appropriate default profiles.

#[derive(Debug, Default, Clone, Deserialize)]
#[cfg_attr(feature = "extras", derive(schemars::JsonSchema))]
#[serde(default)]
pub struct VolumeProfile {
    pub(super) icons: Icons,
}

impl Default for Icons {
    fn default() -> Self {
        Self {
            volume: "󰕾".to_string(),
            muted: "󰝟".to_string(),
        }
    }
}

Defaults

Due to serde limitations, relying on #[serde(default = "default_profiles")] does not work, and these must be injected manually. A new module lifecycle method has been added named on_create which takes a &mut self, allowing for alterations to the config as appropriate.

The Profiles struct exposes a setup_defaults method which takes another Profiles instance and merges it in. Any keys which do not exist in the user's configuration are added from the defaults.

The profiles! macro allows for quick creation of the profile map.

pub(super) fn default_profiles() -> Profiles<f64, VolumeProfile> {
    profiles!(
        "low":33 => VolumeProfile::for_volume_icon("󰕿"),
        "medium":67 => VolumeProfile::for_volume_icon("󰖀")
    )
}

// -- snip --
impl Module<Button> for VolumeModule {
  fn on_create(&mut self) {
      self.profiles.setup_defaults(config::default_profiles());
  } 
}

Module API

The Profiles struct exposes an attach method, which allows you to connect a 'primary' widget to the configuration (upon which the classnames are added), and an update callback.

This callback includes arguments for the widget and event data. Event data includes the value sent in the update, the associated profile configuration (including the default), and any user-data sent in the update.

The attach method returns a profiles manager used for dispatching updates.

let mut manager = self.profiles.attach(
    &btn_mute,
    move |btn_mute, event: ProfileUpdateEvent<VolumeProfile, BtnMuteUiUpdate>| {
        btn_mute.set_active(event.data.muted);

        let icons = &event.profile.icons;
        btn_mute.set_label(if event.data.muted {
            &icons.muted
        } else {
            &icons.volume
        });
    },
);

Updates can then be sent elsewhere in the module code using manager.update.

manager.update(
    info.volume.percent() as i32,
    BtnMuteUiUpdate { muted: info.muted },
);

Outstanding

  • Code review
  • Battery implementation
  • Consider shorthand syntax to match current battery thresholds option
  • Configuration documentation
  • Development documentation
  • Code documentation

Supersedes https://github.com/JakeStanger/ironbar/pull/1274


🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.

## 📋 Pull Request Information **Original PR:** https://github.com/JakeStanger/ironbar/pull/1349 **Author:** [@JakeStanger](https://github.com/JakeStanger) **Created:** 1/27/2026 **Status:** ✅ Merged **Merged:** 2/3/2026 **Merged by:** [@JakeStanger](https://github.com/JakeStanger) **Base:** `master` ← **Head:** `feat/profiles` --- ### 📝 Commits (3) - [`775b1b1`](https://github.com/JakeStanger/ironbar/commit/775b1b1bb7e710b42b4734ea5c358a9ee945bd73) feat(config): standardised profiles system - [`bb9b337`](https://github.com/JakeStanger/ironbar/commit/bb9b337c2ac705f13fc5a99c26cc85fd93ddae86) feat(volume): introduce profiles support - [`1d0b966`](https://github.com/JakeStanger/ironbar/commit/1d0b9663175616b1f95c0ee201cdc7b91a106599) feat(battery): introduce profiles support ### 📊 Changes **12 files changed** (+970 additions, -258 deletions) <details> <summary>View changed files</summary> 📝 `docs/Development guide.md` (+108 -0) ➕ `docs/Profiles.md` (+125 -0) 📝 `docs/_Sidebar.md` (+1 -0) 📝 `docs/modules/Battery.md` (+8 -7) 📝 `docs/modules/Volume.md` (+20 -20) 📝 `src/config/mod.rs` (+2 -0) ➕ `src/config/profiles.rs` (+379 -0) 📝 `src/modules/battery.rs` (+107 -85) 📝 `src/modules/mod.rs` (+4 -0) ➕ `src/modules/volume/config.rs` (+113 -0) 📝 `src/modules/volume/mod.rs` (+74 -144) 📝 `test-configs/battery.corn` (+29 -2) </details> ### 📄 Description Adds a new standard configuration system for setting up module "profiles", which allow a defined subset of the configuration to change reactively when a certain value (volume, battery, brightness) reaches defined thresholds. The primary use of this system is to allow for dynamic icons (ie the volume indicator, wifi strength indicator) to be fully configurable. This approach also allows for any options included in a profile definition to be part of the same system. It also introduces a more declarative reactive UI approach via the profile update callback, which should in time lead to cleaner module code. Included in this PR is support for the following modules: - `volume` (configure volume icon) - `battery` (configure format) The system would additionally apply to the following modules: - `backlight` (configure brightness icon, #1235) - `network_manager` (configure wifi strength icon, #1233) The following locations would also expand to use this with future updates: - `battery` (configure battery icon, #935) - `music` (configure volume icon) In future, `sys_info` may also be supported, although this is not being considered for now. ## Configuration Profiles always include a "default" which is merged flat into the config. This means setting eg `icons.volume` *outside* of the profiles object will apply if no other profile definition takes precedent. Each profile **must** include a `when` key, which indicates the threshold. Once the monitored value (eg volume %) is **less than or equal to** the threshold, this profile activates. Only one profile is active at a time. In the example below, if the volume was `<= 33%`, the low profile would activate. ```corn let { $volume = { // default - applies if no other profile is active icons.volume = "󰕾" profiles = { // medium profile activates when volume <= 67% medium.when = 67 medium.icons.volume = "󰖀" // low profile activates when volume <= 33% low.when = 33 low.icons.volume = "󰕿" // you can also override other icons low.icons.muted = "icons:volume-low-muted" } } } in { end = [ $volume ] } ``` <details> <summary>Same config in JSON for clarity & highlighting</summary> ```json { "end": [ { "icons": { "volume": "󰕾" }, "profiles": { "medium": { "value": 67, "icons": { "volume": "󰖀" } }, "low": { "value": 33, "icons": { "volume": "󰕿", "muted": "icons:volume-low-muted" } } } } ] } ``` </details> The system also supports compound matchers, which is suitable where a single state value is not considered sufficient. **These should be kept simple and contain a minimal amount of values, two or three at an absolute push.** An example below for the battery module. A `percent` value must be provided. A `charging` value can also be provided, which takes higher priority. ```corn let { $battery = { type = "battery" format = "HIGH {percentage}%" profiles = { low.when = { percent = 20 } low.format = "LOW {percentage}%" low-charging.when = { percent = 20 charging = true } low-charging.format = "LOW (CHARGING) {percentage}%" medium.when = { percent = 50 charging = false } medium.format = "MEDIUM {percentage}%" medium-charging.when = { percent = 50 } medium-charging.format = "MEDIUM (CHARGING) {percentage}%" good.when = { percent = 75 charging = false } good.format = "GOOD {percentage}%" good-charging.when = { percent = 75 charging = true } good-charging.format = "GOOD (CHARGING) {percentage}%" empty.when = { percent = 1 charging = true } } } in { end [ $battery ] } ``` <details> <summary>JSON</summary> ```json { "end": [ { "type": "battery", "format": "HIGH {percentage}%", "profiles": { "low": { "when": { "percent": 20 }, "format": "LOW {percentage}%" }, "low-charging": { "when": { "percent": 20, "charging": true }, "format": "LOW (CHARGING) {percentage}%" }, "medium": { "when": { "percent": 50, "charging": false }, "format": "MEDIUM {percentage}%" }, "medium-charging": { "when": { "percent": 50 }, "format": "MEDIUM (CHARGING) {percentage}%" }, "good": { "when": { "percent": 75, "charging": false }, "format": "GOOD {percentage}%" }, "good-charging": { "when": { "percent": 75, "charging": true }, "format": "GOOD (CHARGING) {percentage}%" }, "empty": { "when": { "percent": 1, "charging": true } } } } ] } ``` </details> Each profile is named according to its key (low, medium). When a profile is active, a class name is appended to the relevant widget in the form `profile-{name}`. This allows for dynamic styling (#415), and for replacing the battery `thresholds` option. ## Config API The API is more involved than the original thresholds design. Within a configuration struct you'd place a `profiles` field of type `Profiles<S, T>`. `S` represents the state/matcher type, and `T` represents the configuration contained within a profile. You **must** include `#[serde(flatten)]` on the field to avoid a double `profiles.profiles` syntax and ensure the default is merged at the top-level correctly. ```rust struct VolumeModule { #[serde(flatten)] pub(super) profiles: Profiles<f64, VolumeProfile>, } ``` ```rust struct BatteryModule { #[serde(flatten)] profiles: Profiles<ProfileState, BatteryProfile>, } ``` ### Compound state Complex state objects must manually implement the `PartialOrd` and `State` traits. Deriving `PartialOrd` will likely produce an incorrect sorter. The PartialOrd implementation must obey the following rules: - The primary field must be sorted first (eg battery percentage) - For optional fields, a `Some` variant is *less* than a `None`. This is because internally the first matching profile is used. ```rs impl PartialOrd for ProfileState { fn partial_cmp(&self, other: &Self) -> Option<Ordering> { if self.percent == other.percent { match (self.charging, other.charging) { (Some(_), Some(_)) | (None, None) => Some(Ordering::Equal), (None, Some(_)) => Some(Ordering::Greater), (Some(_), None) => Some(Ordering::Less), } } else { self.percent.partial_cmp(&other.percent) } } } ``` The state implementation requires a single method, which checks whether an input state matches the profile matcher. Again optional fields need to be considered here - if omitted in `&self` (the config value), matching should ignore this field. ```rs impl State for ProfileState { fn matches(&self, value: &Self) -> bool { match self.charging { Some(charging) => { charging == value.charging.expect("value should exist") && value.percent <= self.percent } None => value.percent <= self.percent, } } } ``` ### Profiles Each module should provide a default implementation for its profile type, and a set of appropriate default profiles. ```rs #[derive(Debug, Default, Clone, Deserialize)] #[cfg_attr(feature = "extras", derive(schemars::JsonSchema))] #[serde(default)] pub struct VolumeProfile { pub(super) icons: Icons, } impl Default for Icons { fn default() -> Self { Self { volume: "󰕾".to_string(), muted: "󰝟".to_string(), } } } ``` #### Defaults Due to serde limitations, relying on `#[serde(default = "default_profiles")]` does not work, and these must be injected manually. A new module lifecycle method has been added named `on_create` which takes a `&mut self`, allowing for alterations to the config as appropriate. The `Profiles` struct exposes a `setup_defaults` method which takes another `Profiles` instance and merges it in. Any keys which do not exist in the user's configuration are added from the defaults. The `profiles!` macro allows for quick creation of the profile map. ```rs pub(super) fn default_profiles() -> Profiles<f64, VolumeProfile> { profiles!( "low":33 => VolumeProfile::for_volume_icon("󰕿"), "medium":67 => VolumeProfile::for_volume_icon("󰖀") ) } // -- snip -- impl Module<Button> for VolumeModule { fn on_create(&mut self) { self.profiles.setup_defaults(config::default_profiles()); } } ``` ## Module API The `Profiles` struct exposes an `attach` method, which allows you to connect a 'primary' widget to the configuration (upon which the classnames are added), and an update callback. This callback includes arguments for the widget and event data. Event data includes the value sent in the update, the associated profile configuration (including the default), and any user-data sent in the update. The `attach` method returns a profiles manager used for dispatching updates. ```rs let mut manager = self.profiles.attach( &btn_mute, move |btn_mute, event: ProfileUpdateEvent<VolumeProfile, BtnMuteUiUpdate>| { btn_mute.set_active(event.data.muted); let icons = &event.profile.icons; btn_mute.set_label(if event.data.muted { &icons.muted } else { &icons.volume }); }, ); ``` Updates can then be sent elsewhere in the module code using `manager.update`. ```rs manager.update( info.volume.percent() as i32, BtnMuteUiUpdate { muted: info.muted }, ); ``` ## Outstanding - [x] Code review - [x] Battery implementation - [x] Consider shorthand syntax to match current battery `thresholds` option - [x] Configuration documentation - [x] Development documentation - [x] Code documentation --- Supersedes https://github.com/JakeStanger/ironbar/pull/1274 --- <sub>🔄 This issue represents a GitHub Pull Request. It cannot be merged through Gitea due to API limitations.</sub>
JakeStanger 2026-05-23 00:57:30 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
JakeStanger/ironbar#5542
No description provided.