Compare commits

...
3 Commits
Author SHA1 Message Date
Kaloyan DanchevandClaude Opus 4.6 dce4f172ca Add visual config editor for HA tab
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
Self-contained HTML page that connects to HA WebSocket for entity
discovery, provides visual editing of rooms/cards/weather/vacuum
config, and exports config.json. Hosted from HA's /local/ directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 12:23:21 +03:00
Kaloyan DanchevandClaude Opus 4.6 764af371b9 Add glass card rendering and 6 card types for HA tab
Implement the full Home Assistant tab with room-based navigation
and glass-morphism card rendering:

- Toggle card: on/off for lights, switches, covers with state indicator
- Range card: dimmable lights with brightness bar and D-pad ±10% control
- Climate card: current/target temp, HVAC mode, D-pad ±0.5°C, sparkline
- Action card: one-shot activation for scenes and scripts
- Vacuum card: summary card that opens room selection modal
- Weather card: hero card with condition icon, temp, humidity, wind

Navigation: room tabs (horizontal pills), card grid (3-col), vacuum
modal with room multi-select, cleaning mode cycle, and start button.
Back button cascades: vacuum modal → card grid → room tabs → Home page.

Glass design: rgba(255,255,255,0.12) fill, 22px radius, 1px border at
0.15 opacity, elevated shadow on focus. Accent color #7EB8DA for active
entities, warm orange for heating, cool blue for cooling.

Wires Effect::Ha to dispatch toggle/brightness/temperature/scene/vacuum
service calls through ha_client. Adds 6 new SVG icons matching the
existing Lucide stroke style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 12:14:07 +03:00
Kaloyan DanchevandClaude Opus 4.6 98dd75ab0e Add Notification Center and Game Optimizer themes
Extend Luma-style dark theme to two more Enact web overlays:
- com.webos.app.notificationcenter: dark pills, transparent
  compositor, notification-specific components
- com.webos.app.gameoptimizer: same plus GameItem, GameButton,
  GameSwitch, GamePanel custom components

Rename build.js → build.cjs (ESM compat) and update to build
all three themes from a single script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 12:07:09 +03:00
14 changed files with 2523 additions and 39 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M2 4h20"></path><path d="M2 4v16"></path><path d="M22 4v16"></path><path d="M5 4c0 6 3 8 7 8"></path><path d="M19 4c0 6-3 8-7 8"></path><path d="M5 4c0 10 3 16 7 16"></path><path d="M19 4c0 10-3 16-7 16"></path></svg>

After

Width:  |  Height:  |  Size: 426 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z"></path></svg>

After

Width:  |  Height:  |  Size: 325 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path><path d="M9 18h6"></path><path d="M10 22h4"></path></svg>

After

Width:  |  Height:  |  Size: 375 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h.01"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>

After

Width:  |  Height:  |  Size: 459 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><rect x="2" y="6" width="20" height="12" rx="6"></rect><circle cx="16" cy="12" r="2"></circle></svg>

After

Width:  |  Height:  |  Size: 300 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><circle cx="12" cy="12" r="9"></circle><circle cx="12" cy="12" r="3"></circle><path d="M12 9V4"></path><path d="m14.6 10.5 4.3-2.5"></path><path d="m14.6 13.5 4.3 2.5"></path><path d="M12 15v5"></path><path d="m9.4 13.5-4.3 2.5"></path><path d="m9.4 10.5-4.3-2.5"></path></svg>

After

Width:  |  Height:  |  Size: 477 B

