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
15 changes: 15 additions & 0 deletions EntityRow.qml
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,19 @@ CursorSurface {

signal expandToggled()
signal cursorRequested()
signal expandedControlCursorRequested(int index)

property bool expanded: false
property int expandedControlCursorIndex: -1
readonly property int expandedControlCount: row.domain === "climate"
&& expansion.item !== null ? expansion.item.selectorCount : 0
readonly property bool expandedControlPopupOpen: row.domain === "climate"
&& expansion.item !== null && expansion.item.popupOpen

function activateExpandedControl(index) {
if (row.domain !== "climate" || expansion.item === null) return false
return expansion.item.activateSelector(index)
}

readonly property color fg: bar ? bar.foreground : Color.foreground
readonly property string family: bar ? bar.fontFamily : Style.font.family
Expand Down Expand Up @@ -260,6 +271,10 @@ CursorSurface {
id: climateControls
ClimateControls {
hass: row.hass; entityId: row.entityId; entity: row.entity; bar: row.bar
selectorCursorIndex: row.expandedControlCursorIndex
onSelectorCursorRequested: function(index) {
row.expandedControlCursorRequested(index)
}
}
}

Expand Down
144 changes: 143 additions & 1 deletion Model.js
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,9 @@ var COVER_STOP = 8

var CLIMATE_TARGET_TEMPERATURE = 1
var CLIMATE_TARGET_TEMPERATURE_RANGE = 2
var CLIMATE_FAN_MODE = 8
var CLIMATE_PRESET_MODE = 16
var CLIMATE_SWING_MODE = 32
var CLIMATE_TURN_OFF = 128
var CLIMATE_TURN_ON = 256

Expand Down Expand Up @@ -226,6 +229,10 @@ function capabilitiesFor(entity) {
coverClose: false,
climateTarget: false,
climateRange: false,
climateHvacMode: false,
climateFanMode: false,
climatePresetMode: false,
climateSwingMode: false,
expandable: false,
reserveExpandSlot: false
}
Expand All @@ -247,12 +254,21 @@ function capabilitiesFor(entity) {
&& typeof a.target_temp_high === "number"
result.climateTarget = hasFeature(bits, CLIMATE_TARGET_TEMPERATURE)
&& typeof a.temperature === "number"
}
result.climateHvacMode = hasClimateModeOption(entity, "hvac_modes")
result.climateFanMode = hasFeature(bits, CLIMATE_FAN_MODE)
&& hasClimateModeOption(entity, "fan_modes")
result.climatePresetMode = hasFeature(bits, CLIMATE_PRESET_MODE)
&& hasClimateModeOption(entity, "preset_modes")
result.climateSwingMode = hasFeature(bits, CLIMATE_SWING_MODE)
&& hasClimateModeOption(entity, "swing_modes")

}
result.expandable = result.brightness
|| result.mediaPrevious || result.mediaPlayPause || result.mediaNext
|| result.mediaVolume || result.coverOpen || result.coverStop
|| result.coverClose || result.climateTarget || result.climateRange
|| result.climateHvacMode || result.climateFanMode
|| result.climatePresetMode || result.climateSwingMode
// Climate integrations commonly clear the live target while the device is
// off. Keep the row geometry stable without pretending there is a target
// value to edit: the chevron remains hidden/disabled until controls are
Expand Down Expand Up @@ -343,6 +359,132 @@ function climateTemperatureData(entity, target, low, high, unitFallback) {
return data
}

// HVAC mode is the climate entity state. Unlike optional climate controls,
// Home Assistant does not assign it a supported-feature bit; the advertised
// `hvac_modes` list is the capability contract for climate.set_hvac_mode.
function climateModeOptions(entity, optionsAttribute) {
var declared = attrs(entity)[optionsAttribute]
if (!Array.isArray(declared)) return []
var modes = []
for (var i = 0; i < declared.length; i++) {
if (typeof declared[i] !== "string" || !declared[i].trim()) continue
if (modes.indexOf(declared[i]) === -1) modes.push(declared[i])
}
return modes
}

// Projection only needs the capability bit, not a new option list on every
// state update. Scan the advertised values directly.
function hasClimateModeOption(entity, optionsAttribute) {
var declared = attrs(entity)[optionsAttribute]
if (!Array.isArray(declared)) return false
for (var i = 0; i < declared.length; i++) {
if (typeof declared[i] === "string" && declared[i].trim()) return true
}
return false
}

function climateModeDeclared(entity, optionsAttribute, mode) {
var declared = attrs(entity)[optionsAttribute]
if (!Array.isArray(declared) || typeof mode !== "string") return false
for (var i = 0; i < declared.length; i++) {
if (declared[i] === mode && mode.trim()) return true
}
return false
}

