ember-mug 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -15
- package/dist/cli.js +37 -7
- package/dist/components/App.d.ts +1 -1
- package/dist/components/App.js +65 -54
- package/dist/components/BatteryDisplay.d.ts +9 -3
- package/dist/components/BatteryDisplay.js +8 -19
- package/dist/components/ConnectionStatus.d.ts +7 -2
- package/dist/components/ConnectionStatus.js +5 -4
- package/dist/components/Header.d.ts +7 -2
- package/dist/components/Header.js +11 -3
- package/dist/components/HelpDisplay.d.ts +5 -2
- package/dist/components/HelpDisplay.js +8 -3
- package/dist/components/HorizontalRule.d.ts +2 -0
- package/dist/components/HorizontalRule.js +7 -0
- package/dist/components/Panel.d.ts +18 -0
- package/dist/components/Panel.js +57 -0
- package/dist/components/Presets.d.ts +8 -3
- package/dist/components/Presets.js +13 -8
- package/dist/components/SettingsView.d.ts +9 -3
- package/dist/components/SettingsView.js +90 -16
- package/dist/components/TemperatureControl.d.ts +8 -3
- package/dist/components/TemperatureControl.js +13 -18
- package/dist/components/TemperatureDisplay.d.ts +9 -3
- package/dist/components/TemperatureDisplay.js +9 -36
- package/dist/hooks/useMug.d.ts +3 -1
- package/dist/hooks/useMug.js +69 -22
- package/dist/lib/bluetooth.d.ts +2 -0
- package/dist/lib/bluetooth.js +8 -0
- package/dist/lib/mock-bluetooth.d.ts +65 -0
- package/dist/lib/mock-bluetooth.js +214 -0
- package/dist/lib/settings.d.ts +1 -1
- package/dist/lib/settings.js +20 -20
- package/dist/lib/theme.d.ts +135 -0
- package/dist/lib/theme.js +112 -0
- package/dist/lib/types.d.ts +0 -1
- package/dist/lib/types.js +12 -12
- package/dist/lib/utils.d.ts +7 -4
- package/dist/lib/utils.js +63 -40
- package/package.json +3 -1
- package/dist/components/ColorControl.d.ts +0 -9
- package/dist/components/ColorControl.js +0 -71
package/README.md
CHANGED
|
@@ -39,35 +39,37 @@ npm run dev
|
|
|
39
39
|
- **Temperature Control**: View current temperature and adjust target temperature
|
|
40
40
|
- **Temperature Presets**: Quick-select from predefined temperature presets (Latte, Coffee, Tea)
|
|
41
41
|
- **Battery Monitoring**: Real-time battery level with estimated battery life
|
|
42
|
-
- **Time Estimates**:
|
|
43
|
-
- Estimated time to reach target temperature
|
|
44
|
-
- Estimated battery life based on
|
|
45
|
-
- **LED Color Control**: Customize your mug's LED color
|
|
42
|
+
- **Dynamic Time Estimates**:
|
|
43
|
+
- Estimated time to reach target temperature based on real-time heating/cooling rates
|
|
44
|
+
- Estimated battery life based on actual discharge/charge rates
|
|
45
|
+
- **LED Color Control**: Customize your mug's LED color via the settings panel
|
|
46
46
|
- **Temperature Unit Toggle**: Switch between Celsius and Fahrenheit
|
|
47
|
-
- **Persistent Settings**: Your preferences are saved between sessions
|
|
47
|
+
- **Persistent Settings**: Your preferences and custom presets are saved between sessions
|
|
48
|
+
- **Responsive Layout**: Adapts to narrow or wide terminals automatically
|
|
48
49
|
|
|
49
50
|
## Controls
|
|
50
51
|
|
|
51
52
|
### When Disconnected
|
|
53
|
+
|
|
52
54
|
- `s` - Start scanning for Ember mug
|
|
53
55
|
- `r` - Retry scanning (after error)
|
|
54
56
|
- `q` - Quit
|
|
55
57
|
|
|
56
58
|
### When Connected
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
- `↑/↓` or `j/k` - Adjust by ±1°
|
|
60
|
-
- `t` or `Enter` - Exit temperature mode
|
|
59
|
+
|
|
60
|
+
- `←/→` or `h/l` - Adjust temperature by ±0.5°
|
|
61
61
|
- `1-3` - Select temperature preset
|
|
62
|
-
- `c` - Toggle LED color control
|
|
63
|
-
- `1-8` - Select preset color
|
|
64
|
-
- `c` - Toggle custom color mode
|
|
65
|
-
- `r/g/b` - Select color channel (in custom mode)
|
|
66
|
-
- `←/→` - Adjust selected channel
|
|
67
62
|
- `u` - Toggle temperature unit (°C/°F)
|
|
68
|
-
- `o` - Open settings
|
|
63
|
+
- `o` - Open settings (LED color, unit, preset editing)
|
|
69
64
|
- `q` - Quit
|
|
70
65
|
|
|
66
|
+
### In Settings
|
|
67
|
+
|
|
68
|
+
- `↑/↓` or `j/k` - Navigate settings
|
|
69
|
+
- `Enter/Space` - Toggle unit
|
|
70
|
+
- `←/→` or `h/l` - Change LED color or edit preset temperature
|
|
71
|
+
- `Esc` or `q` - Close settings
|
|
72
|
+
|
|
71
73
|
## Requirements
|
|
72
74
|
|
|
73
75
|
- Node.js 18+
|
|
@@ -77,20 +79,25 @@ npm run dev
|
|
|
77
79
|
### Platform-Specific Notes
|
|
78
80
|
|
|
79
81
|
#### Linux
|
|
82
|
+
|
|
80
83
|
You may need to grant Bluetooth permissions:
|
|
84
|
+
|
|
81
85
|
```bash
|
|
82
86
|
sudo setcap cap_net_raw+eip $(eval readlink -f `which node`)
|
|
83
87
|
```
|
|
84
88
|
|
|
85
89
|
#### macOS
|
|
90
|
+
|
|
86
91
|
Grant Bluetooth permissions to your terminal application in System Preferences > Security & Privacy > Privacy > Bluetooth.
|
|
87
92
|
|
|
88
93
|
#### Windows
|
|
94
|
+
|
|
89
95
|
Requires Windows 10 build 15063 or later with Bluetooth 4.0+ adapter.
|
|
90
96
|
|
|
91
97
|
## Technical Details
|
|
92
98
|
|
|
93
99
|
This application uses:
|
|
100
|
+
|
|
94
101
|
- [@abandonware/noble](https://github.com/abandonware/noble) for Bluetooth LE communication
|
|
95
102
|
- [Ink](https://github.com/vadimdemedes/ink) for the React-based CLI interface
|
|
96
103
|
- [Conf](https://github.com/sindresorhus/conf) for persistent settings storage
|
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { render } from
|
|
4
|
-
import { App } from
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import { render } from "ink";
|
|
4
|
+
import { App } from "./components/App.js";
|
|
5
|
+
import { isMockMode, setBluetoothManager } from "./lib/bluetooth.js";
|
|
6
|
+
// ANSI escape codes for alternate screen buffer
|
|
7
|
+
const enterAltScreen = "\x1b[?1049h";
|
|
8
|
+
const exitAltScreen = "\x1b[?1049l";
|
|
9
|
+
const hideCursor = "\x1b[?25l";
|
|
10
|
+
const showCursor = "\x1b[?25h";
|
|
11
|
+
const clearScreen = "\x1b[2J\x1b[H";
|
|
12
|
+
// Enter alternate screen buffer and hide cursor
|
|
13
|
+
process.stdout.write(enterAltScreen + hideCursor + clearScreen);
|
|
14
|
+
// Handle graceful shutdown - restore terminal state
|
|
15
|
+
const cleanup = () => {
|
|
16
|
+
process.stdout.write(showCursor + exitAltScreen);
|
|
7
17
|
process.exit(0);
|
|
18
|
+
};
|
|
19
|
+
process.on("SIGINT", cleanup);
|
|
20
|
+
process.on("SIGTERM", cleanup);
|
|
21
|
+
process.on("exit", () => {
|
|
22
|
+
process.stdout.write(showCursor + exitAltScreen);
|
|
8
23
|
});
|
|
9
|
-
|
|
24
|
+
// Handle terminal resize - clear screen to prevent artifacts
|
|
25
|
+
process.stdout.on("resize", () => {
|
|
26
|
+
process.stdout.write(clearScreen);
|
|
27
|
+
});
|
|
28
|
+
async function main() {
|
|
29
|
+
// Initialize mock manager if in mock mode (set via EMBER_MOCK env var)
|
|
30
|
+
if (isMockMode()) {
|
|
31
|
+
const { getMockBluetoothManager } = await import("./lib/mock-bluetooth.js");
|
|
32
|
+
setBluetoothManager(getMockBluetoothManager());
|
|
33
|
+
}
|
|
34
|
+
// Render the app
|
|
35
|
+
const app = render(_jsx(App, {}));
|
|
36
|
+
await app.waitUntilExit();
|
|
10
37
|
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
main().catch((error) => {
|
|
40
|
+
process.stdout.write(showCursor + exitAltScreen);
|
|
41
|
+
console.error("Failed to start:", error);
|
|
42
|
+
process.exit(1);
|
|
11
43
|
});
|
|
12
|
-
// Render the app
|
|
13
|
-
render(_jsx(App, {}));
|
package/dist/components/App.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import React from
|
|
1
|
+
import React from "react";
|
|
2
2
|
export declare function App(): React.ReactElement;
|
package/dist/components/App.js
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState, useEffect, useCallback } from
|
|
3
|
-
import { Box, useApp, useInput } from
|
|
4
|
-
import { useMug } from
|
|
5
|
-
import { Header } from
|
|
6
|
-
import { TemperatureDisplay } from
|
|
7
|
-
import { BatteryDisplay } from
|
|
8
|
-
import { TemperatureControl } from
|
|
9
|
-
import { Presets } from
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect, useCallback } from "react";
|
|
3
|
+
import { Box, useApp, useInput, useStdout } from "ink";
|
|
4
|
+
import { useMug } from "../hooks/useMug.js";
|
|
5
|
+
import { Header } from "./Header.js";
|
|
6
|
+
import { TemperatureDisplay } from "./TemperatureDisplay.js";
|
|
7
|
+
import { BatteryDisplay } from "./BatteryDisplay.js";
|
|
8
|
+
import { TemperatureControl } from "./TemperatureControl.js";
|
|
9
|
+
import { Presets } from "./Presets.js";
|
|
10
|
+
import { ConnectionStatus } from "./ConnectionStatus.js";
|
|
11
|
+
import { SettingsView } from "./SettingsView.js";
|
|
12
|
+
import { HelpDisplay } from "./HelpDisplay.js";
|
|
13
|
+
import { TemperatureUnit } from "../lib/types.js";
|
|
14
|
+
import { getPresets, getTemperatureUnit, setTemperatureUnit as saveTemperatureUnit, setLastTargetTemp, updatePreset, } from "../lib/settings.js";
|
|
15
|
+
import { getThemeForState, getTerminalTheme } from "../lib/theme.js";
|
|
16
16
|
export function App() {
|
|
17
17
|
const { exit } = useApp();
|
|
18
|
-
const {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
const
|
|
18
|
+
const { stdout } = useStdout();
|
|
19
|
+
const terminalWidth = stdout?.columns || 80;
|
|
20
|
+
// Responsive layout: stack vertically on narrow terminals, 2x2 grid on wide
|
|
21
|
+
const isNarrowTerminal = terminalWidth < 80;
|
|
22
|
+
// Calculate panel widths: 1/4 screen each in 2x2 grid (accounts for gaps)
|
|
23
|
+
// For a 2x2 grid with gap of 2 between panels, each panel is (width - 6) / 2
|
|
24
|
+
const panelWidth = isNarrowTerminal
|
|
25
|
+
? terminalWidth - 4 // Full width minus margins for stacked layout
|
|
26
|
+
: Math.floor((terminalWidth - 6) / 2); // Half width minus gaps for 2x2 grid
|
|
27
|
+
const { state: mugState, isScanning, error, foundMugName, startScanning, setTargetTemp, setTemperatureUnit: setMugTempUnit, setLedColor, tempRate, batteryRate, } = useMug();
|
|
28
|
+
// Get theme based on mug state
|
|
29
|
+
const themeKey = getThemeForState(mugState.liquidState, mugState.connected);
|
|
30
|
+
const theme = getTerminalTheme(themeKey);
|
|
31
|
+
const [viewMode, setViewMode] = useState("main");
|
|
32
|
+
const [activeControl, setActiveControl] = useState("none");
|
|
33
|
+
const [presets, setPresets] = useState(getPresets());
|
|
22
34
|
const [selectedPresetIndex, setSelectedPresetIndex] = useState(-1);
|
|
23
35
|
const [localTempUnit, setLocalTempUnit] = useState(getTemperatureUnit());
|
|
24
36
|
// Start scanning on mount
|
|
@@ -26,11 +38,17 @@ export function App() {
|
|
|
26
38
|
startScanning();
|
|
27
39
|
}, [startScanning]);
|
|
28
40
|
// Sync temperature unit with mug when connected
|
|
41
|
+
// Priority: Local settings > Mug settings
|
|
29
42
|
useEffect(() => {
|
|
30
|
-
if (mugState.connected) {
|
|
31
|
-
|
|
43
|
+
if (mugState.connected && mugState.temperatureUnit !== localTempUnit) {
|
|
44
|
+
setMugTempUnit(localTempUnit);
|
|
32
45
|
}
|
|
33
|
-
}, [
|
|
46
|
+
}, [
|
|
47
|
+
mugState.connected,
|
|
48
|
+
mugState.temperatureUnit,
|
|
49
|
+
localTempUnit,
|
|
50
|
+
setMugTempUnit,
|
|
51
|
+
]);
|
|
34
52
|
const handleTempChange = useCallback(async (temp) => {
|
|
35
53
|
await setTargetTemp(temp);
|
|
36
54
|
setLastTargetTemp(temp);
|
|
@@ -52,46 +70,45 @@ export function App() {
|
|
|
52
70
|
await setMugTempUnit(unit);
|
|
53
71
|
}
|
|
54
72
|
}, [mugState.connected, setMugTempUnit]);
|
|
73
|
+
const handlePresetUpdate = useCallback((preset) => {
|
|
74
|
+
// Update local state
|
|
75
|
+
setPresets((currentPresets) => currentPresets.map((p) => (p.id === preset.id ? preset : p)));
|
|
76
|
+
// Persist to settings
|
|
77
|
+
updatePreset(preset.id, preset);
|
|
78
|
+
}, []);
|
|
55
79
|
useInput((input, key) => {
|
|
56
80
|
// Global controls
|
|
57
|
-
if (input ===
|
|
81
|
+
if (input === "q" && viewMode === "main" && activeControl === "none") {
|
|
58
82
|
exit();
|
|
59
83
|
return;
|
|
60
84
|
}
|
|
61
85
|
// Handle escape to go back
|
|
62
86
|
if (key.escape) {
|
|
63
|
-
if (activeControl !==
|
|
64
|
-
setActiveControl(
|
|
87
|
+
if (activeControl !== "none") {
|
|
88
|
+
setActiveControl("none");
|
|
65
89
|
}
|
|
66
|
-
else if (viewMode !==
|
|
67
|
-
setViewMode(
|
|
90
|
+
else if (viewMode !== "main") {
|
|
91
|
+
setViewMode("main");
|
|
68
92
|
}
|
|
69
93
|
return;
|
|
70
94
|
}
|
|
71
95
|
// Only handle these if we're in main view with no active control
|
|
72
|
-
if (viewMode ===
|
|
73
|
-
if (input ===
|
|
96
|
+
if (viewMode === "main" && activeControl === "none") {
|
|
97
|
+
if (input === "s" && !mugState.connected) {
|
|
74
98
|
startScanning();
|
|
75
99
|
return;
|
|
76
100
|
}
|
|
77
|
-
if (input ===
|
|
101
|
+
if (input === "r" && !mugState.connected && error) {
|
|
78
102
|
startScanning();
|
|
79
103
|
return;
|
|
80
104
|
}
|
|
81
105
|
if (mugState.connected) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
if (input === 'c') {
|
|
87
|
-
setActiveControl('color');
|
|
88
|
-
return;
|
|
89
|
-
}
|
|
90
|
-
if (input === 'o') {
|
|
91
|
-
setViewMode('settings');
|
|
106
|
+
// 'c' hotkey removed
|
|
107
|
+
if (input === "o") {
|
|
108
|
+
setViewMode("settings");
|
|
92
109
|
return;
|
|
93
110
|
}
|
|
94
|
-
if (input ===
|
|
111
|
+
if (input === "u") {
|
|
95
112
|
const newUnit = localTempUnit === TemperatureUnit.Celsius
|
|
96
113
|
? TemperatureUnit.Fahrenheit
|
|
97
114
|
: TemperatureUnit.Celsius;
|
|
@@ -100,21 +117,15 @@ export function App() {
|
|
|
100
117
|
}
|
|
101
118
|
}
|
|
102
119
|
}
|
|
103
|
-
// Exit temperature control mode
|
|
104
|
-
if (activeControl === 'temperature' && (key.return || input === 't')) {
|
|
105
|
-
setActiveControl('none');
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
// Exit color control mode
|
|
109
|
-
if (activeControl === 'color' && input === 'c') {
|
|
110
|
-
setActiveControl('none');
|
|
111
|
-
return;
|
|
112
|
-
}
|
|
113
120
|
});
|
|
114
121
|
// Settings view
|
|
115
|
-
if (viewMode ===
|
|
116
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { mugName: mugState.mugName, connected: mugState.connected }), _jsx(SettingsView, { presets: presets, temperatureUnit: localTempUnit, onTemperatureUnitChange: handleTemperatureUnitChange, onClose: () => setViewMode(
|
|
122
|
+
if (viewMode === "settings") {
|
|
123
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { mugName: mugState.mugName, connected: mugState.connected, theme: theme, ledColor: mugState.color }), _jsx(SettingsView, { presets: presets, temperatureUnit: localTempUnit, ledColor: mugState.color, onTemperatureUnitChange: handleTemperatureUnitChange, onPresetUpdate: handlePresetUpdate, onColorChange: handleColorChange, onClose: () => setViewMode("main"), isActive: true, theme: theme })] }));
|
|
117
124
|
}
|
|
118
125
|
// Main view
|
|
119
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { mugName: mugState.mugName, connected: mugState.connected }), !mugState.connected ? (_jsx(ConnectionStatus, { isScanning: isScanning, isConnected: mugState.connected, foundMugName: foundMugName, error: error, onRetry: startScanning
|
|
126
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Header, { mugName: mugState.mugName, connected: mugState.connected, theme: theme, ledColor: mugState.color }), !mugState.connected ? (_jsx(ConnectionStatus, { isScanning: isScanning, isConnected: mugState.connected, foundMugName: foundMugName, error: error, onRetry: startScanning, width: panelWidth, theme: theme })) : (_jsx(Box, { flexDirection: "column", children: isNarrowTerminal ? (
|
|
127
|
+
/* Narrow terminal: stack all panels vertically */
|
|
128
|
+
_jsxs(_Fragment, { children: [_jsx(TemperatureDisplay, { currentTemp: mugState.currentTemp, targetTemp: mugState.targetTemp, liquidState: mugState.liquidState, temperatureUnit: localTempUnit, width: panelWidth, theme: theme, tempRate: tempRate }), _jsx(Box, { marginTop: 1, children: _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState, width: panelWidth, theme: theme, batteryRate: batteryRate }) }), _jsx(Box, { marginTop: 1, children: _jsx(TemperatureControl, { targetTemp: mugState.targetTemp, temperatureUnit: localTempUnit, onTempChange: handleTempChange, isActive: true, width: panelWidth, theme: theme }) }), _jsx(Box, { marginTop: 1, children: _jsx(Presets, { presets: presets, selectedIndex: selectedPresetIndex, temperatureUnit: localTempUnit, onSelect: handlePresetSelect, isActive: activeControl === "none", width: panelWidth, theme: theme }) })] })) : (
|
|
129
|
+
/* Wide terminal: 2x2 grid layout */
|
|
130
|
+
_jsxs(_Fragment, { children: [_jsxs(Box, { justifyContent: "center", gap: 2, children: [_jsx(TemperatureDisplay, { currentTemp: mugState.currentTemp, targetTemp: mugState.targetTemp, liquidState: mugState.liquidState, temperatureUnit: localTempUnit, width: panelWidth, height: 8, theme: theme, tempRate: tempRate }), _jsx(BatteryDisplay, { batteryLevel: mugState.batteryLevel, isCharging: mugState.isCharging, liquidState: mugState.liquidState, width: panelWidth, height: 8, theme: theme, batteryRate: batteryRate })] }), _jsxs(Box, { justifyContent: "center", gap: 2, marginTop: 1, children: [_jsx(TemperatureControl, { targetTemp: mugState.targetTemp, temperatureUnit: localTempUnit, onTempChange: handleTempChange, isActive: true, width: panelWidth, height: 9, theme: theme }), _jsx(Presets, { presets: presets, selectedIndex: selectedPresetIndex, temperatureUnit: localTempUnit, onSelect: handlePresetSelect, isActive: activeControl === "none", width: panelWidth, height: 9, theme: theme })] })] })) })), _jsx(HelpDisplay, { isConnected: mugState.connected, theme: theme })] }));
|
|
120
131
|
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import React from
|
|
2
|
-
import { LiquidState } from
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { LiquidState } from "../lib/types.js";
|
|
3
|
+
import { TERMINAL_COLORS } from "../lib/theme.js";
|
|
4
|
+
type TerminalTheme = (typeof TERMINAL_COLORS)[keyof typeof TERMINAL_COLORS];
|
|
3
5
|
interface BatteryDisplayProps {
|
|
4
6
|
batteryLevel: number;
|
|
5
7
|
isCharging: boolean;
|
|
6
8
|
liquidState: LiquidState;
|
|
9
|
+
width?: number;
|
|
10
|
+
height?: number;
|
|
11
|
+
theme: TerminalTheme;
|
|
12
|
+
batteryRate?: number;
|
|
7
13
|
}
|
|
8
|
-
export declare function BatteryDisplay({ batteryLevel, isCharging, liquidState, }: BatteryDisplayProps): React.ReactElement;
|
|
14
|
+
export declare function BatteryDisplay({ batteryLevel, isCharging, liquidState, width, height, theme, batteryRate, }: BatteryDisplayProps): React.ReactElement;
|
|
9
15
|
export {};
|
|
@@ -1,25 +1,14 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from
|
|
3
|
-
import { formatBatteryLevel,
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Box, { justifyContent: "center", children: _jsx(Text, { bold: true, children: "Battery" }) }), _jsx(Box, { justifyContent: "center", marginY: 1, children: _jsxs(Text, { color: batteryColor, children: [batteryIcon, " ", formatBatteryLevel(batteryLevel), isCharging && _jsx(Text, { color: "yellow", children: " (Charging)" })] }) }), batteryTimeEstimate !== null && (_jsx(Box, { justifyContent: "center", children: _jsx(Text, { dimColor: true, children: isCharging ? (_jsxs(_Fragment, { children: ['Time to full: ', _jsx(Text, { color: "green", children: formatDuration(batteryTimeEstimate) })] })) : (_jsxs(_Fragment, { children: ['Est. battery life: ', _jsx(Text, { color: batteryLevel < 20 ? 'red' : 'yellow', children: formatDuration(batteryTimeEstimate) })] })) }) })), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(BatteryBar, { level: batteryLevel, isCharging: isCharging }) })] }));
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { formatBatteryLevel, estimateBatteryLife, formatDuration, } from "../lib/utils.js";
|
|
4
|
+
import { Panel } from "./Panel.js";
|
|
5
|
+
export function BatteryDisplay({ batteryLevel, isCharging, liquidState, width, height, theme, batteryRate, }) {
|
|
6
|
+
const batteryTimeEstimate = estimateBatteryLife(batteryLevel, isCharging, liquidState, batteryRate);
|
|
7
|
+
return (_jsxs(Panel, { title: "Battery", titleColor: theme.primary, borderColor: theme.border, width: width, height: height, children: [_jsxs(Box, { justifyContent: "center", marginY: 1, children: [_jsx(Text, { color: theme.primary, bold: true, children: formatBatteryLevel(batteryLevel) }), isCharging && (_jsxs(Text, { color: theme.primary, bold: true, children: [" ", "~"] }))] }), _jsx(Box, { justifyContent: "center", children: _jsx(BatteryBar, { level: batteryLevel, isCharging: isCharging, theme: theme }) }), batteryTimeEstimate !== null && (_jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { color: theme.dimText, children: isCharging ? (_jsxs(_Fragment, { children: ["[~] ", _jsx(Text, { color: theme.primary, children: formatDuration(batteryTimeEstimate) }), _jsx(Text, { color: theme.dimText, children: " to full" })] })) : (_jsxs(_Fragment, { children: ["[~] ", _jsx(Text, { color: theme.primary, children: formatDuration(batteryTimeEstimate) }), _jsx(Text, { color: theme.dimText, children: " remaining" })] })) }) }))] }));
|
|
9
8
|
}
|
|
10
|
-
function BatteryBar({ level, isCharging }) {
|
|
9
|
+
function BatteryBar({ level, isCharging, theme, }) {
|
|
11
10
|
const totalSegments = 20;
|
|
12
11
|
const filledSegments = Math.round((level / 100) * totalSegments);
|
|
13
12
|
const emptySegments = totalSegments - filledSegments;
|
|
14
|
-
|
|
15
|
-
return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "[" }), _jsx(Text, { color: color, children: '█'.repeat(filledSegments) }), _jsx(Text, { dimColor: true, children: '░'.repeat(emptySegments) }), _jsx(Text, { dimColor: true, children: "]" }), isCharging && _jsx(Text, { color: "yellow", children: " \u26A1" })] }));
|
|
16
|
-
}
|
|
17
|
-
function getBatteryColor(level, isCharging) {
|
|
18
|
-
if (isCharging)
|
|
19
|
-
return 'yellow';
|
|
20
|
-
if (level >= 50)
|
|
21
|
-
return 'green';
|
|
22
|
-
if (level >= 25)
|
|
23
|
-
return 'yellow';
|
|
24
|
-
return 'red';
|
|
13
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.dimText, children: "\u2590" }), _jsx(Text, { color: theme.primary, children: "█".repeat(filledSegments) }), _jsx(Text, { color: theme.dimText, children: "░".repeat(emptySegments) }), _jsx(Text, { color: theme.dimText, children: "\u258C" })] }));
|
|
25
14
|
}
|
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import React from
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { TERMINAL_COLORS } from "../lib/theme.js";
|
|
3
|
+
type TerminalTheme = (typeof TERMINAL_COLORS)[keyof typeof TERMINAL_COLORS];
|
|
2
4
|
interface ConnectionStatusProps {
|
|
3
5
|
isScanning: boolean;
|
|
4
6
|
isConnected: boolean;
|
|
5
7
|
foundMugName: string | null;
|
|
6
8
|
error: string | null;
|
|
7
9
|
onRetry: () => void;
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
theme: TerminalTheme;
|
|
8
13
|
}
|
|
9
|
-
export declare function ConnectionStatus({ isScanning, isConnected, foundMugName, error, }: ConnectionStatusProps): React.ReactElement;
|
|
14
|
+
export declare function ConnectionStatus({ isScanning, isConnected, foundMugName, error, width, height, theme, }: ConnectionStatusProps): React.ReactElement;
|
|
10
15
|
export {};
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from
|
|
3
|
-
import Spinner from
|
|
4
|
-
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import Spinner from "ink-spinner";
|
|
4
|
+
import { Panel } from "./Panel.js";
|
|
5
|
+
export function ConnectionStatus({ isScanning, isConnected, foundMugName, error, width, height = 10, theme, }) {
|
|
5
6
|
if (isConnected) {
|
|
6
7
|
return _jsx(_Fragment, {});
|
|
7
8
|
}
|
|
8
|
-
return (_jsx(Box, {
|
|
9
|
+
return (_jsx(Box, { justifyContent: "center", marginY: 2, children: _jsx(Panel, { title: error ? "[!] Error" : isScanning ? "[~] Scanning" : "C[_] Welcome", titleColor: error ? "red" : theme.primary, borderColor: error ? "red" : theme.border, width: width, height: height, children: error ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginY: 1, children: [_jsx(Text, { color: "red", bold: true, children: error }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.dimText, children: ["Press", " ", _jsx(Text, { color: "yellow", bold: true, children: "[r]" }), " ", "to retry scanning"] }) })] })) : isScanning ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginY: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: theme.text, children: " Searching for Ember mug..." })] }), foundMugName && (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: "green", bold: true, children: ["* Found: ", foundMugName] }) })), foundMugName && (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: theme.primary, children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { color: theme.text, children: " Connecting..." })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.dimText, children: "Ensure mug is powered on and nearby" }) })] })) : (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginY: 1, children: [_jsx(Text, { color: theme.text, children: "No Ember mug connected" }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.dimText, children: ["Press", " ", _jsx(Text, { color: "green", bold: true, children: "[s]" }), " ", "to start scanning"] }) })] })) }) }));
|
|
9
10
|
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
-
import React from
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { TERMINAL_COLORS } from "../lib/theme.js";
|
|
3
|
+
import { RGBColor } from "../lib/types.js";
|
|
4
|
+
type TerminalTheme = (typeof TERMINAL_COLORS)[keyof typeof TERMINAL_COLORS];
|
|
2
5
|
interface HeaderProps {
|
|
3
6
|
mugName: string;
|
|
4
7
|
connected: boolean;
|
|
8
|
+
theme: TerminalTheme;
|
|
9
|
+
ledColor?: RGBColor;
|
|
5
10
|
}
|
|
6
|
-
export declare function Header({ mugName, connected }: HeaderProps): React.ReactElement;
|
|
11
|
+
export declare function Header({ mugName, connected, theme, ledColor, }: HeaderProps): React.ReactElement;
|
|
7
12
|
export {};
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { Box, Text, useStdout } from "ink";
|
|
3
|
+
import { isMockMode } from "../lib/bluetooth.js";
|
|
4
|
+
import { rgbToHex } from "../lib/utils.js";
|
|
5
|
+
export function Header({ mugName, connected, theme, ledColor, }) {
|
|
6
|
+
const mockMode = isMockMode();
|
|
7
|
+
const { stdout } = useStdout();
|
|
8
|
+
const width = stdout?.columns || 80;
|
|
9
|
+
const statusText = connected ? mugName : "Disconnected";
|
|
10
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: theme.border, children: "═".repeat(width) }), _jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsxs(Box, { children: [_jsx(Text, { color: theme.primary, bold: true, children: "C[_] EMBER MUG" }), mockMode && _jsx(Text, { color: "gray", children: " [MOCK]" })] }), _jsxs(Box, { children: [_jsxs(Text, { color: ledColor
|
|
11
|
+
? rgbToHex(ledColor.r, ledColor.g, ledColor.b)
|
|
12
|
+
: theme.primary, children: ["\u25CF", " "] }), _jsx(Text, { color: theme.text, children: statusText })] })] }), _jsx(Text, { color: theme.border, children: "═".repeat(width) })] }));
|
|
5
13
|
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
import React from
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { TERMINAL_COLORS } from "../lib/theme.js";
|
|
3
|
+
type TerminalTheme = (typeof TERMINAL_COLORS)[keyof typeof TERMINAL_COLORS];
|
|
2
4
|
interface HelpDisplayProps {
|
|
3
5
|
isConnected: boolean;
|
|
6
|
+
theme: TerminalTheme;
|
|
4
7
|
}
|
|
5
|
-
export declare function HelpDisplay({ isConnected }: HelpDisplayProps): React.ReactElement;
|
|
8
|
+
export declare function HelpDisplay({ isConnected, theme, }: HelpDisplayProps): React.ReactElement;
|
|
6
9
|
export {};
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Box, Text } from
|
|
3
|
-
export function HelpDisplay({ isConnected }) {
|
|
4
|
-
|
|
2
|
+
import { Box, Text, useStdout } from "ink";
|
|
3
|
+
export function HelpDisplay({ isConnected, theme, }) {
|
|
4
|
+
const { stdout } = useStdout();
|
|
5
|
+
const width = stdout?.columns || 80;
|
|
6
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.border, children: "─".repeat(width) }), _jsx(Box, { paddingY: 1, justifyContent: "center", gap: 2, children: isConnected ? (_jsxs(_Fragment, { children: [_jsx(HelpKey, { keyChar: "t", label: "temp", color: theme.primary }), _jsx(HelpKey, { keyChar: "1-3", label: "presets", color: theme.primary }), _jsx(HelpKey, { keyChar: "u", label: "unit", color: theme.primary }), _jsx(HelpKey, { keyChar: "o", label: "settings", color: theme.primary }), _jsx(HelpKey, { keyChar: "q", label: "quit", color: theme.primary })] })) : (_jsxs(_Fragment, { children: [_jsx(HelpKey, { keyChar: "s", label: "scan", color: theme.primary }), _jsx(HelpKey, { keyChar: "r", label: "retry", color: theme.primary }), _jsx(HelpKey, { keyChar: "q", label: "quit", color: theme.primary })] })) })] }));
|
|
7
|
+
}
|
|
8
|
+
function HelpKey({ keyChar, label, color }) {
|
|
9
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { color: color, bold: true, children: ["[", keyChar, "]"] }), _jsxs(Text, { dimColor: true, children: [" ", label] })] }));
|
|
5
10
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Text, useStdout } from 'ink';
|
|
3
|
+
export function HorizontalRule() {
|
|
4
|
+
const { stdout } = useStdout();
|
|
5
|
+
const width = stdout?.columns || 80;
|
|
6
|
+
return _jsx(Text, { dimColor: true, children: '─'.repeat(width) });
|
|
7
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
interface PanelProps {
|
|
3
|
+
title?: string;
|
|
4
|
+
titleColor?: string;
|
|
5
|
+
borderColor?: string;
|
|
6
|
+
children: React.ReactNode;
|
|
7
|
+
width?: number | string;
|
|
8
|
+
minWidth?: number;
|
|
9
|
+
padding?: number;
|
|
10
|
+
centerContent?: boolean;
|
|
11
|
+
height?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function Panel({ title, titleColor, borderColor, children, width, minWidth, padding, centerContent, height, }: PanelProps): React.ReactElement;
|
|
14
|
+
export declare function useTerminalSize(): {
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
};
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useStdout } from "ink";
|
|
3
|
+
export function Panel({ title, titleColor = "cyan", borderColor = "gray", children, width, minWidth = 20, padding = 1, centerContent = true, height, }) {
|
|
4
|
+
const { stdout } = useStdout();
|
|
5
|
+
const terminalWidth = stdout?.columns || 80;
|
|
6
|
+
// Calculate actual width
|
|
7
|
+
let actualWidth;
|
|
8
|
+
if (typeof width === "number") {
|
|
9
|
+
actualWidth = width;
|
|
10
|
+
}
|
|
11
|
+
else if (width === "100%") {
|
|
12
|
+
actualWidth = terminalWidth;
|
|
13
|
+
}
|
|
14
|
+
else if (typeof width === "string" && width.endsWith("%")) {
|
|
15
|
+
const percent = parseInt(width, 10) / 100;
|
|
16
|
+
actualWidth = Math.floor(terminalWidth * percent);
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
// Default to ~48% of terminal width (to fit 2 panels side by side with gap)
|
|
20
|
+
actualWidth = Math.floor((terminalWidth - 4) / 2);
|
|
21
|
+
}
|
|
22
|
+
// Ensure minimum width
|
|
23
|
+
actualWidth = Math.max(actualWidth, minWidth);
|
|
24
|
+
const innerWidth = actualWidth - 2; // Account for borders
|
|
25
|
+
const titleText = title ? ` ${title} ` : "";
|
|
26
|
+
const titleLen = title ? titleText.length : 0;
|
|
27
|
+
// Ensure title fits
|
|
28
|
+
const safeTitleLen = Math.min(titleLen, innerWidth - 4);
|
|
29
|
+
const safeTitle = titleLen > innerWidth - 4
|
|
30
|
+
? titleText.substring(0, innerWidth - 4)
|
|
31
|
+
: titleText;
|
|
32
|
+
// Top border with title
|
|
33
|
+
const topLeftPadding = Math.max(0, Math.floor((innerWidth - safeTitleLen) / 2));
|
|
34
|
+
const topRightPadding = Math.max(0, innerWidth - safeTitleLen - topLeftPadding);
|
|
35
|
+
const bottomBorder = `+${"-".repeat(innerWidth)}+`;
|
|
36
|
+
// Calculate content height (total height minus top and bottom borders)
|
|
37
|
+
const contentHeight = height ? height - 2 : undefined;
|
|
38
|
+
// Build left border column - one | for each content row
|
|
39
|
+
const renderSideBorders = (content) => {
|
|
40
|
+
if (!contentHeight) {
|
|
41
|
+
// Dynamic height fallback
|
|
42
|
+
return (_jsxs(Box, { flexDirection: "row", flexGrow: 1, children: [_jsx(Text, { color: borderColor, children: "|" }), _jsx(Box, { flexDirection: "column", width: innerWidth, paddingX: padding, justifyContent: centerContent ? "center" : "flex-start", alignItems: centerContent ? "center" : "flex-start", flexGrow: 1, children: content }), _jsx(Text, { color: borderColor, children: "|" })] }));
|
|
43
|
+
}
|
|
44
|
+
// Fixed height: render border characters for each line
|
|
45
|
+
const borderLines = "|\n".repeat(contentHeight).trim();
|
|
46
|
+
return (_jsxs(Box, { flexDirection: "row", height: contentHeight, children: [_jsx(Text, { color: borderColor, children: borderLines }), _jsx(Box, { flexDirection: "column", width: innerWidth, paddingX: padding, justifyContent: centerContent ? "center" : "flex-start", alignItems: centerContent ? "center" : "flex-start", children: content }), _jsx(Text, { color: borderColor, children: borderLines })] }));
|
|
47
|
+
};
|
|
48
|
+
return (_jsxs(Box, { flexDirection: "column", width: actualWidth, height: height, children: [_jsx(Text, { color: borderColor, children: title ? (_jsxs(_Fragment, { children: ["+", "-".repeat(topLeftPadding), _jsx(Text, { color: titleColor, bold: true, children: safeTitle }), "-".repeat(topRightPadding), "+"] })) : (`+${"-".repeat(innerWidth)}+`) }), renderSideBorders(children), _jsx(Text, { color: borderColor, children: bottomBorder })] }));
|
|
49
|
+
}
|
|
50
|
+
// Hook to get terminal dimensions for responsive layouts
|
|
51
|
+
export function useTerminalSize() {
|
|
52
|
+
const { stdout } = useStdout();
|
|
53
|
+
return {
|
|
54
|
+
width: stdout?.columns || 80,
|
|
55
|
+
height: stdout?.rows || 24,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import React from
|
|
2
|
-
import { Preset, TemperatureUnit } from
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Preset, TemperatureUnit } from "../lib/types.js";
|
|
3
|
+
import { TERMINAL_COLORS } from "../lib/theme.js";
|
|
4
|
+
type TerminalTheme = (typeof TERMINAL_COLORS)[keyof typeof TERMINAL_COLORS];
|
|
3
5
|
interface PresetsProps {
|
|
4
6
|
presets: Preset[];
|
|
5
7
|
selectedIndex: number;
|
|
6
8
|
temperatureUnit: TemperatureUnit;
|
|
7
9
|
onSelect: (preset: Preset) => void;
|
|
8
10
|
isActive: boolean;
|
|
11
|
+
width?: number;
|
|
12
|
+
height?: number;
|
|
13
|
+
theme: TerminalTheme;
|
|
9
14
|
}
|
|
10
|
-
export declare function Presets({ presets, selectedIndex, temperatureUnit, onSelect, isActive, }: PresetsProps): React.ReactElement;
|
|
15
|
+
export declare function Presets({ presets, selectedIndex, temperatureUnit, onSelect, isActive, width, height, theme, }: PresetsProps): React.ReactElement;
|
|
11
16
|
export {};
|