+771
View File
@@ -0,0 +1,771 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Luma Home Config Editor</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d26;
--card: #22262f;
--border: rgba(255,255,255,0.08);
--border-focus: rgba(126,184,218,0.5);
--text: #e8e8ea;
--text-dim: #888a90;
--accent: #7eb8da;
--accent-bg: rgba(126,184,218,0.12);
--danger: #e06060;
--danger-bg: rgba(224,96,96,0.12);
--success: #60c080;
--radius: 12px;
--radius-sm: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color-scheme: dark;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); min-height: 100vh; padding: 24px; max-width: 1200px; margin: 0 auto; }
h1 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
h2 { font-size: 18px; font-weight: 600; margin-bottom: 12px; color: var(--accent); }
h3 { font-size: 15px; font-weight: 500; margin-bottom: 8px; }
.subtitle { color: var(--text-dim); font-size: 14px; margin-bottom: 24px; }
.section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 16px; }
label { display: block; font-size: 13px; color: var(--text-dim); margin-bottom: 4px; }
input[type="text"], input[type="url"], input[type="number"], select {
width: 100%; padding: 8px 12px; background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text); font-size: 14px; outline: none;
transition: border-color .15s;
}
input:focus, select:focus { border-color: var(--border-focus); }
input[type="number"] { width: 80px; }
.row { display: flex; gap: 12px; align-items: end; margin-bottom: 12px; }
.row > * { flex: 1; min-width: 0; }
.row > .shrink { flex: 0 0 auto; }
button {
padding: 8px 16px; border-radius: var(--radius-sm); border: 1px solid var(--border);
background: var(--card); color: var(--text); font-size: 13px; cursor: pointer;
transition: all .15s; white-space: nowrap;
}
button:hover { border-color: var(--border-focus); background: var(--accent-bg); }
button.primary { background: var(--accent); color: #111; border-color: var(--accent); font-weight: 600; }
button.primary:hover { opacity: .85; }
button.danger { color: var(--danger); border-color: var(--danger); }
button.danger:hover { background: var(--danger-bg); }
button.small { padding: 4px 10px; font-size: 12px; }
.pill {
display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px;
background: var(--accent-bg); color: var(--accent); margin-right: 4px;
}
.entity-picker { position: relative; }
.entity-picker .dropdown {
position: absolute; top: 100%; left: 0; right: 0; max-height: 260px; overflow-y: auto;
background: var(--card); border: 1px solid var(--border-focus); border-radius: var(--radius-sm);
z-index: 100; display: none;
}
.entity-picker .dropdown.open { display: block; }
.entity-picker .dropdown .item {
padding: 8px 12px; cursor: pointer; font-size: 13px; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between;
}
.entity-picker .dropdown .item:hover { background: var(--accent-bg); }
.entity-picker .dropdown .item .domain { color: var(--text-dim); font-size: 11px; }
.entity-picker .dropdown .item .name { color: var(--text); }
.card-item {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 12px; margin-bottom: 8px; display: flex; gap: 12px; align-items: center;
}
.card-item .handle { cursor: grab; color: var(--text-dim); font-size: 18px; user-select: none; }
.card-item .info { flex: 1; min-width: 0; }
.card-item .info .eid { font-size: 12px; color: var(--text-dim); }
.card-item .actions { display: flex; gap: 4px; }
.room-tab {
display: inline-block; padding: 8px 16px; border-radius: 20px; cursor: pointer;
font-size: 14px; margin-right: 6px; margin-bottom: 8px; border: 1px solid var(--border);
background: var(--card); transition: all .15s;
}
.room-tab.active { background: var(--accent); color: #111; border-color: var(--accent); font-weight: 600; }
.room-tab:hover:not(.active) { border-color: var(--border-focus); }
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 10px 20px; border-radius: var(--radius); background: var(--success);
color: #111; font-weight: 600; font-size: 14px; z-index: 999; opacity: 0;
transition: opacity .3s; pointer-events: none;
}
.toast.show { opacity: 1; }
.status { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 16px; }
.status .dot { width: 8px; height: 8px; border-radius: 50%; }
.status .dot.on { background: var(--success); }
.status .dot.off { background: var(--danger); }
.empty { text-align: center; padding: 32px; color: var(--text-dim); }
.vacuum-room-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
.vacuum-room-item { display: flex; gap: 8px; align-items: center; }
.vacuum-room-item input[type="number"] { width: 60px; }
.vacuum-room-item input[type="text"] { flex: 1; }
#json-preview {
width: 100%; min-height: 300px; background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text); font-family: "SF Mono", Menlo, monospace;
font-size: 13px; padding: 12px; resize: vertical; tab-size: 2;
}
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
.tab-btn {
padding: 6px 14px; border: none; background: none; color: var(--text-dim);
font-size: 14px; cursor: pointer; border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.tab-btn.active { color: var(--accent); background: var(--accent-bg); font-weight: 600; }
.tab-content { display: none; }
.tab-content.active { display: block; }
</style>
</head>
<body>
<h1>Luma Home Config</h1>
<p class="subtitle">Visual editor for the Home Assistant tab on your LG TV</p>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('connection')">Connection</button>
<button class="tab-btn" onclick="switchTab('weather')">Weather</button>
<button class="tab-btn" onclick="switchTab('rooms')">Rooms</button>
<button class="tab-btn" onclick="switchTab('vacuum')">Vacuum</button>
<button class="tab-btn" onclick="switchTab('export')">Export</button>
</div>
<!-- Connection Tab -->
<div id="tab-connection" class="tab-content active">
<div class="section">
<h2>Home Assistant Connection</h2>
<div class="row">
<div>
<label>WebSocket URL</label>
<input type="url" id="ha-url" placeholder="wss://ha.example.org/api/websocket">
</div>
</div>
<div class="row">
<div>
<label>Long-Lived Access Token</label>
<input type="text" id="ha-token" placeholder="eyJ...">
</div>
</div>
<div class="row">
<div class="shrink">
<button class="primary" onclick="connect()">Connect</button>
</div>
<div class="shrink">
<button onclick="disconnect()">Disconnect</button>
</div>
</div>
<div class="status">
<div class="dot" id="conn-dot"></div>
<span id="conn-status">Not connected</span>
<span style="margin-left:auto;color:var(--text-dim)" id="entity-count"></span>
</div>
</div>
<div class="section">
<h2>Import Existing Config</h2>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">Paste a config.json or load from file to pre-populate the editor.</p>
<div class="row">
<div class="shrink"><button onclick="document.getElementById('file-input').click()">Load File</button></div>
<div class="shrink"><button onclick="importFromTextarea()">Paste JSON Below</button></div>
</div>
<input type="file" id="file-input" accept=".json" style="display:none" onchange="loadFile(event)">
<textarea id="import-json" rows="4" placeholder='Paste config.json here...' style="width:100%;margin-top:8px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:monospace;font-size:13px;padding:8px;resize:vertical"></textarea>
</div>
</div>
<!-- Weather Tab -->
<div id="tab-weather" class="tab-content">
<div class="section">
<h2>Weather Card</h2>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">Hero card shown at the top of every room. Leave empty to hide.</p>
<div class="row">
<div class="entity-picker">
<label>Weather Entity</label>
<input type="text" id="weather-entity" placeholder="weather.forecast_home" oninput="filterEntities(this,'weather')">
<div class="dropdown" id="weather-entity-dropdown"></div>
</div>
</div>
</div>
</div>
<!-- Rooms Tab -->
<div id="tab-rooms" class="tab-content">
<div class="section">
<h2>Rooms</h2>
<div id="room-tabs"></div>
<button onclick="addRoom()" style="margin-bottom:16px">+ Add Room</button>
<div id="room-editor"></div>
</div>
</div>
<!-- Vacuum Tab -->
<div id="tab-vacuum" class="tab-content">
<div class="section">
<h2>Vacuum Configuration</h2>
<div class="row">
<div class="entity-picker">
<label>Vacuum Entity</label>
<input type="text" id="vacuum-entity" placeholder="vacuum.roborock" oninput="filterEntities(this,'vacuum')">
<div class="dropdown" id="vacuum-entity-dropdown"></div>
</div>
<div class="entity-picker">
<label>Cleaning Mode Entity</label>
<input type="text" id="vacuum-mode-entity" placeholder="select.roborock_cleaning_mode" oninput="filterEntities(this,'select')">
<div class="dropdown" id="vacuum-mode-entity-dropdown"></div>
</div>
</div>
<h3>Room Segments</h3>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">Map Roborock segment IDs to room names.</p>
<div id="vacuum-rooms"></div>
<button onclick="addVacuumRoom()" class="small">+ Add Segment</button>
</div>
</div>
<!-- Export Tab -->
<div id="tab-export" class="tab-content">
<div class="section">
<h2>Generated config.json</h2>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">
Copy this to <code>ha-home/config.json</code> in your Luma installation, then restart Luma.
</p>
<textarea id="json-preview" readonly></textarea>
<div class="row" style="margin-top:12px">
<div class="shrink"><button class="primary" onclick="copyJson()">Copy to Clipboard</button></div>
<div class="shrink"><button onclick="downloadJson()">Download config.json</button></div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ── State ────────────────────────────────────────────────────────
let ws = null;
let msgId = 0;
let entities = {};
let config = {
version: 1,
ha: { url: '', token: '' },
weather: { entity_id: '' },
vacuum: { entity_id: '', cleaning_mode_entity: '', rooms: {} },
rooms: []
};
let activeRoom = 0;
// ── Init ─────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const saved = localStorage.getItem('luma-editor-config');
if (saved) {
try { Object.assign(config, JSON.parse(saved)); } catch {}
}
const url = localStorage.getItem('luma-ha-url');
const token = localStorage.getItem('luma-ha-token');
if (url) document.getElementById('ha-url').value = url;
if (token) document.getElementById('ha-token').value = token;
if (config.ha.url) document.getElementById('ha-url').value = config.ha.url;
if (config.weather?.entity_id) document.getElementById('weather-entity').value = config.weather.entity_id;
if (config.vacuum?.entity_id) document.getElementById('vacuum-entity').value = config.vacuum.entity_id;
if (config.vacuum?.cleaning_mode_entity) document.getElementById('vacuum-mode-entity').value = config.vacuum.cleaning_mode_entity;
renderRoomTabs();
renderVacuumRooms();
renderExport();
document.addEventListener('click', (e) => {
document.querySelectorAll('.dropdown.open').forEach(d => {
if (!d.parentElement.contains(e.target)) d.classList.remove('open');
});
});
});
// ── Tabs ─────────────────────────────────────────────────────────
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
if (name === 'export') renderExport();
}
// ── HA WebSocket ─────────────────────────────────────────────────
function connect() {
const url = document.getElementById('ha-url').value.trim();
const token = document.getElementById('ha-token').value.trim();
if (!url || !token) return toast('Enter URL and token');
config.ha.url = url;
config.ha.token = ''; // never persist token in config
localStorage.setItem('luma-ha-url', url);
localStorage.setItem('luma-ha-token', token);
saveState();
if (ws) ws.close();
setStatus('connecting');
try {
ws = new WebSocket(url);
} catch (e) {
setStatus('error', e.message);
return;
}
ws.onopen = () => {};
ws.onclose = () => setStatus('disconnected');
ws.onerror = () => setStatus('error');
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'auth_required') {
ws.send(JSON.stringify({ type: 'auth', access_token: token }));
} else if (msg.type === 'auth_ok') {
setStatus('connected');
fetchEntities();
} else if (msg.type === 'auth_invalid') {
setStatus('error', 'Invalid token');
ws.close();
} else if (msg.type === 'result' && msg.success && Array.isArray(msg.result)) {
processEntities(msg.result);
}
};
}
function disconnect() {
if (ws) ws.close();
ws = null;
entities = {};
setStatus('disconnected');
document.getElementById('entity-count').textContent = '';
}
function fetchEntities() {
msgId++;
ws.send(JSON.stringify({ id: msgId, type: 'get_states' }));
}
function processEntities(states) {
entities = {};
for (const s of states) {
entities[s.entity_id] = {
entity_id: s.entity_id,
state: s.state,
friendly_name: s.attributes?.friendly_name || s.entity_id,
domain: s.entity_id.split('.')[0],
attributes: s.attributes || {}
};
}
document.getElementById('entity-count').textContent = `${Object.keys(entities).length} entities`;
}
function setStatus(state, detail) {
const dot = document.getElementById('conn-dot');
const label = document.getElementById('conn-status');
dot.className = 'dot';
if (state === 'connected') {
dot.classList.add('on');
label.textContent = 'Connected';
} else if (state === 'connecting') {
label.textContent = 'Connecting...';
} else if (state === 'error') {
dot.classList.add('off');
label.textContent = 'Error' + (detail ? ': ' + detail : '');
} else {
dot.classList.add('off');
label.textContent = 'Disconnected';
}
}
// ── Entity Picker ────────────────────────────────────────────────
function filterEntities(input, domainFilter) {
const query = input.value.toLowerCase();
const dropdown = input.parentElement.querySelector('.dropdown');
if (query.length < 2) { dropdown.classList.remove('open'); return; }
const matches = Object.values(entities)
.filter(e => {
if (domainFilter && e.domain !== domainFilter) return false;
return e.entity_id.includes(query) || e.friendly_name.toLowerCase().includes(query);
})
.slice(0, 30);
if (matches.length === 0) { dropdown.classList.remove('open'); return; }
dropdown.innerHTML = matches.map(e => `
<div class="item" onclick="pickEntity(this, '${e.entity_id}')">
<span class="name">${esc(e.friendly_name)}</span>
<span class="domain">${esc(e.entity_id)}</span>
</div>
`).join('');
dropdown.classList.add('open');
}
function pickEntity(el, entityId) {
const input = el.closest('.entity-picker').querySelector('input');
input.value = entityId;
el.closest('.dropdown').classList.remove('open');
input.dispatchEvent(new Event('change'));
}
// ── Room Editor ──────────────────────────────────────────────────
function addRoom() {
const id = 'room_' + (config.rooms.length + 1);
config.rooms.push({ id, name: 'New Room', background: '', sensors: [], cards: [] });
activeRoom = config.rooms.length - 1;
renderRoomTabs();
renderRoomEditor();
saveState();
}
function removeRoom(index) {
config.rooms.splice(index, 1);
if (activeRoom >= config.rooms.length) activeRoom = Math.max(0, config.rooms.length - 1);
renderRoomTabs();
renderRoomEditor();
saveState();
}
function selectRoom(index) {
activeRoom = index;
renderRoomTabs();
renderRoomEditor();
}
function renderRoomTabs() {
const container = document.getElementById('room-tabs');
container.innerHTML = config.rooms.map((r, i) =>
`<span class="room-tab${i === activeRoom ? ' active' : ''}" onclick="selectRoom(${i})">${esc(r.name)}</span>`
).join('');
renderRoomEditor();
}
function renderRoomEditor() {
const container = document.getElementById('room-editor');
if (config.rooms.length === 0) {
container.innerHTML = '<div class="empty">No rooms. Click "+ Add Room" to start.</div>';
return;
}
const room = config.rooms[activeRoom];
if (!room) { container.innerHTML = ''; return; }
container.innerHTML = `
<div class="row">
<div>
<label>Room Name</label>
<input type="text" value="${esc(room.name)}" onchange="updateRoom('name', this.value)">
</div>
<div>
<label>Room ID (slug)</label>
<input type="text" value="${esc(room.id)}" onchange="updateRoom('id', this.value)">
</div>
<div class="shrink" style="padding-bottom:1px">
<button class="danger small" onclick="removeRoom(${activeRoom})">Delete Room</button>
</div>
</div>
<h3>Cards</h3>
<div id="cards-list">${renderCards(room.cards)}</div>
<div class="row" style="margin-top:8px">
<div class="shrink"><button onclick="addCard('toggle')">+ Toggle</button></div>
<div class="shrink"><button onclick="addCard('range')">+ Range</button></div>
<div class="shrink"><button onclick="addCard('climate')">+ Climate</button></div>
<div class="shrink"><button onclick="addCard('action')">+ Action</button></div>
<div class="shrink"><button onclick="addCard('vacuum')">+ Vacuum</button></div>
</div>
`;
}
function renderCards(cards) {
if (cards.length === 0) return '<div class="empty" style="padding:16px">No cards in this room.</div>';
return cards.map((card, i) => `
<div class="card-item" draggable="true" ondragstart="dragCard(event,${i})" ondragover="event.preventDefault()" ondrop="dropCard(event,${i})">
<span class="handle">⠿</span>
<div class="info">
<div><span class="pill">${card.type}</span> <strong>${esc(card.name || '')}</strong></div>
<div class="eid">${esc(card.entity_id)}</div>
</div>
<div class="actions">
<button class="small" onclick="editCard(${i})">Edit</button>
<button class="small danger" onclick="removeCard(${i})">×</button>
</div>
</div>
`).join('');
}
function updateRoom(field, value) {
config.rooms[activeRoom][field] = value;
renderRoomTabs();
saveState();
}
function addCard(type) {
const room = config.rooms[activeRoom];
room.cards.push({ type, entity_id: '', name: '' });
renderRoomEditor();
editCard(room.cards.length - 1);
saveState();
}
function removeCard(index) {
config.rooms[activeRoom].cards.splice(index, 1);
renderRoomEditor();
saveState();
}
let dragIndex = -1;
function dragCard(e, index) { dragIndex = index; e.dataTransfer.effectAllowed = 'move'; }
function dropCard(e, index) {
e.preventDefault();
if (dragIndex === index) return;
const cards = config.rooms[activeRoom].cards;
const [moved] = cards.splice(dragIndex, 1);
cards.splice(index, 0, moved);
renderRoomEditor();
saveState();
}
function editCard(index) {
const room = config.rooms[activeRoom];
const card = room.cards[index];
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;display:flex;align-items:center;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px;width:480px;max-width:90vw">
<h2 style="margin-bottom:16px">Edit Card</h2>
<div style="margin-bottom:12px">
<label>Type</label>
<select id="edit-type">
${['toggle','range','climate','action','vacuum'].map(t => `<option${t===card.type?' selected':''}>${t}</option>`).join('')}
</select>
</div>
<div style="margin-bottom:12px" class="entity-picker">
<label>Entity ID</label>
<input type="text" id="edit-entity" value="${esc(card.entity_id)}" oninput="filterEntities(this, document.getElementById('edit-type').value === 'climate' ? 'climate' : null)">
<div class="dropdown" id="edit-entity-dropdown"></div>
</div>
<div style="margin-bottom:12px">
<label>Display Name</label>
<input type="text" id="edit-name" value="${esc(card.name || '')}" placeholder="Leave empty to use HA friendly_name">
</div>
<div style="display:flex;gap:8px;justify-content:end">
<button onclick="this.closest('div[style*=fixed]').remove()">Cancel</button>
<button class="primary" onclick="saveCard(${index})">Save</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
}
function saveCard(index) {
const card = config.rooms[activeRoom].cards[index];
card.type = document.getElementById('edit-type').value;
card.entity_id = document.getElementById('edit-entity').value.trim();
card.name = document.getElementById('edit-name').value.trim();
if (!card.name && entities[card.entity_id]) {
card.name = entities[card.entity_id].friendly_name;
}
document.querySelector('div[style*="fixed"]').remove();
renderRoomEditor();
saveState();
}
// ── Vacuum ───────────────────────────────────────────────────────
function renderVacuumRooms() {
const container = document.getElementById('vacuum-rooms');
const rooms = config.vacuum?.rooms || {};
const entries = Object.entries(rooms).sort((a, b) => Number(a[0]) - Number(b[0]));
if (entries.length === 0) {
container.innerHTML = '<div class="empty" style="padding:12px">No segments configured.</div>';
return;
}
container.innerHTML = entries.map(([id, name]) => `
<div class="vacuum-room-item" style="margin-bottom:6px;display:flex;gap:8px;align-items:center">
<input type="number" value="${id}" onchange="renameVacuumSegment('${id}', this.value, null)" style="width:70px" placeholder="ID">
<input type="text" value="${esc(name)}" onchange="renameVacuumSegment('${id}', null, this.value)" placeholder="Room name">
<button class="small danger" onclick="removeVacuumRoom('${id}')">×</button>
</div>
`).join('');
}
function addVacuumRoom() {
if (!config.vacuum) config.vacuum = { entity_id: '', cleaning_mode_entity: '', rooms: {} };
const nextId = Math.max(0, ...Object.keys(config.vacuum.rooms).map(Number)) + 1;
config.vacuum.rooms[String(nextId)] = 'Room ' + nextId;
renderVacuumRooms();
saveState();
}
function removeVacuumRoom(id) {
delete config.vacuum.rooms[id];
renderVacuumRooms();
saveState();
}
function renameVacuumSegment(oldId, newId, newName) {
if (newId !== null && newId !== oldId) {
const name = config.vacuum.rooms[oldId];
delete config.vacuum.rooms[oldId];
config.vacuum.rooms[newId] = name;
}
if (newName !== null) {
config.vacuum.rooms[newId || oldId] = newName;
}
renderVacuumRooms();
saveState();
}
// Sync vacuum inputs
document.addEventListener('change', (e) => {
if (e.target.id === 'vacuum-entity') {
if (!config.vacuum) config.vacuum = { entity_id: '', cleaning_mode_entity: '', rooms: {} };
config.vacuum.entity_id = e.target.value;
saveState();
}
if (e.target.id === 'vacuum-mode-entity') {
if (!config.vacuum) config.vacuum = { entity_id: '', cleaning_mode_entity: '', rooms: {} };
config.vacuum.cleaning_mode_entity = e.target.value;
saveState();
}
if (e.target.id === 'weather-entity') {
config.weather = { entity_id: e.target.value };
saveState();
}
});
// ── Import ───────────────────────────────────────────────────────
function loadFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
importConfig(JSON.parse(e.target.result));
toast('Config loaded');
} catch (err) {
toast('Invalid JSON: ' + err.message);
}
};
reader.readAsText(file);
event.target.value = '';
}
function importFromTextarea() {
const text = document.getElementById('import-json').value.trim();
if (!text) return toast('Paste JSON first');
try {
importConfig(JSON.parse(text));
toast('Config imported');
} catch (err) {
toast('Invalid JSON: ' + err.message);
}
}
function importConfig(data) {
config = {
version: data.version || 1,
ha: { url: data.ha?.url || '', token: '' },
weather: data.weather || { entity_id: '' },
vacuum: data.vacuum || { entity_id: '', cleaning_mode_entity: '', rooms: {} },
rooms: data.rooms || []
};
if (config.ha.url) document.getElementById('ha-url').value = config.ha.url;
document.getElementById('weather-entity').value = config.weather?.entity_id || '';
document.getElementById('vacuum-entity').value = config.vacuum?.entity_id || '';
document.getElementById('vacuum-mode-entity').value = config.vacuum?.cleaning_mode_entity || '';
activeRoom = 0;
renderRoomTabs();
renderVacuumRooms();
saveState();
}
// ── Export ────────────────────────────────────────────────────────
function buildExport() {
const out = {
version: 1,
ha: {
url: document.getElementById('ha-url').value.trim(),
token: ''
}
};
const weatherId = document.getElementById('weather-entity').value.trim();
if (weatherId) out.weather = { entity_id: weatherId };
const vacId = document.getElementById('vacuum-entity').value.trim();
if (vacId) {
out.vacuum = {
entity_id: vacId,
cleaning_mode_entity: document.getElementById('vacuum-mode-entity').value.trim(),
rooms: config.vacuum?.rooms || {}
};
}
out.rooms = config.rooms.map(r => {
const room = { id: r.id, name: r.name };
if (r.background) room.background = r.background;
room.sensors = r.sensors || [];
room.cards = r.cards.filter(c => c.entity_id).map(c => {
const card = { type: c.type, entity_id: c.entity_id };
if (c.name) card.name = c.name;
return card;
});
return room;
});
return out;
}
function renderExport() {
document.getElementById('json-preview').value = JSON.stringify(buildExport(), null, 2);
}
function copyJson() {
renderExport();
const text = document.getElementById('json-preview').value;
navigator.clipboard.writeText(text).then(() => toast('Copied!')).catch(() => {
document.getElementById('json-preview').select();
toast('Select all + copy manually');
});
}
function downloadJson() {
renderExport();
const blob = new Blob([document.getElementById('json-preview').value], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'config.json';
a.click();
URL.revokeObjectURL(url);
}
// ── Helpers ──────────────────────────────────────────────────────
function saveState() {
const save = { ...config };
save.ha = { url: save.ha.url, token: '' };
localStorage.setItem('luma-editor-config', JSON.stringify(save));
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2500);
}
</script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
const fs = require('fs');
const path = require('path');
const APPS = [
'com.palm.app.settings',
'com.webos.app.notificationcenter',
'com.webos.app.gameoptimizer',
];
const outDir = path.join(__dirname, 'dist');
fs.mkdirSync(outDir, { recursive: true });
for (const app of APPS) {
const srcPath = path.join(__dirname, app + '.js');
if (!fs.existsSync(srcPath)) {
console.log('SKIP:', app, '(source not found)');
continue;
}
const code = fs.readFileSync(srcPath, 'utf8');
let minified = code
.replace(/\/\/[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\n\s*\n/g, '\n')
.replace(/^\s+/gm, '')
.replace(/\n/g, '')
.replace(/ +/g, ' ');
console.log(app + ':');
console.log(' Original:', code.length, 'bytes');
console.log(' Minified:', minified.length, 'bytes');
const minPath = path.join(outDir, app + '.min.js');
fs.writeFileSync(minPath, minified);
const b64 = Buffer.from(minified).toString('base64');
const b64Path = path.join(outDir, app + '.b64');
fs.writeFileSync(b64Path, b64);
console.log(' Base64: ', b64.length, 'bytes');
}
-29
View File
@@ -1,29 +0,0 @@
const fs = require('fs');
const path = require('path');
const src = path.join(__dirname, 'com.palm.app.settings.js');
const code = fs.readFileSync(src, 'utf8');
let minified = code
.replace(/\/\/[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\n\s*\n/g, '\n')
.replace(/^\s+/gm, '')
.replace(/\n/g, '')
.replace(/ +/g, ' ');
console.log('Original size:', code.length, 'bytes');
console.log('Minified size:', minified.length, 'bytes');
const outDir = path.join(__dirname, 'dist');
fs.mkdirSync(outDir, { recursive: true });
const outPath = path.join(outDir, 'com.palm.app.settings.min.js');
fs.writeFileSync(outPath, minified);
console.log('Written to:', outPath);
const b64 = Buffer.from(minified).toString('base64');
const b64Path = path.join(outDir, 'com.palm.app.settings.b64');
fs.writeFileSync(b64Path, b64);
console.log('Base64 size:', b64.length, 'bytes');
console.log('Base64 written to:', b64Path);
@@ -0,0 +1,282 @@
// LG webOS Game Optimizer — Luma-style Dark Theme V1.0
// Same visual language as Settings theme: transparent compositor,
// nuclear background strip, dark pills, white focus.
// CSS class hashes from webOS 6.5.3 (kisscurl-koli).
(function () {
'use strict';
var STYLE_ID = 'luma-go-v10';
// === COMPOSITOR TRANSPARENCY ===
try {
if (window.PalmSystem && PalmSystem.setWindowProperties) {
PalmSystem.setWindowProperties(JSON.stringify({ transparent: true }));
}
} catch (e) {}
try {
if (window.webOSSystem && webOSSystem.setWindowProperty) {
webOSSystem.setWindowProperty('transparent', 'true');
}
} catch (e) {}
var CSS = [
// -------------------------------------------------------
// 1. NUCLEAR — strip all backgrounds
// -------------------------------------------------------
'*, *::before, *::after {',
' background-color: transparent !important;',
' background-image: none !important;',
' box-shadow: none !important;',
' transition: none !important;',
' animation: none !important;',
'}',
'html, body {',
' background: transparent !important;',
' background-color: transparent !important;',
' background-image: none !important;',
'}',
'body { color: rgba(255,255,255,0.85) !important; }',
// -------------------------------------------------------
// 2. DARK PILLS on items
// -------------------------------------------------------
// Standard Enact Item bg
'.Item_bg__166C- {',
' opacity: 1 !important;',
' background-color: rgba(0,0,0,0.65) !important;',
' background-image: none !important;',
' border-radius: 9999px !important;',
' filter: none !important;',
'}',
// Game-specific Item bg
'.GameItem_bg__1Px-3 {',
' opacity: 1 !important;',
' background-color: rgba(0,0,0,0.65) !important;',
' background-image: none !important;',
' border-radius: 9999px !important;',
' filter: none !important;',
'}',
// Other bg elements: pill radius
'[class*="_bg__"]:not(.Item_bg__166C-):not(.GameItem_bg__1Px-3) {',
' border-radius: 9999px !important;',
'}',
// -------------------------------------------------------
// 3. FOCUS — white pill + dark text
// -------------------------------------------------------
// Standard Item focus
'.Item_item__1Q0oR.spottable:focus,',
'.Item_item__2eTQF.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.Item_item__1Q0oR.spottable:focus .Item_bg__166C-,',
'.Item_item__2eTQF.spottable:focus .Item_bg__166C- {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.Item_item__1Q0oR.spottable:focus .Item_label__yk-Xa,',
'.Item_item__2eTQF.spottable:focus .Item_label__yk-Xa,',
'.Item_item__1Q0oR.spottable:focus .Item_itemContent__372MG,',
'.Item_item__2eTQF.spottable:focus .Item_itemContent__372MG,',
'.Item_item__1Q0oR.spottable:focus .Item_slotAfter__2fc4G,',
'.Item_item__2eTQF.spottable:focus .Item_slotAfter__2fc4G,',
'.Item_item__1Q0oR.spottable:focus .Item_slotBefore__37DXJ,',
'.Item_item__2eTQF.spottable:focus .Item_slotBefore__37DXJ,',
'.Item_item__1Q0oR.spottable:focus .Icon_icon__1UDrL,',
'.Item_item__1Q0oR.spottable:focus .Icon_icon__2ch_u,',
'.Item_item__2eTQF.spottable:focus .Icon_icon__1UDrL,',
'.Item_item__2eTQF.spottable:focus .Icon_icon__2ch_u {',
' color: #1a1a1a !important;',
'}',
// GameItem focus
'.GameItem_item__1e3fq.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.GameItem_item__1e3fq.spottable:focus .GameItem_bg__1Px-3 {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.GameItem_item__1e3fq.spottable:focus .GameItem_label__2HOiY,',
'.GameItem_item__1e3fq.spottable:focus .Icon_icon__1UDrL,',
'.GameItem_item__1e3fq.spottable:focus .Icon_icon__2ch_u {',
' color: #1a1a1a !important;',
'}',
// Disabled items
'[disabled] .Item_itemContent__372MG,',
'[aria-disabled="true"] .Item_itemContent__372MG,',
'[disabled] .Item_label__yk-Xa,',
'[aria-disabled="true"] .Item_label__yk-Xa,',
'[disabled] .Item_slotAfter__2fc4G,',
'[aria-disabled="true"] .Item_slotAfter__2fc4G,',
'[disabled] .Item_slotBefore__37DXJ,',
'[aria-disabled="true"] .Item_slotBefore__37DXJ,',
'[disabled] .Icon_icon__1UDrL,',
'[disabled] .Icon_icon__2ch_u,',
'[aria-disabled="true"] .Icon_icon__1UDrL,',
'[aria-disabled="true"] .Icon_icon__2ch_u {',
' opacity: 1 !important;',
' color: rgba(255,255,255,0.5) !important;',
'}',
// Button focus (standard Enact)
'.Button_button__2TM80.spottable:focus,',
'.Button_button__2eYtX.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.Button_button__2TM80.spottable:focus .Button_bg__2zdeb,',
'.Button_button__2TM80.spottable:focus .Button_bg__2zkI-,',
'.Button_button__2eYtX.spottable:focus .Button_bg__2zdeb,',
'.Button_button__2eYtX.spottable:focus .Button_bg__2zkI- {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.Button_button__2TM80.spottable:focus .Button_client__1-mQb,',
'.Button_button__2TM80.spottable:focus .Button_client__2pFZm,',
'.Button_button__2eYtX.spottable:focus .Button_client__1-mQb,',
'.Button_button__2eYtX.spottable:focus .Button_client__2pFZm,',
'.Button_button__2TM80.spottable:focus .Icon_icon__1UDrL,',
'.Button_button__2TM80.spottable:focus .Icon_icon__2ch_u,',
'.Button_button__2eYtX.spottable:focus .Icon_icon__1UDrL,',
'.Button_button__2eYtX.spottable:focus .Icon_icon__2ch_u {',
' color: #1a1a1a !important;',
'}',
// GameButton focus
'.GameButton_button__2_0__.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.GameButton_button__2_0__.spottable:focus .GameButton_bg__10LOj {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
// GameIconButton focus
'.GameIconButton_button__2mofS.spottable:focus .GameIconButton_bg__121PO {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
// GameSwitch focus
'.GameSwitch_switch__OWz1z.spottable:focus .GameSwitch_bg__1mRa0 {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
// GameRadioItem focus
'.GameRadioItem_radioItem__2GUcI.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.spottable:focus { outline: none !important; }',
// -------------------------------------------------------
// 4. TEXT & MISC
// -------------------------------------------------------
'.Item_item__1Q0oR, .Item_item__2eTQF {',
' color: rgba(255,255,255,0.85) !important;',
'}',
'.GameItem_item__1e3fq {',
' color: rgba(255,255,255,0.85) !important;',
'}',
'.Button_button__2TM80, .Button_button__2eYtX {',
' color: rgba(255,255,255,0.85) !important;',
' border-radius: 9999px !important;',
'}',
'.Header_header__2Xmak {',
' color: #fff !important;',
' border-bottom: 1px solid rgba(255,255,255,0.06) !important;',
'}',
'h1, h2, h3 { color: #fff !important; }',
'.Heading_heading__Y4gPw { color: #fff !important; }',
'.GameHeading_gameHeading__2dHP1 { color: #fff !important; }',
// GamePanel backdrop — semi-transparent dark instead of opaque
'.GamePanel_panelBackdrop__1nX3U {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
'.GamePanel_panel__2770_ { color: rgba(255,255,255,0.85) !important; }',
// AI Indicator
'.AIIndicatorUnit_title__1EVRE { color: rgba(255,255,255,0.7) !important; }',
'.AIIndicatorUnit_value__pC9jL { color: #fff !important; }',
// Alert popup
'.Alert_alert__gVwa9,',
'.Alert_popup__1b3Xc {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
'.Alert_title__1HfX1 { color: #fff !important; }',
'.Alert_body__3g69H { color: rgba(255,255,255,0.85) !important; }',
// FixedPopupPanels
'.FixedPopupPanels_fixedPopupPanels__wpe6K,',
'.FixedPopupPanels_body__2ARIw,',
'.FixedPopupPanels_panel__38yei {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
// Popup container
'.Popup_popup__3sv6e {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
'.Popup_body__2J-hw { color: rgba(255,255,255,0.85) !important; }',
// Picker styling
'.Picker_picker__fngq1 { color: rgba(255,255,255,0.85) !important; }',
'* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; }',
''
].join('\n');
function injectStyle() {
if (document.getElementById(STYLE_ID)) return;
var style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
if (document.body) {
injectStyle();
} else {
document.addEventListener('DOMContentLoaded', injectStyle);
}
new MutationObserver(function () {
if (!document.getElementById(STYLE_ID)) injectStyle();
}).observe(document.documentElement, { childList: true, subtree: true });
})();
@@ -0,0 +1,237 @@
// LG webOS Notification Center — Luma-style Dark Theme V1.0
// Same visual language as Settings theme: transparent compositor,
// nuclear background strip, dark pills, white focus.
// CSS class hashes from webOS 6.5.3 (kisscurl-koli).
(function () {
'use strict';
var STYLE_ID = 'luma-nc-v10';
// === COMPOSITOR TRANSPARENCY ===
try {
if (window.PalmSystem && PalmSystem.setWindowProperties) {
PalmSystem.setWindowProperties(JSON.stringify({ transparent: true }));
}
} catch (e) {}
try {
if (window.webOSSystem && webOSSystem.setWindowProperty) {
webOSSystem.setWindowProperty('transparent', 'true');
}
} catch (e) {}
var CSS = [
// -------------------------------------------------------
// 1. NUCLEAR — strip all backgrounds
// -------------------------------------------------------
'*, *::before, *::after {',
' background-color: transparent !important;',
' background-image: none !important;',
' box-shadow: none !important;',
' transition: none !important;',
' animation: none !important;',
'}',
'html, body {',
' background: transparent !important;',
' background-color: transparent !important;',
' background-image: none !important;',
'}',
'body { color: rgba(255,255,255,0.85) !important; }',
// -------------------------------------------------------
// 2. DARK PILLS on items
// -------------------------------------------------------
'.Item_bg__2MM1D {',
' opacity: 1 !important;',
' background-color: rgba(0,0,0,0.65) !important;',
' background-image: none !important;',
' border-radius: 9999px !important;',
' filter: none !important;',
'}',
// Other bg elements stay hidden until focus
'[class*="_bg__"]:not(.Item_bg__2MM1D) {',
' border-radius: 9999px !important;',
'}',
// FormCheckboxItem bg
'.FormCheckboxItem_bg__3GHsc {',
' opacity: 1 !important;',
' background-color: rgba(0,0,0,0.65) !important;',
' background-image: none !important;',
' border-radius: 9999px !important;',
' filter: none !important;',
'}',
// -------------------------------------------------------
// 3. FOCUS — white pill + dark text
// -------------------------------------------------------
// Item focus
'.Item_item__3OZOO.spottable:focus,',
'.Item_item__3gT3d.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.Item_item__3OZOO.spottable:focus .Item_bg__2MM1D,',
'.Item_item__3gT3d.spottable:focus .Item_bg__2MM1D {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.Item_item__3OZOO.spottable:focus .Item_label__2Wgl2,',
'.Item_item__3gT3d.spottable:focus .Item_label__2Wgl2,',
'.Item_item__3OZOO.spottable:focus .Item_itemContent__19m1V,',
'.Item_item__3gT3d.spottable:focus .Item_itemContent__19m1V,',
'.Item_item__3OZOO.spottable:focus .Item_slotAfter__hf946,',
'.Item_item__3gT3d.spottable:focus .Item_slotAfter__hf946,',
'.Item_item__3OZOO.spottable:focus .Item_slotBefore__39lzy,',
'.Item_item__3gT3d.spottable:focus .Item_slotBefore__39lzy,',
'.Item_item__3OZOO.spottable:focus .Icon_icon__3xbFH,',
'.Item_item__3OZOO.spottable:focus .Icon_icon__uDPTY,',
'.Item_item__3gT3d.spottable:focus .Icon_icon__3xbFH,',
'.Item_item__3gT3d.spottable:focus .Icon_icon__uDPTY {',
' color: #1a1a1a !important;',
'}',
// FormCheckboxItem focus
'.FormCheckboxItem_item__s8bcU.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.FormCheckboxItem_item__s8bcU.spottable:focus .FormCheckboxItem_bg__3GHsc {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
// Disabled items: force content children visible
'[disabled] .Item_itemContent__19m1V,',
'[aria-disabled="true"] .Item_itemContent__19m1V,',
'[disabled] .Item_label__2Wgl2,',
'[aria-disabled="true"] .Item_label__2Wgl2,',
'[disabled] .Item_slotAfter__hf946,',
'[aria-disabled="true"] .Item_slotAfter__hf946,',
'[disabled] .Item_slotBefore__39lzy,',
'[aria-disabled="true"] .Item_slotBefore__39lzy,',
'[disabled] .Icon_icon__3xbFH,',
'[disabled] .Icon_icon__uDPTY,',
'[aria-disabled="true"] .Icon_icon__3xbFH,',
'[aria-disabled="true"] .Icon_icon__uDPTY {',
' opacity: 1 !important;',
' color: rgba(255,255,255,0.5) !important;',
'}',
// Button focus
'.Button_button__2U0Zv.spottable:focus,',
'.Button_button__msIzD.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.Button_button__2U0Zv.spottable:focus .Button_bg__1Dcsq,',
'.Button_button__2U0Zv.spottable:focus .Button_bg__AoDJy,',
'.Button_button__msIzD.spottable:focus .Button_bg__1Dcsq,',
'.Button_button__msIzD.spottable:focus .Button_bg__AoDJy {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.Button_button__2U0Zv.spottable:focus .Button_client__2HSGm,',
'.Button_button__2U0Zv.spottable:focus .Button_client__3Hkj3,',
'.Button_button__msIzD.spottable:focus .Button_client__2HSGm,',
'.Button_button__msIzD.spottable:focus .Button_client__3Hkj3,',
'.Button_button__2U0Zv.spottable:focus .Icon_icon__3xbFH,',
'.Button_button__2U0Zv.spottable:focus .Icon_icon__uDPTY,',
'.Button_button__msIzD.spottable:focus .Icon_icon__3xbFH,',
'.Button_button__msIzD.spottable:focus .Icon_icon__uDPTY {',
' color: #1a1a1a !important;',
'}',
// Checkbox focus
'.Checkbox_checkbox__1mTpP.spottable:focus .Checkbox_bg__1-1hH {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.spottable:focus { outline: none !important; }',
// -------------------------------------------------------
// 4. TEXT & MISC
// -------------------------------------------------------
'.Item_item__3OZOO, .Item_item__3gT3d {',
' color: rgba(255,255,255,0.85) !important;',
'}',
'.Button_button__2U0Zv, .Button_button__msIzD {',
' color: rgba(255,255,255,0.85) !important;',
' border-radius: 9999px !important;',
'}',
'.Header_header__1wT5v {',
' color: #fff !important;',
' border-bottom: 1px solid rgba(255,255,255,0.06) !important;',
'}',
'h1, h2, h3 { color: #fff !important; }',
'.Heading_heading__Exc3p { color: #fff !important; }',
// Notification-specific: NotiHeader
'.NotiHeader_children__5zAyB { color: rgba(255,255,255,0.85) !important; }',
'.NotiHeader_headerButton__Y2scK { color: rgba(255,255,255,0.7) !important; }',
'.NotiIcon_icon__3m2-f { color: rgba(255,255,255,0.85) !important; }',
// Alert popup styling
'.Alert_alert__1hTPw,',
'.Alert_popup__2t0iD {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
'.Alert_title__1Qf_f { color: #fff !important; }',
'.Alert_body__3frtV { color: rgba(255,255,255,0.85) !important; }',
// FixedPopupPanels (detail views)
'.FixedPopupPanels_fixedPopupPanels__12XNO,',
'.FixedPopupPanels_body__jZC_5,',
'.FixedPopupPanels_panel__3qAUZ {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
// Popup container
'.Popup_popup__K1Ygj {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
'.Popup_body__1N5b9 { color: rgba(255,255,255,0.85) !important; }',
'* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; }',
''
].join('\n');
function injectStyle() {
if (document.getElementById(STYLE_ID)) return;
var style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
if (document.body) {
injectStyle();
} else {
document.addEventListener('DOMContentLoaded', injectStyle);
}
new MutationObserver(function () {
if (!document.getElementById(STYLE_ID)) injectStyle();
}).observe(document.documentElement, { childList: true, subtree: true });
})();
+105
View File
@@ -178,6 +178,7 @@ fn effects(
platform: &Platform,
preferences: &std::path::Path,
weather: &weather_service::Service,
ha: Option<&ha_client::HaService>,
) -> bool {
for effect in effects {
match effect {
@@ -225,11 +226,110 @@ fn effects(
}
Effect::Quit => return false,
Effect::Weather(command) => weather.send(command),
Effect::Ha(ha_effect) => {
if let Some(service) = ha {
ha_dispatch(service, ha_effect);
}
}
}
}
true
}
fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) {
use state::HaEffect;
static CALL_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1000);
let next_id = || CALL_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
match effect {
HaEffect::Toggle(entity_id) => {
let (domain, service_name) = if entity_id.starts_with("light.") {
("light", "toggle")
} else if entity_id.starts_with("switch.") {
("switch", "toggle")
} else if entity_id.starts_with("cover.") {
("cover", "toggle")
} else {
("homeassistant", "toggle")
};
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: domain.into(),
service: service_name.into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::SetBrightness(entity_id, brightness) => {
if brightness <= 0. {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "light".into(),
service: "turn_off".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
} else {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "light".into(),
service: "turn_on".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ "brightness": brightness as u8 }),
});
}
}
HaEffect::SetClimateTemp(entity_id, temp) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "climate".into(),
service: "set_temperature".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ "temperature": temp }),
});
}
HaEffect::ActivateScene(entity_id) => {
let (domain, service_name) = if entity_id.starts_with("script.") {
("script", "turn_on")
} else {
("scene", "turn_on")
};
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: domain.into(),
service: service_name.into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::VacuumClean { entity_id, segments, mode } => {
if let Some(mode_name) = &mode {
let cleaning_mode_entity = format!(
"select.{}_cleaning_mode",
entity_id.strip_prefix("vacuum.").unwrap_or(&entity_id)
);
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "select".into(),
service: "select_option".into(),
target: serde_json::json!({ "entity_id": cleaning_mode_entity }),
data: serde_json::json!({ "option": mode_name }),
});
}
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "vacuum".into(),
service: "send_command".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({
"command": "app_segment_clean",
"params": { "segments": segments, "repeat": 1 }
}),
});
}
}
}
fn script(
action: &str,
state: &mut State,
@@ -524,6 +624,7 @@ fn run() -> Result<(), String> {
};
let ha_service = if let Ok(bytes) = std::fs::read(&ha_config_path) {
if let Ok(config_value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
state.ha.config = state::HaTabConfig::parse(&config_value);
let url = config_value.get("ha").and_then(|h| h.get("url")).and_then(|v| v.as_str()).unwrap_or("").to_owned();
let token = config_value.get("ha").and_then(|h| h.get("token")).and_then(|v| v.as_str()).unwrap_or("").to_owned();
if !url.is_empty() && !token.is_empty() {
@@ -555,6 +656,7 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
platform.sync_splash(&state.preferences.background);
startup_stage("preferences and workers");
@@ -965,6 +1067,7 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
if press
.as_ref()
@@ -1008,6 +1111,7 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
}
}
@@ -1029,6 +1133,7 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
}
let keyboard = state.text_input_active();
+406 -1
View File
@@ -62,6 +62,14 @@ pub enum Target {
WeatherSearch,
WeatherClear,
WeatherResult(usize),
HaRoom(usize),
HaCard(usize, usize),
HaWeather,
HaVacuumOpen,
HaVacuumRoom(usize),
HaVacuumMode,
HaVacuumStart,
HaVacuumClose,
}
#[derive(Clone, Copy, Debug, Default)]
@@ -139,6 +147,20 @@ pub enum Effect {
Launch(String),
Quit,
Weather(weather::Command),
Ha(HaEffect),
}
#[derive(Debug, PartialEq)]
pub enum HaEffect {
Toggle(String),
SetBrightness(String, f32),
SetClimateTemp(String, f32),
ActivateScene(String),
VacuumClean {
entity_id: String,
segments: Vec<u32>,
mode: Option<String>,
},
}
pub const KEYS: [&str; 40] = [
@@ -147,11 +169,64 @@ pub const KEYS: [&str; 40] = [
"Delete", "Clear", "Done",
];
#[derive(Clone, Debug)]
pub enum HaCardType {
Toggle,
Range,
Climate,
Action,
Vacuum,
}
#[derive(Clone, Debug)]
pub struct HaCardConfig {
pub card_type: HaCardType,
pub entity_id: String,
pub name: String,
}
#[derive(Clone, Debug)]
pub struct HaRoomConfig {
pub id: String,
pub name: String,
pub cards: Vec<HaCardConfig>,
}
#[derive(Clone, Debug, Default)]
pub struct HaWeatherConfig {
pub entity_id: String,
}
#[derive(Clone, Debug)]
pub struct HaVacuumRoom {
pub segment_id: u32,
pub name: String,
}
#[derive(Clone, Debug, Default)]
pub struct HaVacuumConfig {
pub entity_id: String,
pub cleaning_mode_entity: String,
pub rooms: Vec<HaVacuumRoom>,
}
#[derive(Clone, Debug, Default)]
pub struct HaTabConfig {
pub rooms: Vec<HaRoomConfig>,
pub weather: Option<HaWeatherConfig>,
pub vacuum: Option<HaVacuumConfig>,
}
#[derive(Default)]
pub struct HaState {
pub connected: bool,
pub entities: std::collections::HashMap<String, HaEntity>,
pub history: std::collections::HashMap<String, Vec<(f64, f64)>>,
pub config: HaTabConfig,
pub room: usize,
pub vacuum_selected: Vec<bool>,
pub vacuum_mode: usize,
pub vacuum_open: bool,
}
#[derive(Clone, Debug)]
@@ -160,6 +235,74 @@ pub struct HaEntity {
pub attributes: serde_json::Value,
}
impl HaTabConfig {
pub fn parse(value: &serde_json::Value) -> Self {
let mut config = HaTabConfig::default();
if let Some(w) = value.get("weather") {
if let Some(eid) = w.get("entity_id").and_then(|v| v.as_str()) {
config.weather = Some(HaWeatherConfig {
entity_id: eid.to_owned(),
});
}
}
if let Some(v) = value.get("vacuum") {
let mut vc = HaVacuumConfig::default();
if let Some(eid) = v.get("entity_id").and_then(|v| v.as_str()) {
vc.entity_id = eid.to_owned();
}
if let Some(cme) = v.get("cleaning_mode_entity").and_then(|v| v.as_str()) {
vc.cleaning_mode_entity = cme.to_owned();
}
if let Some(rooms) = v.get("rooms").and_then(|v| v.as_object()) {
for (id, name) in rooms {
if let (Ok(seg), Some(n)) = (id.parse::<u32>(), name.as_str()) {
vc.rooms.push(HaVacuumRoom {
segment_id: seg,
name: n.to_owned(),
});
}
}
vc.rooms.sort_by_key(|r| r.segment_id);
}
if !vc.entity_id.is_empty() {
config.vacuum = Some(vc);
}
}
if let Some(rooms) = value.get("rooms").and_then(|v| v.as_array()) {
for room in rooms {
let id = room.get("id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let name = room.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let mut cards = Vec::new();
if let Some(card_arr) = room.get("cards").and_then(|v| v.as_array()) {
for card in card_arr {
let ct = match card.get("type").and_then(|v| v.as_str()) {
Some("toggle") => HaCardType::Toggle,
Some("range") => HaCardType::Range,
Some("climate") => HaCardType::Climate,
Some("action") => HaCardType::Action,
Some("vacuum") => HaCardType::Vacuum,
_ => continue,
};
let eid = card.get("entity_id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let n = card.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
cards.push(HaCardConfig {
card_type: ct,
entity_id: eid,
name: n,
});
}
}
config.rooms.push(HaRoomConfig { id, name, cards });
}
}
config
}
}
pub struct State {
pub page: Page,
pub focus: Target,
@@ -410,7 +553,13 @@ impl State {
.first()
.map(|app| Target::App(app.id.clone()))
.unwrap_or(Target::Filter(1)),
Page::Ha => Target::Nav(2),
Page::Ha => {
if self.ha.config.rooms.is_empty() {
Target::Nav(2)
} else {
Target::HaRoom(self.ha.room)
}
}
Page::Settings => Target::Sidebar(0),
}
}
@@ -636,6 +785,99 @@ impl State {
return vec![];
}
}
// ── HA page navigation ────────────────────────────────
if self.page == Page::Ha && !self.ha.config.rooms.is_empty() {
if self.ha.vacuum_open {
return self.ha_vacuum_direction(direction, controls);
}
if let Target::HaRoom(room) = self.focus {
match direction {
Direction::Left if room > 0 => {
self.ha.room = room - 1;
self.focus(Target::HaRoom(room - 1), controls);
self.scroll = 0.;
}
Direction::Right if room + 1 < self.ha.config.rooms.len() => {
self.ha.room = room + 1;
self.focus(Target::HaRoom(room + 1), controls);
self.scroll = 0.;
}
Direction::Up => self.focus(Target::Nav(2), controls),
Direction::Down => {
if let Some(weather) = &self.ha.config.weather
&& self.ha.entities.contains_key(&weather.entity_id)
{
self.focus(Target::HaWeather, controls);
} else if !self.ha.config.rooms[self.ha.room].cards.is_empty() {
self.focus(Target::HaCard(self.ha.room, 0), controls);
}
}
_ => {}
}
return vec![];
}
if self.focus == Target::HaWeather {
match direction {
Direction::Up => self.focus(Target::HaRoom(self.ha.room), controls),
Direction::Down => {
if !self.ha.config.rooms[self.ha.room].cards.is_empty() {
self.focus(Target::HaCard(self.ha.room, 0), controls);
}
}
_ => {}
}
return vec![];
}
if let Target::HaCard(room, index) = self.focus {
let room_cfg = &self.ha.config.rooms[room];
let card = &room_cfg.cards[index];
let cols = self.ha_card_columns();
match (&card.card_type, direction) {
(HaCardType::Range, Direction::Left) => {
let brightness = self.ha_entity_brightness(&card.entity_id);
let new = (brightness - 25.5).max(0.);
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
}
(HaCardType::Range, Direction::Right) => {
let brightness = self.ha_entity_brightness(&card.entity_id);
let new = (brightness + 25.5).min(255.);
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
}
(HaCardType::Climate, Direction::Left) => {
let temp = self.ha_entity_target_temp(&card.entity_id);
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp - 0.5))];
}
(HaCardType::Climate, Direction::Right) => {
let temp = self.ha_entity_target_temp(&card.entity_id);
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), temp + 0.5))];
}
(_, Direction::Left) if index % cols > 0 => {
self.focus(Target::HaCard(room, index - 1), controls);
}
(_, Direction::Right) if index % cols + 1 < cols && index + 1 < room_cfg.cards.len() => {
self.focus(Target::HaCard(room, index + 1), controls);
}
(_, Direction::Up) => {
if index >= cols {
self.focus(Target::HaCard(room, index - cols), controls);
} else if self.ha.config.weather.is_some() {
self.focus(Target::HaWeather, controls);
} else {
self.focus(Target::HaRoom(self.ha.room), controls);
}
}
(_, Direction::Down) => {
let next = index + cols;
if next < room_cfg.cards.len() {
self.focus(Target::HaCard(room, next), controls);
}
}
_ => {}
}
return vec![];
}
}
let setting_rows = match self.subpage {
Some(Subpage::Greeting) => Some([Target::GreetingName, Target::GreetingNameClear]),
Some(Subpage::AppNames) => Some([Target::AppNamesHome, Target::AppNamesApps]),
@@ -982,6 +1224,80 @@ impl State {
vec![]
}
fn ha_card_columns(&self) -> usize {
3
}
fn ha_entity_brightness(&self, entity_id: &str) -> f32 {
self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("brightness"))
.and_then(|v| v.as_f64())
.unwrap_or(0.) as f32
}
fn ha_entity_target_temp(&self, entity_id: &str) -> f32 {
self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("temperature"))
.and_then(|v| v.as_f64())
.unwrap_or(20.) as f32
}
fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> {
let vac = match &self.ha.config.vacuum {
Some(v) => v,
None => return vec![],
};
let room_count = vac.rooms.len();
let cols = 3usize;
match &self.focus {
Target::HaVacuumRoom(index) => {
let index = *index;
match direction {
Direction::Left if index % cols > 0 => {
self.focus(Target::HaVacuumRoom(index - 1), controls);
}
Direction::Right if index % cols + 1 < cols && index + 1 < room_count => {
self.focus(Target::HaVacuumRoom(index + 1), controls);
}
Direction::Up if index >= cols => {
self.focus(Target::HaVacuumRoom(index - cols), controls);
}
Direction::Up => {
self.focus(Target::HaVacuumClose, controls);
}
Direction::Down => {
let next = index + cols;
if next < room_count {
self.focus(Target::HaVacuumRoom(next), controls);
} else {
self.focus(Target::HaVacuumMode, controls);
}
}
_ => {}
}
}
Target::HaVacuumMode => match direction {
Direction::Up => {
let last_row_start = (room_count.saturating_sub(1)) / cols * cols;
let idx = (last_row_start).min(room_count.saturating_sub(1));
self.focus(Target::HaVacuumRoom(idx), controls);
}
Direction::Down => self.focus(Target::HaVacuumStart, controls),
_ => {}
},
Target::HaVacuumStart => match direction {
Direction::Up => self.focus(Target::HaVacuumMode, controls),
_ => {}
},
Target::HaVacuumClose => match direction {
Direction::Down => self.focus(Target::HaVacuumRoom(0), controls),
_ => {}
},
_ => {}
}
vec![]
}
pub fn options(&mut self) {
if self.overlay.is_some() || self.moving.is_some() {
return;
@@ -1330,6 +1646,82 @@ impl State {
return vec![Effect::Save];
}
}
Target::HaRoom(index) => {
self.ha.room = index;
self.scroll = 0.;
if !self.ha.config.rooms[index].cards.is_empty() {
self.focus = Target::HaCard(index, 0);
}
}
Target::HaCard(room, index) => {
let card = &self.ha.config.rooms[room].cards[index];
match card.card_type {
HaCardType::Toggle => {
return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))];
}
HaCardType::Action => {
return vec![Effect::Ha(HaEffect::ActivateScene(card.entity_id.clone()))];
}
HaCardType::Vacuum => {
if let Some(vac) = &self.ha.config.vacuum {
self.ha.vacuum_open = true;
self.ha.vacuum_selected = vec![false; vac.rooms.len()];
self.ha.vacuum_mode = 0;
self.focus = Target::HaVacuumRoom(0);
}
}
_ => {}
}
}
Target::HaVacuumRoom(index) => {
if index < self.ha.vacuum_selected.len() {
self.ha.vacuum_selected[index] = !self.ha.vacuum_selected[index];
}
}
Target::HaVacuumMode => {
if let Some(vac) = &self.ha.config.vacuum {
if let Some(entity) = self.ha.entities.get(&vac.cleaning_mode_entity) {
if let Some(options) = entity.attributes.get("options").and_then(|o| o.as_array()) {
self.ha.vacuum_mode = (self.ha.vacuum_mode + 1) % options.len().max(1);
}
}
}
}
Target::HaVacuumStart => {
if let Some(vac) = &self.ha.config.vacuum {
let segments: Vec<u32> = vac.rooms.iter()
.enumerate()
.filter(|(i, _)| self.ha.vacuum_selected.get(*i).copied().unwrap_or(false))
.map(|(_, r)| r.segment_id)
.collect();
if !segments.is_empty() {
let mode = self.ha.entities.get(&vac.cleaning_mode_entity)
.and_then(|e| e.attributes.get("options"))
.and_then(|o| o.as_array())
.and_then(|opts| opts.get(self.ha.vacuum_mode))
.and_then(|v| v.as_str())
.map(|s| s.to_owned());
self.ha.vacuum_open = false;
self.focus = Target::HaCard(self.ha.room,
self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0));
return vec![Effect::Ha(HaEffect::VacuumClean {
entity_id: vac.entity_id.clone(),
segments,
mode,
})];
}
}
}
Target::HaVacuumClose => {
self.ha.vacuum_open = false;
self.focus = Target::HaCard(self.ha.room,
self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0));
}
Target::HaVacuumOpen | Target::HaWeather => {}
Target::Sidebar(index) => {
self.section = index;
self.scroll = 0.;
@@ -1560,6 +1952,19 @@ impl State {
self.scroll = 0.;
return vec![];
}
if self.page == Page::Ha && self.ha.vacuum_open {
self.ha.vacuum_open = false;
self.focus = Target::HaCard(self.ha.room,
self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0));
return vec![];
}
if self.page == Page::Ha && matches!(self.focus, Target::HaCard(..)) {
self.focus = Target::HaRoom(self.ha.room);
self.scroll = 0.;
return vec![];
}
if self.page == Page::Apps && matches!(self.focus, Target::Search | Target::ClearSearch) {
self.query.clear();
self.focus = Target::Nav(1);
+675 -9
View File
@@ -1608,20 +1608,686 @@ fn location_settings(out: &mut Scene, state: &State, assets: &Assets) {
}
fn ha(out: &mut Scene, state: &State, assets: &Assets) {
use crate::state::HaCardType;
let language = state.preferences.language;
let rect = Rect::new(84., 164.8, 1752., 780.);
empty(
out,
state,
let config = &state.ha.config;
let s = state.scroll;
if config.rooms.is_empty() {
let rect = Rect::new(84., 164.8, 1752., 780.);
empty(
out,
state,
assets,
EmptyState {
rect,
icon: "settings",
title: language.t("ha"),
hint: "Home Assistant",
action: None,
},
);
return;
}
// ── Room tabs (horizontal pill strip) ────────────────────────
let mut tab_x = 84.;
for (index, room) in config.rooms.iter().enumerate() {
let width = (assets.width(&room.name, text(16., 500, 0.)) + 36.).max(88.);
let rect = Rect::new(tab_x, 164.8, width, 42.8);
let focused = state.focus == Target::HaRoom(index);
let selected = state.ha.room == index;
out.pill(rect, focused, selected);
let color = if focused {
c(0x2c2c2c, 1.)
} else if selected {
WHITE
} else {
c(0xeeeeee, 0.55)
};
out.text(
&room.name,
Rect::new(rect.x + 18., rect.y + 12., rect.w - 36., 20.),
text(16., 500, 0.),
color,
true,
);
out.control(Target::HaRoom(index), rect, false);
tab_x += width + 8.;
}
// ── Connection indicator ─────────────────────────────────────
if !state.ha.connected {
let dot = Rect::new(tab_x + 12., 178., 12., 12.);
out.shape(dot, 6., c(0xff6b6b, 0.8));
}
let room = &config.rooms[state.ha.room];
let mut y = 230. - s;
// ── Weather hero card (if configured) ────────────────────────
if let Some(weather_cfg) = &config.weather {
if let Some(entity) = state.ha.entities.get(&weather_cfg.entity_id) {
let rect = Rect::new(84., y, 1752., 100.);
ha_weather_card(out, state, assets, rect, entity);
out.control(Target::HaWeather, rect, true);
y += 116.;
}
}
// ── Card grid ────────────────────────────────────────────────
let cols = 3usize;
let card_w = 564.;
let card_gap = 16.;
let card_h = 88.;
for (index, card) in room.cards.iter().enumerate() {
let col = index % cols;
let row = index / cols;
let cx = 84. + col as f32 * (card_w + card_gap);
let cy = y + row as f32 * (card_h + card_gap);
let rect = Rect::new(cx, cy, card_w, card_h);
let focused = state.focus == Target::HaCard(state.ha.room, index);
let entity = state.ha.entities.get(&card.entity_id);
match card.card_type {
HaCardType::Toggle => {
ha_toggle_card(out, state, assets, rect, card, entity, focused);
}
HaCardType::Range => {
ha_range_card(out, state, assets, rect, card, entity, focused);
}
HaCardType::Climate => {
ha_climate_card(out, state, assets, rect, card, entity, focused);
}
HaCardType::Action => {
ha_action_card(out, state, assets, rect, card, focused);
}
HaCardType::Vacuum => {
ha_vacuum_card(out, state, assets, rect, card, entity, focused);
}
}
out.control(Target::HaCard(state.ha.room, index), rect, true);
}
// ── Vacuum modal (overlays everything) ───────────────────────
if state.ha.vacuum_open {
if let Some(vac_cfg) = &config.vacuum {
ha_vacuum_modal(out, state, assets, vac_cfg);
}
}
}
// ── Glass card base ──────────────────────────────────────────────
fn ha_glass(out: &mut Scene, rect: Rect, focused: bool) {
out.shadow(
rect,
22.,
if focused { 28. } else { 6. },
if focused { 8. } else { 2. },
if focused { 0.28 } else { 0.06 },
);
out.shape(rect, 22., c(0xffffff, if focused { 0.16 } else { 0.12 }));
out.border(rect, 22., 1., c(0xffffff, if focused { 0.28 } else { 0.15 }));
if focused {
out.border(rect.inset(-4.), 26., 2., c(0xffffff, 0.12));
}
}
fn ha_entity_icon(card_type: &crate::state::HaCardType, entity_id: &str) -> &'static str {
use crate::state::HaCardType;
match card_type {
HaCardType::Toggle => {
if entity_id.starts_with("light.") {
"light"
} else if entity_id.starts_with("cover.") {
"curtain"
} else {
"switch"
}
}
HaCardType::Range => "light",
HaCardType::Climate => "thermometer",
HaCardType::Action => "scene",
HaCardType::Vacuum => "vacuum",
}
}
fn ha_state_color(entity: Option<&crate::state::HaEntity>) -> Color {
match entity.map(|e| e.state.as_str()) {
Some("on") => c(0x7EB8DA, 1.),
Some("heat" | "heat_cool" | "auto") => c(0xf0a060, 1.),
Some("cool") => c(0x60b0f0, 1.),
_ => c(0xbbbbbb, 0.5),
}
}
// ── Toggle card ──────────────────────────────────────────────────
fn ha_toggle_card(
out: &mut Scene,
_state: &State,
assets: &Assets,
rect: Rect,
card: &crate::state::HaCardConfig,
entity: Option<&crate::state::HaEntity>,
focused: bool,
) {
ha_glass(out, rect, focused);
let icon_name = ha_entity_icon(&card.card_type, &card.entity_id);
let accent = ha_state_color(entity);
let is_on = entity.is_some_and(|e| e.state == "on");
out.icon(
assets,
EmptyState {
icon_name,
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
if is_on { accent } else { c(0xbbbbbb, 0.5) },
);
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 22., rect.w - 180., 24.),
text(20., 500, -0.02),
WHITE,
false,
);
let status = entity.map_or("unavailable", |e| e.state.as_str());
out.text(
status,
Rect::new(rect.x + 80., rect.y + 52., rect.w - 180., 16.),
text(13.12, 400, 0.),
c(0xe8e8e8, 0.48),
false,
);
// On/off indicator dot
let dot_x = rect.x + rect.w - 36.;
let dot_y = rect.y + rect.h / 2. - 8.;
out.shape(
Rect::new(dot_x, dot_y, 16., 16.),
8.,
if is_on { accent } else { c(0x555555, 0.6) },
);
}
// ── Range card (dimmable lights, cover position) ─────────────────
fn ha_range_card(
out: &mut Scene,
_state: &State,
assets: &Assets,
rect: Rect,
card: &crate::state::HaCardConfig,
entity: Option<&crate::state::HaEntity>,
focused: bool,
) {
ha_glass(out, rect, focused);
let is_on = entity.is_some_and(|e| e.state == "on");
let brightness = entity
.and_then(|e| e.attributes.get("brightness"))
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let pct = (brightness / 255. * 100.).round() as u8;
let accent = if is_on { c(0x7EB8DA, 1.) } else { c(0xbbbbbb, 0.5) };
out.icon(
assets,
"light",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
accent,
);
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 16., rect.w - 180., 24.),
text(20., 500, -0.02),
WHITE,
false,
);
// Brightness bar
let bar_x = rect.x + 80.;
let bar_y = rect.y + 52.;
let bar_w = rect.w - 160.;
let bar_h = 8.;
out.shape(Rect::new(bar_x, bar_y, bar_w, bar_h), 4., c(0xffffff, 0.08));
if is_on && pct > 0 {
let fill_w = bar_w * (pct as f32 / 100.).min(1.);
out.shape(Rect::new(bar_x, bar_y, fill_w, bar_h), 4., accent);
}
// Percentage label
let label = if is_on { format!("{}%", pct) } else { "Off".into() };
out.text(
&label,
Rect::new(rect.x + rect.w - 72., rect.y + 34., 52., 20.),
text(15.2, 500, 0.).right(),
if is_on { WHITE } else { c(0xbbbbbb, 0.5) },
false,
);
if focused {
// D-pad hint arrows
out.icon(assets, "left", Rect::new(rect.x + 80., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4));
out.icon(assets, "right", Rect::new(rect.x + 96., bar_y + 14., 12., 12.), c(0xeeeeee, 0.4));
}
}
// ── Climate card ─────────────────────────────────────────────────
fn ha_climate_card(
out: &mut Scene,
state: &State,
assets: &Assets,
rect: Rect,
card: &crate::state::HaCardConfig,
entity: Option<&crate::state::HaEntity>,
focused: bool,
) {
ha_glass(out, rect, focused);
let accent = ha_state_color(entity);
let current_temp = entity
.and_then(|e| e.attributes.get("current_temperature"))
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let target_temp = entity
.and_then(|e| e.attributes.get("temperature"))
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let hvac_mode = entity.map_or("off", |e| e.state.as_str());
out.icon(
assets,
"thermometer",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
accent,
);
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 16., 280., 24.),
text(20., 500, -0.02),
WHITE,
false,
);
out.text(
hvac_mode,
Rect::new(rect.x + 80., rect.y + 46., 120., 16.),
text(13.12, 400, 0.),
c(0xe8e8e8, 0.48),
false,
);
// Current temperature
let current_str = format!("{:.1}°", current_temp);
out.text(
&current_str,
Rect::new(rect.x + rect.w - 200., rect.y + 16., 80., 28.),
text(24., 600, -0.03).right(),
WHITE,
false,
);
// Target temperature
let target_str = format!("→ {:.1}°", target_temp);
out.text(
&target_str,
Rect::new(rect.x + rect.w - 108., rect.y + 18., 88., 24.),
text(18., 400, 0.),
accent,
false,
);
if focused {
out.icon(assets, "left", Rect::new(rect.x + rect.w - 108., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4));
out.icon(assets, "right", Rect::new(rect.x + rect.w - 92., rect.y + 52., 12., 12.), c(0xeeeeee, 0.4));
}
// History sparkline if available
if let Some(points) = state.ha.history.get(&card.entity_id) {
if points.len() >= 2 {
ha_sparkline(out, Rect::new(rect.x + 80., rect.y + 64., 300., 16.), points, accent);
}
}
}
fn ha_sparkline(out: &mut Scene, rect: Rect, points: &[(f64, f64)], color: Color) {
if points.is_empty() { return; }
let min_val = points.iter().map(|p| p.1).fold(f64::INFINITY, f64::min);
let max_val = points.iter().map(|p| p.1).fold(f64::NEG_INFINITY, f64::max);
let range = (max_val - min_val).max(1.);
let step = rect.w / (points.len() as f32 - 1.).max(1.);
for i in 0..points.len() {
let x = rect.x + i as f32 * step;
let norm = ((points[i].1 - min_val) / range) as f32;
let bar_h = (norm * rect.h).max(2.);
let y = rect.y + rect.h - bar_h;
out.shape(
Rect::new(x, y, step.max(2.), bar_h),
0.,
[color[0], color[1], color[2], 0.35],
);
}
}
// ── Action card (scenes, scripts) ────────────────────────────────
fn ha_action_card(
out: &mut Scene,
_state: &State,
assets: &Assets,
rect: Rect,
card: &crate::state::HaCardConfig,
focused: bool,
) {
ha_glass(out, rect, focused);
let accent = c(0xc9a0dc, 1.);
out.icon(
assets,
"scene",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
accent,
);
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 28., rect.w - 120., 32.),
text(20., 500, -0.02),
WHITE,
false,
);
if focused {
out.text(
"OK to activate",
Rect::new(rect.x + 80., rect.y + 60., 200., 16.),
text(12., 400, 0.),
c(0xe8e8e8, 0.40),
false,
);
}
}
// ── Vacuum card (summary; opens modal) ───────────────────────────
fn ha_vacuum_card(
out: &mut Scene,
_state: &State,
assets: &Assets,
rect: Rect,
card: &crate::state::HaCardConfig,
entity: Option<&crate::state::HaEntity>,
focused: bool,
) {
ha_glass(out, rect, focused);
let status = entity.map_or("unavailable", |e| e.state.as_str());
let accent = match status {
"cleaning" => c(0x7EB8DA, 1.),
"returning" => c(0xf0a060, 1.),
_ => c(0xbbbbbb, 0.5),
};
out.icon(
assets,
"vacuum",
Rect::new(rect.x + 20., rect.y + 22., 44., 44.),
accent,
);
out.text(
&card.name,
Rect::new(rect.x + 80., rect.y + 22., rect.w - 120., 24.),
text(20., 500, -0.02),
WHITE,
false,
);
out.text(
status,
Rect::new(rect.x + 80., rect.y + 52., rect.w - 120., 16.),
text(13.12, 400, 0.),
c(0xe8e8e8, 0.48),
false,
);
if focused {
out.text(
"OK to open",
Rect::new(rect.x + rect.w - 120., rect.y + 36., 100., 16.),
text(12., 400, 0.).right(),
c(0xe8e8e8, 0.40),
false,
);
}
}
// ── Weather hero card ────────────────────────────────────────────
fn ha_weather_card(
out: &mut Scene,
_state: &State,
assets: &Assets,
rect: Rect,
entity: &crate::state::HaEntity,
) {
let focused = _state.focus == Target::HaWeather;
ha_glass(out, rect, focused);
let condition = entity.state.as_str();
let temp = entity
.attributes
.get("temperature")
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let humidity = entity
.attributes
.get("humidity")
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let wind = entity
.attributes
.get("wind_speed")
.and_then(|v| v.as_f64())
.unwrap_or(0.);
let weather_icon = match condition {
"sunny" | "clear-night" => "weather-sun",
"cloudy" | "fog" => "weather-cloud",
"partlycloudy" => "weather-partly-cloudy",
"rainy" | "pouring" => "weather-rain",
"snowy" => "weather-snow",
"lightning" | "lightning-rainy" => "weather-storm",
"hail" => "weather-drizzle",
_ => "weather-sun",
};
out.icon(
assets,
weather_icon,
Rect::new(rect.x + 24., rect.y + 22., 56., 56.),
c(0xf0d080, 1.),
);
out.text(
&format!("{:.0}°", temp),
Rect::new(rect.x + 100., rect.y + 18., 120., 40.),
text(36., 600, -0.04),
WHITE,
false,
);
out.text(
condition,
Rect::new(rect.x + 100., rect.y + 62., 200., 18.),
text(14.4, 400, 0.),
c(0xe8e8e8, 0.60),
false,
);
out.icon(
assets,
"humidity",
Rect::new(rect.x + 340., rect.y + 34., 20., 20.),
c(0x60b0f0, 0.7),
);
out.text(
&format!("{:.0}%", humidity),
Rect::new(rect.x + 368., rect.y + 36., 80., 20.),
text(15.2, 400, 0.),
c(0xe8e8e8, 0.60),
false,
);
out.text(
&format!("{:.0} km/h", wind),
Rect::new(rect.x + 480., rect.y + 36., 120., 20.),
text(15.2, 400, 0.),
c(0xe8e8e8, 0.60),
false,
);
}
// ── Vacuum modal ─────────────────────────────────────────────────
fn ha_vacuum_modal(
out: &mut Scene,
state: &State,
assets: &Assets,
vac: &crate::state::HaVacuumConfig,
) {
let overlay_rect = Rect::new(0., 0., 1920., 1080.);
out.shape(overlay_rect, 0., c(0x080b12, 0.75));
out.modal = Some(overlay_rect);
let modal = Rect::new(320., 120., 1280., 840.);
ha_glass(out, modal, false);
// Title + close
out.text(
"Room Cleaning",
Rect::new(modal.x + 40., modal.y + 28., 400., 32.),
text(28., 600, -0.03),
WHITE,
false,
);
let close_rect = Rect::new(modal.x + modal.w - 72., modal.y + 24., 40., 40.);
out.shape(close_rect, 20., c(0xffffff, 0.08));
out.icon(assets, "close", close_rect.inset(10.), c(0xeeeeee, 0.7));
let close_focused = state.focus == Target::HaVacuumClose;
if close_focused {
out.border(close_rect.inset(-3.), 23., 2., WHITE);
}
out.control(Target::HaVacuumClose, close_rect, false);
// Room grid
let cols = 3usize;
let room_w = 380.;
let room_h = 72.;
let gap = 12.;
let grid_x = modal.x + 40.;
let grid_y = modal.y + 88.;
for (index, room) in vac.rooms.iter().enumerate() {
let col = index % cols;
let row = index / cols;
let rx = grid_x + col as f32 * (room_w + gap);
let ry = grid_y + row as f32 * (room_h + gap);
let rect = Rect::new(rx, ry, room_w, room_h);
let selected = state.ha.vacuum_selected.get(index).copied().unwrap_or(false);
let focused = state.focus == Target::HaVacuumRoom(index);
out.shape(
rect,
icon: "settings",
title: language.t("ha"),
hint: "Home Assistant",
action: None,
16.,
if selected {
c(0x7EB8DA, 0.20)
} else {
c(0xffffff, 0.06)
},
);
out.border(
rect,
16.,
if selected { 2. } else { 1. },
if selected {
c(0x7EB8DA, 0.60)
} else {
c(0xffffff, 0.10)
},
);
if focused {
out.border(rect.inset(-4.), 20., 2., WHITE);
}
if selected {
out.icon(
assets,
"check",
Rect::new(rect.x + 16., rect.y + 20., 32., 32.),
c(0x7EB8DA, 1.),
);
}
out.text(
&room.name,
Rect::new(
rect.x + if selected { 56. } else { 20. },
rect.y + 24.,
rect.w - 76.,
24.,
),
text(18., 500, -0.02),
WHITE,
false,
);
out.control(Target::HaVacuumRoom(index), rect, false);
}
// Cleaning mode selector
let mode_y = grid_y + ((vac.rooms.len() + cols - 1) / cols) as f32 * (room_h + gap) + 16.;
let mode_rect = Rect::new(grid_x, mode_y, 600., 56.);
let mode_focused = state.focus == Target::HaVacuumMode;
out.shape(mode_rect, 14., c(0xffffff, 0.06));
out.border(mode_rect, 14., 1., c(0xffffff, 0.10));
if mode_focused {
out.border(mode_rect.inset(-4.), 18., 2., WHITE);
}
let mode_label = state.ha.entities.get(&vac.cleaning_mode_entity)
.and_then(|e| e.attributes.get("options"))
.and_then(|o| o.as_array())
.and_then(|opts| opts.get(state.ha.vacuum_mode))
.and_then(|v| v.as_str())
.unwrap_or("default");
out.text(
&format!("Mode: {}", mode_label),
Rect::new(mode_rect.x + 20., mode_rect.y + 16., 540., 24.),
text(18., 500, -0.02),
WHITE,
false,
);
out.control(Target::HaVacuumMode, mode_rect, false);
// Start button
let start_y = mode_y + 72.;
let selected_count = state.ha.vacuum_selected.iter().filter(|&&s| s).count();
let start_rect = Rect::new(grid_x, start_y, 600., 56.);
let start_focused = state.focus == Target::HaVacuumStart;
let can_start = selected_count > 0;
out.shape(
start_rect,
14.,
if can_start {
c(0x7EB8DA, 0.25)
} else {
c(0xffffff, 0.04)
},
);
out.border(
start_rect,
14.,
1.,
if can_start {
c(0x7EB8DA, 0.50)
} else {
c(0xffffff, 0.06)
},
);
if start_focused && can_start {
out.border(start_rect.inset(-4.), 18., 2., WHITE);
}
out.text(
&format!(
"Start Cleaning{}",
if selected_count > 0 {
format!(" ({} rooms)", selected_count)
} else {
String::new()
}
),
Rect::new(start_rect.x + 20., start_rect.y + 16., 540., 24.),
text(18., 600, -0.02),
if can_start { WHITE } else { c(0xbbbbbb, 0.5) },
false,
);
out.control(Target::HaVacuumStart, start_rect, false);
}
fn settings(out: &mut Scene, state: &State, assets: &Assets) {