function climateAttributeMode(entity, attributeName) {
var mode = attrs(entity)[attributeName]
return typeof mode === "string" ? mode : ""
}

function climateModeData(entity, mode, featureFlag, optionsAttribute, payloadKey) {
if (domain(entity) !== "climate" || isUnavailable(entity)
|| (featureFlag && !hasFeature(featureBits(entity), featureFlag))
|| !climateModeDeclared(entity, optionsAttribute, mode)) {
return {}
}
var data = {}
data[payloadKey] = mode
return data
}

function climateHvacModes(entity) {
return climateModeOptions(entity, "hvac_modes")
}

function climateHvacMode(entity) {
return stateOf(entity)
}

// Home Assistant mode tokens are protocol values, not ready-made UI copy.
// Preserve the token for service calls, but never render separators verbatim.
function humanizeMode(mode) {
var text = cleaned(mode).replace(/[_-]+/g, " ")
return capitalize(text)
}

function climateHvacModeLabel(mode) {
return mode === "heat_cool" ? "Heat/Cool" : humanizeMode(mode)
}

function climateFanModeLabel(mode) {
return humanizeMode(mode)
}

function climateHvacModeData(entity, mode) {
return climateModeData(entity, mode, 0, "hvac_modes", "hvac_mode")
}

// Climate integrations declare every permitted fan-mode token. Preserve tokens
// exactly because Home Assistant expects the selected value verbatim.
function climateFanModes(entity) {
return climateModeOptions(entity, "fan_modes")
}

function climateFanMode(entity) {
return climateAttributeMode(entity, "fan_mode")
}

function climateFanModeData(entity, mode) {
return climateModeData(entity, mode, CLIMATE_FAN_MODE, "fan_modes", "fan_mode")
}

function climatePresetModeLabel(mode) {
return humanizeMode(mode)
}

function climatePresetModes(entity) {
return climateModeOptions(entity, "preset_modes")
}

function climatePresetMode(entity) {
return climateAttributeMode(entity, "preset_mode")
}

function climatePresetModeData(entity, mode) {
return climateModeData(entity, mode, CLIMATE_PRESET_MODE,
"preset_modes", "preset_mode")
}

function climateSwingModeLabel(mode) {
return humanizeMode(mode)
}

function climateSwingModes(entity) {
return climateModeOptions(entity, "swing_modes")
}

function climateSwingMode(entity) {
return climateAttributeMode(entity, "swing_mode")
}

function climateSwingModeData(entity, mode) {
return climateModeData(entity, mode, CLIMATE_SWING_MODE,
"swing_modes", "swing_mode")
}


// ---------------------------------------------------------------- icons

