Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 53 additions & 21 deletions thinkpad-fan/BarWidget.qml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,17 @@ Item {
readonly property real barFontSize: Style.getBarFontSizeForScreen(root.screenName)
readonly property string fixedFont: Settings.data.ui.fontFixed

// Special fan level identifiers as reported by /proc/acpi/ibm/fan
readonly property string levelAuto: "auto"
readonly property string levelOff: "0"
readonly property string levelUnknown: "unknown"

// procfs/sysfs don't emit inotify events — values must be polled
readonly property int pollIntervalMs: 2000
readonly property int refreshDelayMs: 300

property int fanRpm: 0
property string fanLevel: "auto"
property string fanLevel: levelAuto
property int currentTemp: 0
property bool isInitialized: false

Expand All @@ -39,6 +48,29 @@ Item {
pluginApi?.manifest?.metadata?.defaultSettings?.allowPopupOpening ??
true

// Per-mode colors. An empty value means "neutral" (match the bar): off and
// forced-speed default to a theme color, automatic defaults to neutral.
readonly property var rawColorLevel0:
pluginApi?.pluginSettings?.colorLevel0 ??
pluginApi?.manifest?.metadata?.defaultSettings?.colorLevel0 ??
Color.mError
readonly property var rawColorActive:
pluginApi?.pluginSettings?.colorActive ??
pluginApi?.manifest?.metadata?.defaultSettings?.colorActive ??
Color.mPrimary
readonly property var rawColorAuto:
pluginApi?.pluginSettings?.colorAuto ??
pluginApi?.manifest?.metadata?.defaultSettings?.colorAuto ??
""

readonly property bool level0IsNeutral: String(rawColorLevel0).length === 0
readonly property bool activeIsNeutral: String(rawColorActive).length === 0
readonly property bool autoIsNeutral: String(rawColorAuto).length === 0

readonly property string colorLevel0: level0IsNeutral ? Style.capsuleColor : rawColorLevel0
readonly property string colorActive: activeIsNeutral ? Style.capsuleColor : rawColorActive
readonly property string colorAuto: autoIsNeutral ? Style.capsuleColor : rawColorAuto

readonly property real contentWidth: layout.implicitWidth + Style.marginS * 2
readonly property real contentHeight: capsuleHeight
implicitWidth: contentWidth
Expand All @@ -62,7 +94,7 @@ Item {
if (content) {
let lines = content.split("\n");
let parsedRpm = 0;
let parsedLevel = "auto";
let parsedLevel = root.levelAuto;

for (let i = 0; i < lines.length; i++) {
let line = lines[i].trim();
Expand Down Expand Up @@ -112,7 +144,7 @@ Item {
}

let cleanLevel = String(targetLevel).replace(/[\r\n\t]/g, "").trim().toLowerCase();
if (!cleanLevel || cleanLevel === "unknown") {
if (!cleanLevel || cleanLevel === root.levelUnknown) {
return;
}

Expand All @@ -122,19 +154,28 @@ Item {
fanProcess.running = true;
}

Timer { id: refreshTimer; interval: 300; repeat: false; onTriggered: { fanLoader.reload(); tempLoader.reload(); } }
// procfs/sysfs don't emit inotify events — polling is required for live values
Timer { interval: 2000; running: true; repeat: true; triggeredOnStart: true; onTriggered: { fanLoader.reload(); tempLoader.reload(); } }
Timer { id: refreshTimer; interval: root.refreshDelayMs; repeat: false; onTriggered: { fanLoader.reload(); tempLoader.reload(); } }
Timer { interval: root.pollIntervalMs; running: true; repeat: true; triggeredOnStart: true; onTriggered: { fanLoader.reload(); tempLoader.reload(); } }

readonly property bool isCustomActive: root.fanLevel !== root.levelAuto && root.fanLevel !== root.levelOff
readonly property bool isOff: root.fanLevel === root.levelOff

readonly property bool isCustomActive: root.fanLevel !== "auto" && root.fanLevel !== "0"
// Resolved color + neutral flag for the currently active fan mode
readonly property bool currentIsNeutral:
!root.colorizeByStatus
|| (root.isCustomActive ? root.activeIsNeutral
: (root.isOff ? root.level0IsNeutral : root.autoIsNeutral))
readonly property string currentColor:
root.isCustomActive ? root.colorActive
: (root.isOff ? root.colorLevel0 : root.colorAuto)

// ===== NATIVE NOCTALIA CONTEXT MENU =====
NPopupContextMenu {
id: contextMenu

model: [
{
"label": "Widget Settings",
"label": pluginApi?.tr("menu.widget-settings"),
"action": "settings",
"icon": "settings"
}
Expand All @@ -160,17 +201,8 @@ Item {
height: root.contentHeight
radius: Style.radiusL

color: !root.colorizeByStatus
? Style.capsuleColor
: (root.isCustomActive
? Color.mPrimary
: (root.fanLevel === "0" ? "#cc241d" : Style.capsuleColor))

border.color: !root.colorizeByStatus
? Style.capsuleBorderColor
: (root.isCustomActive
? Color.mPrimary
: (root.fanLevel === "0" ? "#cc241d" : Style.capsuleBorderColor))
color: root.currentIsNeutral ? Style.capsuleColor : root.currentColor
border.color: root.currentIsNeutral ? Style.capsuleBorderColor : root.currentColor
border.width: Style.capsuleBorderWidth

RowLayout {
Expand All @@ -181,7 +213,7 @@ Item {
NIcon {
id: fanIcon
icon: "car-fan"
color: root.colorizeByStatus && (root.isCustomActive || root.fanLevel === "0") ? Color.mOnPrimary : Color.mOnSurface
color: root.currentIsNeutral ? Color.mOnSurface : Color.mOnPrimary
}

NText {
Expand All @@ -190,7 +222,7 @@ Item {
pointSize: barFontSize
font.family: root.fixedFont
font.weight: Font.Bold
color: root.colorizeByStatus && (root.isCustomActive || root.fanLevel === "0") ? Color.mOnPrimary : Color.mOnSurface
color: root.currentIsNeutral ? Color.mOnSurface : Color.mOnPrimary
}
}
}
Expand Down
18 changes: 13 additions & 5 deletions thinkpad-fan/FanPopup.qml
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,17 @@ Item {
property string level: ""

readonly property bool isSelected: root.mainWidget?.fanLevel === level


// Resolved color + neutral flag for the mode this button represents
readonly property bool stateNeutral:
level === (root.mainWidget?.levelOff ?? "0") ? (root.mainWidget?.level0IsNeutral ?? false)
: level === (root.mainWidget?.levelAuto ?? "auto") ? (root.mainWidget?.autoIsNeutral ?? true)
: (root.mainWidget?.activeIsNeutral ?? false)
readonly property string stateColor:
level === (root.mainWidget?.levelOff ?? "0") ? (root.mainWidget?.colorLevel0 ?? Color.mError)
: level === (root.mainWidget?.levelAuto ?? "auto") ? (root.mainWidget?.colorAuto ?? Style.capsuleColor)
: (root.mainWidget?.colorActive ?? Color.mPrimary)

implicitWidth: 52 * Style.uiScaleRatio
implicitHeight: 28 * Style.uiScaleRatio
cursorShape: Qt.PointingHandCursor
Expand All @@ -92,9 +102,7 @@ Item {
Rectangle {
anchors.fill: parent
radius: Style.radiusS
color: parent.isSelected
? (parent.level === "0" ? "#cc241d" : Color.mPrimary)
: Color.mSurface
color: parent.isSelected ? parent.stateColor : Color.mSurface
opacity: parent.isSelected ? 1.0 : (parent.containsMouse ? 0.85 : 0.5)
}

Expand All @@ -103,7 +111,7 @@ Item {
text: parent.text
font.weight: parent.isSelected ? Font.Bold : Font.Normal
pointSize: Style.fontSizeS
color: parent.isSelected ? Color.mOnPrimary : Color.mOnSurface
color: (parent.isSelected && !parent.stateNeutral) ? Color.mOnPrimary : Color.mOnSurface
}
}
}
20 changes: 17 additions & 3 deletions thinkpad-fan/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,23 @@ A resilient system utility plugin designed for the **Noctalia** desktop shell en

* **Dynamic Fan Speed Indicator**: Embedded status bar module reporting realtime revolutions per minute (`RPM`) telemetry, updating cycles safely every 2 seconds.
* **Thermal Zone Inspector**: Contextual diagnostic popup panel tracking active primary sensor clusters.
* **Stateful Micro-Pill Alerts**: Custom visual states that color-shift dynamically based on active overrides:
* Turns **Solid Crimson Red** if safety limits are bypassed by forcing the fans off (`level 0`).
* Shifts to system `mPrimary` palette states when explicit constant numeric thresholds are locked down.
* **Stateful Micro-Pill Alerts**: The bar capsule color-shifts based on the current fan mode (when *Dynamic coloring* is enabled):
* **Fan off (`level 0`)**: uses the configurable *Fan off* color (defaults to the theme `mError`).
* **Automatic mode**: neutral by default, matching the rest of the bar — optionally a custom *Automatic mode* color can be chosen.
* **Any forced speed** (levels `1`–`7`, full speed, …): uses the configurable *Fan active* color (defaults to the theme `mPrimary`).
* **Configurable Colors**: The *Fan off*, *Fan active* and *Automatic mode* colors are pickable from the theme palette in the plugin settings — each can also be set to **neutral** (no color) to match the bar.

## Settings
--------

Open the plugin settings (right-click the widget → **Widget Settings**) to configure:

* **Dynamic coloring**: toggle the mode-based capsule coloring on/off. When off, the capsule keeps the default bar color.
* **Fan off color**: palette color used when the fan is stopped (`level 0`).
* **Fan active color**: palette color used whenever the fan runs at a forced speed (every mode except automatic and off).
* **Automatic mode color**: palette color for automatic mode (neutral by default).
* Each of the three pickers includes a **neutral** (no-color) option as the first swatch — choose it to keep that mode matching the bar.
* **Fan speed manual override**: when enabled, left-clicking the widget opens the manual fan control panel.

## Prerequisites
-------------
Expand Down
169 changes: 164 additions & 5 deletions thinkpad-fan/SettingsView.qml
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,94 @@ ColumnLayout {
pluginApi?.manifest?.metadata?.defaultSettings?.allowPopupOpening ??
true

property string editColorLevel0:
pluginApi?.pluginSettings?.colorLevel0 ??
pluginApi?.manifest?.metadata?.defaultSettings?.colorLevel0 ??
Color.mError

property string editColorActive:
pluginApi?.pluginSettings?.colorActive ??
pluginApi?.manifest?.metadata?.defaultSettings?.colorActive ??
Color.mPrimary

// Empty = unset → automatic mode stays neutral
property string editColorAuto:
pluginApi?.pluginSettings?.colorAuto ??
pluginApi?.manifest?.metadata?.defaultSettings?.colorAuto ??
""

// ===== SAVE =====
function saveSettings() {
if (!pluginApi) return
pluginApi.pluginSettings.colorizeByStatus = root.editColorizeByStatus
pluginApi.pluginSettings.allowPopupOpening = root.editAllowPopupOpening
pluginApi.pluginSettings.colorActive = root.editColorActive
pluginApi.pluginSettings.colorLevel0 = root.editColorLevel0
pluginApi.pluginSettings.colorAuto = root.editColorAuto
pluginApi.saveSettings()
}

// ===== UI =====
NText {
text: pluginApi?.tr("settings.title")
pointSize: Style.fontSizeM
font.weight: Font.Bold
color: Color.mOnSurface

// Color swatch sizing
readonly property int swatchSize: 28
readonly property int swatchBorderSelected: 3
readonly property int swatchBorderDefault: 1

// All palette colors
readonly property var noctaliaPalette: [
Color.mPrimary, Color.mSecondary, Color.mTertiary, Color.mError,
Color.mSurface, Color.mSurfaceVariant, Color.mOutline
]

// Reusable palette picker: a "neutral" (no-color) swatch followed by the theme palette.
// `selected` is the current value (empty = neutral); `picked` fires with the chosen value.
component ColorSwatchRow: RowLayout {
id: swatchRow
property string selected: ""
signal picked(string value)
spacing: Style.marginS

// Neutral / no-color option
Rectangle {
width: root.swatchSize
height: root.swatchSize
radius: root.swatchSize / 2
color: Style.capsuleColor
border.color: !swatchRow.selected ? Color.mOnSurface : Color.mOutline
border.width: !swatchRow.selected ? root.swatchBorderSelected : root.swatchBorderDefault

NIcon {
anchors.centerIn: parent
icon: "close"
color: Color.mOnSurfaceVariant
}

MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: swatchRow.picked("")
}
}

Repeater {
model: root.noctaliaPalette

Rectangle {
width: root.swatchSize
height: root.swatchSize
radius: root.swatchSize / 2
color: modelData
border.color: (swatchRow.selected && Qt.colorEqual(swatchRow.selected, modelData)) ? Color.mOnSurface : Color.mOutline
border.width: (swatchRow.selected && Qt.colorEqual(swatchRow.selected, modelData)) ? root.swatchBorderSelected : root.swatchBorderDefault

MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: swatchRow.picked(String(modelData))
}
}
}
}

// Option 1: Dynamic coloring based on fan status
Expand All @@ -48,6 +122,91 @@ ColumnLayout {
}
}

ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginM
enabled: root.editColorizeByStatus
opacity: enabled ? 1.0 : 0.5

// Level 0 color
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS

NText {
text: pluginApi?.tr("settings.color-level0")
font.weight: Font.Bold
}
NText {
text: pluginApi?.tr("settings.color-level0-desc")
font.pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}

ColorSwatchRow {
selected: root.editColorLevel0
onPicked: value => {
root.editColorLevel0 = value
root.saveSettings()
}
}
}

// Space separator
Item { Layout.preferredHeight: Style.marginS }

// Active (forced speed) color
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS

NText {
text: pluginApi?.tr("settings.color-active")
font.weight: Font.Bold
}
NText {
text: pluginApi?.tr("settings.color-active-desc")
font.pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}

ColorSwatchRow {
selected: root.editColorActive
onPicked: value => {
root.editColorActive = value
root.saveSettings()
}
}
}

// Space separator
Item { Layout.preferredHeight: Style.marginS }

// Automatic mode color (optional, neutral by default)
ColumnLayout {
Layout.fillWidth: true
spacing: Style.marginS

NText {
text: pluginApi?.tr("settings.color-auto")
font.weight: Font.Bold
}
NText {
text: pluginApi?.tr("settings.color-auto-desc")
font.pointSize: Style.fontSizeS
color: Color.mOnSurfaceVariant
}

ColorSwatchRow {
selected: root.editColorAuto
onPicked: value => {
root.editColorAuto = value
root.saveSettings()
}
}
}
}

// Option 2: Left Click Interaction Toggle
NToggle {
Layout.fillWidth: true
Expand Down
Loading