// Material Design Icons, as in Home Assistant's own `mdi:` hints. Codepoints
Expand Down
82 changes: 78 additions & 4 deletions Panel.qml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Panel {
// Dormant until a key is pressed.
property int cursorIndex: 0
property bool cursorActive: false
property int expandedControlCursorIndex: -1

readonly property int rowCount: serviceReady ? hass.rows.count : 0
readonly property bool hasDevices: serviceReady && hass.hasDevices
Expand All @@ -34,11 +35,53 @@ Panel {
expandedEntityId = ""
cursorActive = false
cursorIndex = 0
expandedControlCursorIndex = -1
}

function moveCursor(delta) {
if (rowCount === 0) return
cursorIndex = Math.max(0, Math.min(rowCount - 1, cursorIndex + delta))
if (rowCount === 0 || delta === 0) return
var currentPosition = 0
var total = 0
for (var i = 0; i < rowCount; i++) {
var item = entityRepeater.itemAt(i)
var controls = item && item.expanded ? item.expandedControlCount : 0
if (i === cursorIndex) {
var controlOffset = root.expandedControlCursorIndex >= 0
&& root.expandedControlCursorIndex < controls
? root.expandedControlCursorIndex + 1 : 0
currentPosition = total + controlOffset
}
total += 1 + controls
}

var nextPosition = Math.max(0, Math.min(total - 1, currentPosition + delta))
if (nextPosition === currentPosition) return
for (var rowIndex = 0; rowIndex < rowCount; rowIndex++) {
var row = entityRepeater.itemAt(rowIndex)
var rowControls = row && row.expanded ? row.expandedControlCount : 0
if (nextPosition === 0) {
cursorIndex = rowIndex
expandedControlCursorIndex = -1
return
}
if (nextPosition <= rowControls) {
cursorIndex = rowIndex
expandedControlCursorIndex = nextPosition - 1
return
}
nextPosition -= 1 + rowControls
}
}

function moveCursorH(delta) {
var item = currentRow()
if (!item || root.expandedControlCursorIndex < 0) {
root.switchTab(delta)
return
}
root.expandedControlCursorIndex = Math.max(
0, Math.min(item.expandedControlCount - 1,
root.expandedControlCursorIndex + delta))
}

function switchTab(delta) {
Expand All @@ -50,6 +93,7 @@ Panel {
var next = (current + delta + tabs.length) % tabs.length
hass.setActiveTab(tabs[next].id)
cursorIndex = 0
expandedControlCursorIndex = -1
expandedEntityId = ""
}

Expand All @@ -58,10 +102,25 @@ Panel {
if (cursorIndex < 0 || cursorIndex >= items) return null
return entityRepeater.itemAt(cursorIndex)
}
readonly property bool expandedControlPopupOpen: {
if (!root.expandedEntityId) return false
for (var i = 0; i < entityRepeater.count; i++) {
var item = entityRepeater.itemAt(i)
if (item && item.entityId === root.expandedEntityId) {
return item.expandedControlPopupOpen
}
}
return false
}

function activateCursor() {
var item = currentRow()
if (item) item.activate()
if (!item) return
if (root.expandedControlCursorIndex >= 0) {
item.activateExpandedControl(root.expandedControlCursorIndex)
} else {
item.activate()
}
}

// A separate plugin surface, so it goes through the shell. The popup closes
Expand All @@ -76,6 +135,7 @@ Panel {
var item = currentRow()
if (!item || !item.expandable) return
expandedEntityId = (expandedEntityId === item.entityId) ? "" : item.entityId
expandedControlCursorIndex = -1
}

// Colour carries the state, so the button never changes width.
Expand Down Expand Up @@ -211,13 +271,15 @@ Panel {
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
// An open dropdown owns j/k, arrows, Enter, and Escape.
blocked: root.expandedControlPopupOpen
onCloseRequested: root.close()
onTabRequested: function(direction) { root.switchPanel(direction) }
onMoveRequested: function(dx, dy) {
// The first key press only wakes the cursor.
if (!root.cursorActive) { root.cursorActive = true; return }
if (dy !== 0) root.moveCursor(dy)
else if (dx !== 0) root.switchTab(dx)
else if (dx !== 0) root.moveCursorH(dx)
}
onActivateRequested: if (root.cursorActive) root.activateCursor()
onTextKey: function(key) {
Expand Down Expand Up @@ -288,6 +350,7 @@ Panel {
if (!root.serviceReady) return
root.hass.setActiveTab(value)
root.cursorIndex = 0
root.expandedControlCursorIndex = -1
root.expandedEntityId = ""
}
}
Expand Down Expand Up @@ -438,15 +501,26 @@ Panel {
showIcon: root.serviceReady ? root.hass.showEntityIcons : true
reserveExpandSlot: root.serviceReady ? root.hass.rowsHaveExpandable : false
hasCursor: root.cursorActive && root.cursorIndex === index
&& root.expandedControlCursorIndex < 0
expanded: root.expandedEntityId === entityId
expandedControlCursorIndex: root.cursorIndex === index
? root.expandedControlCursorIndex : -1
onCursorRequested: {
root.cursorActive = true
root.cursorIndex = index
root.expandedControlCursorIndex = -1
}
onExpandToggled: {
// One at a time: this is a popup, not a dashboard.
root.expandedEntityId = (root.expandedEntityId === entityId)
? "" : entityId
root.expandedControlCursorIndex = -1
}
onExpandedControlCursorRequested: function(controlIndex) {
if (controlIndex < 0) return
root.cursorActive = true
root.cursorIndex = index
root.expandedControlCursorIndex = controlIndex
}
}
}
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ Quickshell plugin for **Omarchy 4**. Pick the devices and toggle lights, adjust

## Keyboard

With the panel open: `j`/`k` or arrows move, `←`/`→` switch area tabs, `enter`
turns the highlighted device on or off, `e` expands its controls, `s` opens
settings, `r` refreshes, `esc` closes, `tab` moves to the next bar panel.
With the panel open, `j`/`k` or `↑`/`↓` traverse device rows and their
expanded controls. Inside expanded controls, `←`/`→` move between selectors;
otherwise they switch area tabs. `enter` activates the selected row or opens
the selected dropdown. `e` expands its controls, `s` opens settings, `r`
refreshes, `esc` closes, and `tab` moves to the next bar panel.

## What you can control

Expand All @@ -32,7 +34,7 @@ settings, `r` refreshes, `esc` closes, `tab` moves to the next bar panel.
| `scene`, `script` | Activate button |
| `media_player` | Previous / play-pause / next, volume slider |
| `cover` | Open / stop / close |
| `climate` | On/off when advertised, plus a target temperature or low/high band |
| `climate` | On/off when advertised, plus HVAC, fan, preset, swing, and target-temperature or low/high-band controls when advertised |
| `sensor`, `binary_sensor`, everything else | State display only |

Cameras not yet.
Expand Down
Loading
Loading