feat: show live battery charge by default
This commit is contained in:
@@ -10,6 +10,7 @@ One editor crate; the workspace also includes its patched Crossterm dependency.
|
|||||||
|
|
||||||
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
- `editor`: owns shared buffer lifecycle/text/saved state/undo history and independent tab/pane layouts, focused views, modes, selections, searches, explorers, and comparisons; session-wide clipboard policy, independent registers and owned-system fragment metadata, semantic native repeat, action metadata, command-palette and file-picker focus/dispatch, key notation, key-sequence/count prefixes and binding resolution, modal paste policy, saving, command ownership, capabilities, and validated edit effects. Its low-level interface returns an `Invocation` when an external command is selected.
|
||||||
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, and named-value validation. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
|
- `chrome`: owns validated top/bottom bar anchor groups, segment title templates and padding/edge options, builtin defaults, and named-value validation. Both bars accept the same segments and layout options. It has no terminal geometry or render callbacks.
|
||||||
|
- `battery`: reads native charge off the input thread, sampling immediately and every 30 seconds through an interruptible worker. macOS CoreFoundation references are type-checked and owned Copy/Create values released; Linux reads system battery sysfs and Windows reads native power status. A one-slot atomic mailbox retains the latest sample; absent/invalid readings clear cached presentation instead of inventing a percentage. Session starts collection only while an unoverridden battery segment is configured.
|
||||||
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
- `files`: owns the gitignore-aware directory/file catalog, authoritative OS paths, compact tree projection, query filtering, browse/search focus, expansion/selection restoration, navigation, and scan/error state shared by floating and docked presentations.
|
||||||
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
|
Virtual file leaves retain their original catalog input index for caller-owned annotations; scanned entries and directories remain unannotated.
|
||||||
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
- `diff`: owns searchable Before/After selection, category, focus and explorer visibility, request freshness, read-only comparison DTOs, aligned rows with cached grapheme-level change ranges, and shared vertical/independent horizontal offsets. Changed paths form a virtual `files::FilePicker` catalog, reusing the normal tree projection, filtering, and navigation without scanning disk. It has no subprocess or terminal dependencies.
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ Plugin paths resolve relative to the config file, not the shell's working direct
|
|||||||
|
|
||||||
Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported.
|
Use the two setters independently; a combined `config.bar = { ... }` assignment is not supported.
|
||||||
|
|
||||||
Defaults are unchanged: the top has `left: [:tabs]`; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count, :language]` and `right: [:position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs below:
|
Defaults put `left: [:tabs]` and `right: [:battery]` in the top bar; the bottom has `left: [:mode, :pending, :buffer, :diagnostics, :path, :selections, :count, :language]` and `right: [:position]`; other groups are empty. Set the bars independently, as in this swapped layout with status information above and tabs plus live battery charge below:
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
Vamp.configure do |config|
|
Vamp.configure do |config|
|
||||||
@@ -68,21 +68,22 @@ Vamp.configure do |config|
|
|||||||
},
|
},
|
||||||
battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
||||||
}
|
}
|
||||||
config.labels = { battery: "bat 38%" }
|
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
Builtins are `:tabs`, `:mode`, `:path`, `:position`, `:language`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, mode, file path/dirty state, caret position, detected language, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count.
|
Builtins are `:tabs`, `:battery`, `:mode`, `:path`, `:position`, `:language`, `:diagnostics`, `:selections`, `:count`, `:pending`, and `:buffer`, in either bar. They provide tab titles, battery charge, mode, file path/dirty state, caret position, detected language, diagnostics, selection count, numeric prefix, pending key sequence, and buffer identity/count.
|
||||||
|
|
||||||
A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. Narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. The command/search prompt and message area remain separate from both bars.
|
A bar with no visible content consumes no row. For example, `config.bar.top = { left: [], center: [], right: [] }` removes the top bar. A bar whose only visible content is `:tabs` is hidden with a single tab, whether above or below the workspace. Narrow layouts first omit inactive tabs, then optional segments from group ends to avoid overlap; the active tab drops decorations before its title is clipped. The command/search prompt and message area remain separate from both bars.
|
||||||
|
|
||||||
Each segment's `title` is a literal template supporting only `{index}`, `{name}`, `{path}`, and `{modified}`: the one-based tab index, filename, file path, and dirty marker (`" [+]"` when dirty, `""` when clean). Tabs use each tab's buffer metadata; other segments use the active buffer. Tabs default to `"{index} {name}{modified}"`; an empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback.
|
Each segment's `title` is a literal template supporting only `{index}`, `{name}`, `{path}`, and `{modified}`: the one-based tab index, filename, file path, and dirty marker (`" [+]"` when dirty, `""` when clean). Tabs use each tab's buffer metadata; other segments use the active buffer. Tabs default to `"{index} {name}{modified}"`; an empty title on another segment uses its builtin content or named label value. Templates are not Ruby code and never invoke a render callback.
|
||||||
|
|
||||||
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs default to `"░▒▓"` / `"▓▒░"` edges; mode has only the trailing `"▓▒░"` edge, position only the leading `"░▒▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
|
`padding` adds spaces inside the segment's styling, on both sides of its title, and accepts integers from `0` through `64` (default `1`). `leading_edge` and `trailing_edge` are separate styled glyph strings, not padding; `""` removes an edge. Tabs default to `"░▒▓"` / `"▓▒░"` edges; mode has only the trailing `"▓▒░"` edge, position and battery only the leading `"░▒▓"` edge, and other segments have none. `first_leading_edge` optionally overrides the first occurrence's leading glyphs: for tabs this is the actual first tab, not the first tab remaining after clipping; other segments have one occurrence. Tabs default this override to `""`, suppressing the first tab's left fade. Unicode widths are measured in terminal cells. Edge strings must be single-line and contain no control characters. Unknown style keys, invalid templates, and wrong types reject configuration.
|
||||||
|
|
||||||
Any other segment name is a custom named value, such as `:battery`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
|
Any other segment name is a custom named value, such as `:build_status`. Set initial strings with `config.labels`; an empty value hides the custom segment, including its title override, padding, and edges. Names match `[A-Za-z_][A-Za-z0-9_]*`. Labels are plain text: control characters are replaced rather than interpreted as terminal escapes. The same named value can appear in either bar.
|
||||||
|
|
||||||
Configuration runs once at startup. For live values, use [`editor.label(name, text)` or asynchronous plugin updates](plugins.md#named-label-updates). The editor does not collect battery data, run periodic shell commands, or accept render callbacks; a plugin supplies values and chooses when to refresh them.
|
`:battery` displays `bat N%` from the OS, with an immediate background reading and refreshes every 30 seconds. macOS uses IOKit, Linux uses system battery sysfs entries, and Windows uses `GetSystemPowerStatus`; no shell commands run. Missing, unsupported, or unreadable batteries hide the segment. macOS/Linux currently report the first present internal/system battery; Windows uses the OS's system percentage.
|
||||||
|
|
||||||
|
Move `:battery` to any anchor group or remove it from both bars to stop battery collection. A nonempty `config.labels[:battery]` or plugin value explicitly overrides the native text and pauses collection; clearing that value restores native readings. Other custom live values use [`editor.label(name, text)` or asynchronous plugin updates](plugins.md#named-label-updates). Configuration runs once at startup; there are no render callbacks.
|
||||||
|
|
||||||
## Actions and modes
|
## Actions and modes
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ Rust tests live alongside their modules; native LSP integration checks are in [`
|
|||||||
- Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting.
|
- Tab checks cover shared edits/history with independent pane layouts, focused views, explorers and viewports; cross-tab close guards, immutable snapshots, hidden comparison refresh/result routing, and separate repository workers. Rendered checks cover sanitized/dirty titles, active-tab visibility in narrow terminals, overlay row reservation, and exclusion of hidden diff geometry from palette polling and viewport reporting.
|
||||||
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup.
|
- Session and Ruby checks exercise real Ruby execution, command/capability ownership, effect validation, configuration fallback, plugin retirement, crash/timeout recovery, and inherited-pipe cleanup.
|
||||||
- For chrome changes, exercise anchor/template validation, padding/edge bounds, all builtin/custom segments in either bar, named startup values, and atomic live label batches. Rendering regressions should cover swapped bars across ordinary/split/diff/selector views, full-row center anchoring, terminal-cell glyph widths, empty-content row removal, tabs-only singleton hiding in either position, active-tab preservation, padding inside styled edges, and separate prompt geometry. Labels are session-wide presentation state: test cross-tab/read-only updates and unchanged-value redraw behavior without collecting real battery data or invoking render callbacks.
|
- For chrome changes, exercise anchor/template validation, padding/edge bounds, all builtin/custom segments in either bar, named startup values, and atomic live label batches. Rendering regressions should cover swapped bars across ordinary/split/diff/selector views, full-row center anchoring, terminal-cell glyph widths, empty-content row removal, tabs-only singleton hiding in either position, active-tab preservation, padding inside styled edges, and separate prompt geometry. Labels are session-wide presentation state: test cross-tab/read-only updates and unchanged-value redraw behavior without collecting real battery data or invoking render callbacks.
|
||||||
|
- Battery checks inject samples and refresh gates, use temporary Linux sysfs fixtures, and validate native value types/bounds without depending on the machine's charge or clock. Unit sessions do not automatically read hardware. Live smoke should compare the default indicator with OS telemetry and observe a production refresh; no-battery, explicit overrides, and disabled anchors must not fabricate values.
|
||||||
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
|
- Unix terminal-hook checks use isolated child processes and real PTYs to keep process-global hooks out of the parallel runner. They cover raw/alternate-screen/mouse-capture cleanup on entry failure, normal return, I/O errors, and panics during frame construction, including original panic-payload propagation.
|
||||||
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
|
- Redraw checks inspect actual Crossterm output during repeated scrolling: supporting terminals receive synchronized frames, terminals ignoring synchronization never paint with a visible hardware cursor, and the final caret remains visible. A protocol-state regression verifies that the terminal is not left in synchronized-output mode while the next frame is computed.
|
||||||
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
|
- Wheel checks render document/panel views and cover three-row scrolling, unchanged carets/selections/text, first/last-page clamping, offscreen cursor hiding, no-op/custom-bound navigation reveal, manual persistence through idle/resize/palette updates, and floating/unfocused-docked explorer selection and focus preservation. Queued-input checks verify that the next frame shows ordered wheel steps without crossing a key or resize boundary.
|
||||||
|
|||||||
+6
-6
@@ -51,22 +51,22 @@ Navigation carries the source `buffer_id`/`revision`, destination `path`/`row`/`
|
|||||||
`editor.label(name, text)` sets a session-wide named value initialized by `config.labels`. Place that name in a [top or bottom bar anchor group](configuration.md#top-and-bottom-bars) to display it. This presentation effect does not edit text, move carets, change undo history, or dismiss completion. It also works in read-only diff views.
|
`editor.label(name, text)` sets a session-wide named value initialized by `config.labels`. Place that name in a [top or bottom bar anchor group](configuration.md#top-and-bottom-bars) to display it. This presentation effect does not edit text, move carets, change undo history, or dismiss completion. It also works in read-only diff views.
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
Vamp.plugin "battery_label"
|
Vamp.plugin "build_label"
|
||||||
|
|
||||||
# @param editor [Vamp::Editor]
|
# @param editor [Vamp::Editor]
|
||||||
# @param args [String]
|
# @param args [String]
|
||||||
Vamp.command "battery" do |editor, args|
|
Vamp.command "build_label" do |editor, args|
|
||||||
editor.label("battery", args)
|
editor.label("build_status", args)
|
||||||
end
|
end
|
||||||
```
|
```
|
||||||
|
|
||||||
Load this plugin with `config.plugin`, then use `:battery bat 38%`; `:battery` clears the value and hides its segment. For asynchronous data, an `async: true` plugin's worker publishes a named value without a request ID:
|
Place `:build_status` in a bar and load this plugin with `config.plugin`, then use `:build_label passed`; `:build_label` clears the value and hides its segment. For asynchronous data, an `async: true` plugin's worker publishes a named value without a request ID:
|
||||||
|
|
||||||
```ruby
|
```ruby
|
||||||
Vamp.publish([{ type: "label", name: "battery", text: "bat 38%" }])
|
Vamp.publish([{ type: "label", name: "build_status", text: "passed" }])
|
||||||
```
|
```
|
||||||
|
|
||||||
Collect data and choose the refresh interval in the plugin; there is no builtin battery collection or render callback. Stop worker threads in `Vamp.on_shutdown`. Changed values redraw while idle and persist across tabs, without depending on the originating buffer or revision. Publishing an unchanged value does not request a redraw. The last value remains until overwritten or cleared, including after plugin retirement.
|
Collect custom data and choose its refresh interval in the plugin; the builtin `:battery` already refreshes from native OS data. There are no render callbacks. Stop worker threads in `Vamp.on_shutdown`. Changed values redraw while idle and persist across tabs, without depending on the originating buffer or revision. Publishing an unchanged value does not request a redraw. The last value remains until overwritten or cleared, including after plugin retirement.
|
||||||
|
|
||||||
Names match `[A-Za-z_][A-Za-z0-9_]*` and text must be a string. Control characters become replacement glyphs; an empty string clears the value. Updates share the existing 4 MiB protocol/text bounds; a rejected batch cannot partially change labels or other state.
|
Names match `[A-Za-z_][A-Za-z0-9_]*` and text must be a string. Control characters become replacement glyphs; an empty string clears the value. Updates share the existing 4 MiB protocol/text bounds; a rejected batch cannot partially change labels or other state.
|
||||||
|
|
||||||
|
|||||||
+1
-2
@@ -7,7 +7,7 @@ Vamp.configure do |config|
|
|||||||
config.file_explorer_side = "right"
|
config.file_explorer_side = "right"
|
||||||
config.file_explorer_view = "tree"
|
config.file_explorer_view = "tree"
|
||||||
config.file_picker_view = "tree"
|
config.file_picker_view = "tree"
|
||||||
# Status above, tabs below; either bar accepts all segments and style options.
|
# Status above, tabs and live battery below; either bar accepts all segments.
|
||||||
config.bar.top = {
|
config.bar.top = {
|
||||||
left: [:mode, :path, :language, :diagnostics, :selections, :count, :pending, :buffer],
|
left: [:mode, :path, :language, :diagnostics, :selections, :count, :pending, :buffer],
|
||||||
center: [], right: [:position],
|
center: [], right: [:position],
|
||||||
@@ -22,7 +22,6 @@ Vamp.configure do |config|
|
|||||||
},
|
},
|
||||||
battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" }
|
||||||
}
|
}
|
||||||
config.labels = { battery: "bat 38%" } # Static example; plugins update via editor.label(name, text)
|
|
||||||
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
config.bind "ctrl+j", config.core.down, mode: config.modes.selection
|
||||||
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
config.bind "ctrl+k", config.core.up, mode: config.modes.selection
|
||||||
config.plugin "plugins/greeting.rb"
|
config.plugin "plugins/greeting.rb"
|
||||||
|
|||||||
+542
@@ -0,0 +1,542 @@
|
|||||||
|
use std::{
|
||||||
|
io,
|
||||||
|
sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU8, Ordering},
|
||||||
|
mpsc::{self, Receiver, Sender},
|
||||||
|
},
|
||||||
|
thread::{self, JoinHandle},
|
||||||
|
};
|
||||||
|
#[cfg(not(test))]
|
||||||
|
use std::{sync::mpsc::RecvTimeoutError, time::Duration};
|
||||||
|
|
||||||
|
pub(crate) struct Battery {
|
||||||
|
results: Arc<AtomicU8>,
|
||||||
|
wake: Option<Sender<()>>,
|
||||||
|
thread: Option<JoinHandle<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Battery {
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub(crate) fn start() -> io::Result<Self> {
|
||||||
|
Self::start_with(read_charge, |wake| {
|
||||||
|
matches!(
|
||||||
|
wake.recv_timeout(Duration::from_secs(30)),
|
||||||
|
Ok(()) | Err(RecvTimeoutError::Timeout)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_with(
|
||||||
|
mut read: impl FnMut() -> Option<u8> + Send + 'static,
|
||||||
|
mut wait: impl FnMut(&Receiver<()>) -> bool + Send + 'static,
|
||||||
|
) -> io::Result<Self> {
|
||||||
|
let results = Arc::new(AtomicU8::new(0));
|
||||||
|
let latest = Arc::clone(&results);
|
||||||
|
let (wake, requests) = mpsc::channel();
|
||||||
|
let thread = thread::Builder::new()
|
||||||
|
.name("vamp-battery".into())
|
||||||
|
.spawn(move || {
|
||||||
|
loop {
|
||||||
|
let charge = read().filter(|charge| *charge <= 100);
|
||||||
|
// A one-slot latest-result mailbox: 0 = no new sample,
|
||||||
|
// 1 = no battery, 2..=102 = charge. Publishing never blocks.
|
||||||
|
latest.store(charge.map_or(1, |charge| charge + 2), Ordering::Relaxed);
|
||||||
|
if !wait(&requests) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
results,
|
||||||
|
wake: Some(wake),
|
||||||
|
thread: Some(thread),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn poll(&mut self) -> Option<Option<u8>> {
|
||||||
|
match self.results.swap(0, Ordering::Relaxed) {
|
||||||
|
0 => None,
|
||||||
|
1 => Some(None),
|
||||||
|
charge => Some(Some(charge - 2)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Battery {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// Disconnecting interrupts recv_timeout instead of waiting for its deadline.
|
||||||
|
self.wake.take();
|
||||||
|
if let Some(thread) = self.thread.take() {
|
||||||
|
let _ = thread.join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub(crate) fn read_charge() -> Option<u8> {
|
||||||
|
// ponytail: first internal battery on macOS/Linux; aggregate compatible capacities for multi-battery systems.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
return macos::read_charge();
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
return linux::read_charge(std::path::Path::new("/sys/class/power_supply"));
|
||||||
|
#[cfg(windows)]
|
||||||
|
return windows::read_charge();
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "linux", test))]
|
||||||
|
fn percentage(current: u64, full: u64) -> Option<u8> {
|
||||||
|
if full == 0 || current > full {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(((current as u128 * 100) / full as u128) as u8)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
mod macos {
|
||||||
|
use std::ffi::{CStr, c_char, c_void};
|
||||||
|
|
||||||
|
type Object = *const c_void;
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
#[link(name = "IOKit", kind = "framework")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn IOPSCopyPowerSourcesInfo() -> Object;
|
||||||
|
fn IOPSCopyPowerSourcesList(info: Object) -> Object;
|
||||||
|
fn IOPSGetPowerSourceDescription(info: Object, source: Object) -> Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[link(name = "CoreFoundation", kind = "framework")]
|
||||||
|
unsafe extern "C" {
|
||||||
|
fn CFRelease(object: Object);
|
||||||
|
fn CFGetTypeID(object: Object) -> usize;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFEqual(left: Object, right: Object) -> u8;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFArrayGetTypeID() -> usize;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFArrayGetCount(array: Object) -> isize;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFArrayGetValueAtIndex(array: Object, index: isize) -> Object;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFDictionaryGetTypeID() -> usize;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFDictionaryGetValue(dictionary: Object, key: Object) -> Object;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFStringGetTypeID() -> usize;
|
||||||
|
fn CFStringCreateWithCString(
|
||||||
|
allocator: Object,
|
||||||
|
string: *const c_char,
|
||||||
|
encoding: u32,
|
||||||
|
) -> Object;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFBooleanGetTypeID() -> usize;
|
||||||
|
#[cfg(not(test))]
|
||||||
|
fn CFBooleanGetValue(boolean: Object) -> u8;
|
||||||
|
fn CFNumberGetTypeID() -> usize;
|
||||||
|
fn CFNumberGetValue(number: Object, kind: isize, value: *mut c_void) -> u8;
|
||||||
|
#[cfg(test)]
|
||||||
|
fn CFNumberCreate(allocator: Object, kind: isize, value: Object) -> Object;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only Copy/Create results belong here; power source descriptions are borrowed.
|
||||||
|
struct Owned(Object);
|
||||||
|
|
||||||
|
impl Owned {
|
||||||
|
fn new(object: Object) -> Option<Self> {
|
||||||
|
(!object.is_null()).then(|| Self(object))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn string(value: &CStr) -> Option<Self> {
|
||||||
|
// kCFStringEncodingUTF8, from CFString.h; a null allocator is the default.
|
||||||
|
Self::new(unsafe {
|
||||||
|
CFStringCreateWithCString(std::ptr::null(), value.as_ptr(), 0x0800_0100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Owned {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
unsafe { CFRelease(self.0) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn is_type(object: Object, kind: usize) -> bool {
|
||||||
|
!object.is_null() && unsafe { CFGetTypeID(object) == kind }
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe fn number(object: Object) -> Option<u64> {
|
||||||
|
if !unsafe { is_type(object, CFNumberGetTypeID()) } {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut value: i64 = 0;
|
||||||
|
// kCFNumberSInt64Type = 4. A lossy/out-of-range conversion returns false.
|
||||||
|
let converted = unsafe { CFNumberGetValue(object, 4, (&mut value as *mut i64).cast()) };
|
||||||
|
(converted != 0).then_some(value)?.try_into().ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(test))]
|
||||||
|
pub(super) fn read_charge() -> Option<u8> {
|
||||||
|
// The opaque snapshot is only passed to IOPS accessors. All owned values
|
||||||
|
// are released on every return path, including failed type checks.
|
||||||
|
unsafe {
|
||||||
|
let info = Owned::new(IOPSCopyPowerSourcesInfo())?;
|
||||||
|
let sources = Owned::new(IOPSCopyPowerSourcesList(info.0))?;
|
||||||
|
if !is_type(sources.0, CFArrayGetTypeID()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let type_key = Owned::string(c"Type")?;
|
||||||
|
let internal = Owned::string(c"InternalBattery")?;
|
||||||
|
let present_key = Owned::string(c"Is Present")?;
|
||||||
|
let current_key = Owned::string(c"Current Capacity")?;
|
||||||
|
let full_key = Owned::string(c"Max Capacity")?;
|
||||||
|
for index in 0..CFArrayGetCount(sources.0) {
|
||||||
|
let source = CFArrayGetValueAtIndex(sources.0, index);
|
||||||
|
if source.is_null() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let description = IOPSGetPowerSourceDescription(info.0, source);
|
||||||
|
if !is_type(description, CFDictionaryGetTypeID()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let kind = CFDictionaryGetValue(description, type_key.0);
|
||||||
|
if !is_type(kind, CFStringGetTypeID()) || CFEqual(kind, internal.0) == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let present = CFDictionaryGetValue(description, present_key.0);
|
||||||
|
if !is_type(present, CFBooleanGetTypeID()) || CFBooleanGetValue(present) == 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let current = number(CFDictionaryGetValue(description, current_key.0))?;
|
||||||
|
let full = number(CFDictionaryGetValue(description, full_key.0))?;
|
||||||
|
return super::percentage(current, full);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_values_are_type_checked_and_numbers_must_be_exact_and_nonnegative() {
|
||||||
|
assert!(Owned::new(std::ptr::null()).is_none());
|
||||||
|
unsafe {
|
||||||
|
assert_eq!(number(std::ptr::null()), None);
|
||||||
|
let text = Owned::string(c"27").unwrap();
|
||||||
|
assert_eq!(number(text.0), None);
|
||||||
|
for value in [-1_i64, 0, 100] {
|
||||||
|
let object = Owned::new(CFNumberCreate(
|
||||||
|
std::ptr::null(),
|
||||||
|
4,
|
||||||
|
(&value as *const i64).cast(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(number(object.0), u64::try_from(value).ok());
|
||||||
|
}
|
||||||
|
let fraction = 1.5_f64;
|
||||||
|
let object = Owned::new(CFNumberCreate(
|
||||||
|
std::ptr::null(),
|
||||||
|
6,
|
||||||
|
(&fraction as *const f64).cast(),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(number(object.0), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "linux", test))]
|
||||||
|
mod linux {
|
||||||
|
use std::{fs, io, path::Path};
|
||||||
|
|
||||||
|
pub(super) fn read_charge(root: &Path) -> Option<u8> {
|
||||||
|
for entry in fs::read_dir(root).ok()? {
|
||||||
|
let path = entry.ok()?.path();
|
||||||
|
if fs::read_to_string(path.join("type"))
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
!= Some("Battery")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match fs::read_to_string(path.join("scope")) {
|
||||||
|
Ok(scope) if matches!(scope.trim(), "System" | "Unknown") => {}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
if fs::read_to_string(path.join("present"))
|
||||||
|
.ok()
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
!= Some("1")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return read_capacity(&path);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_capacity(path: &Path) -> Option<u8> {
|
||||||
|
match fs::read_to_string(path.join("capacity")) {
|
||||||
|
Ok(capacity) => {
|
||||||
|
return capacity
|
||||||
|
.trim()
|
||||||
|
.parse::<u8>()
|
||||||
|
.ok()
|
||||||
|
.filter(|value| *value <= 100);
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||||
|
Err(_) => return None,
|
||||||
|
}
|
||||||
|
// Never mix energy and charge units or use design capacity as full capacity.
|
||||||
|
let (current, full) = match fs::read_to_string(path.join("energy_now")) {
|
||||||
|
Ok(current) => (current, fs::read_to_string(path.join("energy_full")).ok()?),
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::NotFound => (
|
||||||
|
fs::read_to_string(path.join("charge_now")).ok()?,
|
||||||
|
fs::read_to_string(path.join("charge_full")).ok()?,
|
||||||
|
),
|
||||||
|
Err(_) => return None,
|
||||||
|
};
|
||||||
|
super::percentage(current.trim().parse().ok()?, full.trim().parse().ok()?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(any(windows, test))]
|
||||||
|
fn windows_charge(percent: u8, flags: u8) -> Option<u8> {
|
||||||
|
(percent <= 100 && flags & 128 == 0).then_some(percent)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(windows, not(test)))]
|
||||||
|
mod windows {
|
||||||
|
#[repr(C)]
|
||||||
|
struct SystemPowerStatus {
|
||||||
|
ac_line_status: u8,
|
||||||
|
battery_flag: u8,
|
||||||
|
battery_life_percent: u8,
|
||||||
|
system_status_flag: u8,
|
||||||
|
battery_life_time: u32,
|
||||||
|
battery_full_life_time: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[link(name = "kernel32")]
|
||||||
|
unsafe extern "system" {
|
||||||
|
fn GetSystemPowerStatus(status: *mut SystemPowerStatus) -> i32;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn read_charge() -> Option<u8> {
|
||||||
|
let mut status = std::mem::MaybeUninit::uninit();
|
||||||
|
if unsafe { GetSystemPowerStatus(status.as_mut_ptr()) } == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let status = unsafe { status.assume_init() };
|
||||||
|
super::windows_charge(status.battery_life_percent, status.battery_flag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::{
|
||||||
|
fs,
|
||||||
|
path::PathBuf,
|
||||||
|
sync::atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn percentages_reject_invalid_data_and_include_both_endpoints() {
|
||||||
|
assert_eq!(percentage(0, 100), Some(0));
|
||||||
|
assert_eq!(percentage(100, 100), Some(100));
|
||||||
|
assert_eq!(percentage(1, 3), Some(33));
|
||||||
|
assert_eq!(percentage(u64::MAX, u64::MAX), Some(100));
|
||||||
|
assert_eq!(percentage(0, 0), None);
|
||||||
|
assert_eq!(percentage(101, 100), None);
|
||||||
|
assert_eq!(windows_charge(0, 4), Some(0));
|
||||||
|
assert_eq!(windows_charge(100, 8), Some(100));
|
||||||
|
assert_eq!(windows_charge(101, 0), None);
|
||||||
|
assert_eq!(windows_charge(255, 0), None);
|
||||||
|
assert_eq!(windows_charge(50, 128), None);
|
||||||
|
assert_eq!(windows_charge(50, 255), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn worker(samples: Vec<Option<u8>>) -> (Battery, Receiver<()>, Arc<AtomicBool>) {
|
||||||
|
struct ReaderDropped(Arc<AtomicBool>);
|
||||||
|
impl Drop for ReaderDropped {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.0.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dropped = Arc::new(AtomicBool::new(false));
|
||||||
|
let reader = ReaderDropped(Arc::clone(&dropped));
|
||||||
|
let mut samples = samples.into_iter();
|
||||||
|
let (ready, published) = mpsc::channel();
|
||||||
|
let battery = Battery::start_with(
|
||||||
|
move || {
|
||||||
|
let _ = &reader;
|
||||||
|
samples
|
||||||
|
.next()
|
||||||
|
.expect("test provides every requested sample")
|
||||||
|
},
|
||||||
|
move |wake| {
|
||||||
|
ready.send(()).unwrap();
|
||||||
|
// Explicit channel messages replace the production clock entirely.
|
||||||
|
wake.recv().is_ok()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
(battery, published, dropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_reads_immediately_replaces_stale_samples_and_clears_invalid_data() {
|
||||||
|
let (mut battery, published, dropped) = worker(vec![Some(0), Some(100), None, Some(101)]);
|
||||||
|
published.recv().unwrap();
|
||||||
|
assert_eq!(battery.poll(), Some(Some(0)));
|
||||||
|
assert_eq!(battery.poll(), None);
|
||||||
|
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||||
|
published.recv().unwrap();
|
||||||
|
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||||
|
published.recv().unwrap();
|
||||||
|
assert_eq!(battery.poll(), Some(None));
|
||||||
|
assert_eq!(battery.poll(), None);
|
||||||
|
battery.wake.as_ref().unwrap().send(()).unwrap();
|
||||||
|
published.recv().unwrap();
|
||||||
|
assert_eq!(battery.poll(), Some(None));
|
||||||
|
drop(battery);
|
||||||
|
assert!(dropped.load(Ordering::Acquire));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dropping_interrupts_wait_and_joins_even_with_an_unread_sample() {
|
||||||
|
let (battery, published, dropped) = worker(vec![Some(50)]);
|
||||||
|
published.recv().unwrap();
|
||||||
|
drop(battery);
|
||||||
|
assert!(dropped.load(Ordering::Acquire));
|
||||||
|
}
|
||||||
|
|
||||||
|
static FIXTURE_ID: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
struct Fixture(PathBuf);
|
||||||
|
|
||||||
|
impl Fixture {
|
||||||
|
fn new() -> Self {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"vamp-battery-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
FIXTURE_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
fs::create_dir(&path).unwrap();
|
||||||
|
Self(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn source(&self, name: &str, fields: &[(&str, &str)]) {
|
||||||
|
let path = self.0.join(name);
|
||||||
|
fs::create_dir_all(&path).unwrap();
|
||||||
|
for (key, value) in fields {
|
||||||
|
fs::write(path.join(key), value).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self) -> Option<u8> {
|
||||||
|
linux::read_charge(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Fixture {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
fs::remove_dir_all(&self.0).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sysfs_reads_present_system_batteries_but_not_external_or_absent_sources() {
|
||||||
|
let fixture = Fixture::new();
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
fixture.source("AC", &[("type", "Mains"), ("capacity", "100")]);
|
||||||
|
fixture.source(
|
||||||
|
"mouse",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("scope", "Device"),
|
||||||
|
("present", "1"),
|
||||||
|
("capacity", "100"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
fixture.source(
|
||||||
|
"absent",
|
||||||
|
&[("type", "Battery"), ("present", "0"), ("capacity", "100")],
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
fixture.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery\n"),
|
||||||
|
("scope", "System\n"),
|
||||||
|
("present", "1\n"),
|
||||||
|
("capacity", "27\n"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(fixture.read(), Some(27));
|
||||||
|
fixture.source("BAT0", &[("capacity", "0")]);
|
||||||
|
assert_eq!(fixture.read(), Some(0));
|
||||||
|
fixture.source("BAT0", &[("capacity", "100")]);
|
||||||
|
assert_eq!(fixture.read(), Some(100));
|
||||||
|
for invalid in ["101", "-1", "255", "garbage", ""] {
|
||||||
|
fixture.source("BAT0", &[("capacity", invalid)]);
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
}
|
||||||
|
fs::remove_file(fixture.0.join("BAT0/capacity")).unwrap();
|
||||||
|
assert_eq!(fixture.read(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sysfs_uses_matching_actual_energy_or_charge_totals_only() {
|
||||||
|
let energy = Fixture::new();
|
||||||
|
energy.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("present", "1"),
|
||||||
|
("energy_now", "27"),
|
||||||
|
("energy_full", "100"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(energy.read(), Some(27));
|
||||||
|
energy.source("BAT0", &[("energy_full", "0")]);
|
||||||
|
assert_eq!(energy.read(), None);
|
||||||
|
energy.source("BAT0", &[("energy_full", "26")]);
|
||||||
|
assert_eq!(energy.read(), None);
|
||||||
|
energy.source("BAT0", &[("energy_full", "garbage")]);
|
||||||
|
assert_eq!(energy.read(), None);
|
||||||
|
let charge = Fixture::new();
|
||||||
|
charge.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("present", "1"),
|
||||||
|
("charge_now", "15"),
|
||||||
|
("charge_full", "20"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(charge.read(), Some(75));
|
||||||
|
charge.source("BAT0", &[("energy_now", "5")]);
|
||||||
|
assert_eq!(charge.read(), None);
|
||||||
|
let design = Fixture::new();
|
||||||
|
design.source(
|
||||||
|
"BAT0",
|
||||||
|
&[
|
||||||
|
("type", "Battery"),
|
||||||
|
("present", "1"),
|
||||||
|
("energy_now", "20"),
|
||||||
|
("energy_full_design", "100"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(design.read(), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
+7
-3
@@ -47,8 +47,11 @@ impl ChromeLine {
|
|||||||
Self {
|
Self {
|
||||||
left: vec!["tabs".into()],
|
left: vec!["tabs".into()],
|
||||||
center: Vec::new(),
|
center: Vec::new(),
|
||||||
right: Vec::new(),
|
right: vec!["battery".into()],
|
||||||
segments: BTreeMap::from([("tabs".into(), default_segment("tabs"))]),
|
segments: BTreeMap::from([
|
||||||
|
("tabs".into(), default_segment("tabs")),
|
||||||
|
("battery".into(), default_segment("battery")),
|
||||||
|
]),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,7 +117,7 @@ impl ChromeLine {
|
|||||||
|
|
||||||
fn default_segment(name: &str) -> SegmentConfig {
|
fn default_segment(name: &str) -> SegmentConfig {
|
||||||
let mut segment = SegmentConfig::default();
|
let mut segment = SegmentConfig::default();
|
||||||
if matches!(name, "tabs" | "position") {
|
if matches!(name, "tabs" | "position" | "battery") {
|
||||||
segment.leading_edge = "░▒▓".into();
|
segment.leading_edge = "░▒▓".into();
|
||||||
}
|
}
|
||||||
if matches!(name, "tabs" | "mode") {
|
if matches!(name, "tabs" | "mode") {
|
||||||
@@ -291,6 +294,7 @@ mod tests {
|
|||||||
"count",
|
"count",
|
||||||
"language",
|
"language",
|
||||||
"position",
|
"position",
|
||||||
|
"battery",
|
||||||
"workspace",
|
"workspace",
|
||||||
];
|
];
|
||||||
let options = serde_json::json!({
|
let options = serde_json::json!({
|
||||||
|
|||||||
@@ -1354,6 +1354,8 @@ pub struct Editor {
|
|||||||
top_bar: ChromeLine,
|
top_bar: ChromeLine,
|
||||||
bottom_bar: ChromeLine,
|
bottom_bar: ChromeLine,
|
||||||
labels: BTreeMap<String, String>,
|
labels: BTreeMap<String, String>,
|
||||||
|
battery_charge: Option<u8>,
|
||||||
|
battery_label: String,
|
||||||
quit: bool,
|
quit: bool,
|
||||||
capabilities: Vec<String>,
|
capabilities: Vec<String>,
|
||||||
features: HashMap<String, Feature>,
|
features: HashMap<String, Feature>,
|
||||||
@@ -1442,6 +1444,8 @@ impl Editor {
|
|||||||
top_bar: ChromeLine::top(),
|
top_bar: ChromeLine::top(),
|
||||||
bottom_bar: ChromeLine::bottom(),
|
bottom_bar: ChromeLine::bottom(),
|
||||||
labels: BTreeMap::new(),
|
labels: BTreeMap::new(),
|
||||||
|
battery_charge: None,
|
||||||
|
battery_label: String::new(),
|
||||||
quit: false,
|
quit: false,
|
||||||
capabilities,
|
capabilities,
|
||||||
features: HashMap::new(),
|
features: HashMap::new(),
|
||||||
@@ -3840,6 +3844,9 @@ impl Editor {
|
|||||||
pub fn label(&self, name: &str) -> &str {
|
pub fn label(&self, name: &str) -> &str {
|
||||||
self.labels.get(name).map_or("", String::as_str)
|
self.labels.get(name).map_or("", String::as_str)
|
||||||
}
|
}
|
||||||
|
pub fn battery_label(&self) -> &str {
|
||||||
|
&self.battery_label
|
||||||
|
}
|
||||||
pub fn path(&self) -> Option<&Path> {
|
pub fn path(&self) -> Option<&Path> {
|
||||||
self.active.path.as_deref()
|
self.active.path.as_deref()
|
||||||
}
|
}
|
||||||
@@ -3921,6 +3928,20 @@ impl Editor {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn set_battery_charge(&mut self, charge: Option<u8>) -> bool {
|
||||||
|
let charge = charge.filter(|charge| *charge <= 100);
|
||||||
|
if self.battery_charge == charge {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
self.battery_charge = charge;
|
||||||
|
self.battery_label.clear();
|
||||||
|
if let Some(charge) = charge {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
write!(self.battery_label, "bat {charge}%").unwrap();
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub fn register(&mut self, feature: Feature) -> Result<(), String> {
|
pub fn register(&mut self, feature: Feature) -> Result<(), String> {
|
||||||
self.install(feature, false)
|
self.install(feature, false)
|
||||||
}
|
}
|
||||||
@@ -17816,6 +17837,54 @@ mod tests {
|
|||||||
assert_eq!(editor.text_revision(), 0);
|
assert_eq!(editor.text_revision(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_battery_cache_validates_changes_without_editing_or_replacing_labels() {
|
||||||
|
let mut editor = editor();
|
||||||
|
editor.handle(Input::Char('i'));
|
||||||
|
editor.handle(Input::Paste("document".into()));
|
||||||
|
editor.handle(Input::Escape);
|
||||||
|
editor.handle(Input::Char('v'));
|
||||||
|
let text = editor.context().text;
|
||||||
|
let selection = editor.selections();
|
||||||
|
let revision = editor.text_revision();
|
||||||
|
let history = (editor.active.undo.len(), editor.active.redo.len());
|
||||||
|
assert_eq!(editor.battery_label(), "");
|
||||||
|
assert!(!editor.set_battery_charge(None));
|
||||||
|
assert!(!editor.set_battery_charge(Some(101)));
|
||||||
|
assert!(editor.set_battery_charge(Some(27)));
|
||||||
|
assert_eq!(editor.battery_label(), "bat 27%");
|
||||||
|
assert!(!editor.set_battery_charge(Some(27)));
|
||||||
|
assert_eq!(editor.label("battery"), "");
|
||||||
|
|
||||||
|
editor.set_label("battery".into(), "custom".into()).unwrap();
|
||||||
|
assert!(editor.set_battery_charge(Some(0)));
|
||||||
|
assert_eq!(editor.battery_label(), "bat 0%");
|
||||||
|
assert_eq!(editor.label("battery"), "custom");
|
||||||
|
assert!(editor.set_battery_charge(Some(100)));
|
||||||
|
assert_eq!(editor.battery_label(), "bat 100%");
|
||||||
|
assert!(editor.set_battery_charge(Some(255)));
|
||||||
|
assert_eq!(editor.battery_label(), "");
|
||||||
|
assert!(!editor.set_battery_charge(None));
|
||||||
|
assert_eq!(editor.label("battery"), "custom");
|
||||||
|
assert_eq!(editor.context().text, text);
|
||||||
|
assert_eq!(editor.selections(), selection);
|
||||||
|
assert_eq!(editor.mode(), Mode::Visual);
|
||||||
|
assert_eq!(editor.text_revision(), revision);
|
||||||
|
assert_eq!(
|
||||||
|
(editor.active.undo.len(), editor.active.redo.len()),
|
||||||
|
history
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(editor.set_battery_charge(Some(28)));
|
||||||
|
editor.execute("tab.new");
|
||||||
|
assert_eq!(editor.battery_label(), "bat 28%");
|
||||||
|
editor.set_label("battery".into(), String::new()).unwrap();
|
||||||
|
assert_eq!(editor.battery_label(), "bat 28%");
|
||||||
|
assert_eq!(editor.label("battery"), "");
|
||||||
|
assert!(editor.set_battery_charge(None));
|
||||||
|
assert_eq!(editor.battery_label(), "");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn named_labels_sanitize_and_survive_tabs_until_independently_cleared() {
|
fn named_labels_sanitize_and_survive_tabs_until_independently_cleared() {
|
||||||
let mut editor = editor();
|
let mut editor = editor();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod battery;
|
||||||
pub mod chrome;
|
pub mod chrome;
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
pub mod diff;
|
pub mod diff;
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ pub struct EditorSession {
|
|||||||
pending_completion: bool,
|
pending_completion: bool,
|
||||||
hover_owner: Option<String>,
|
hover_owner: Option<String>,
|
||||||
diff_workers: BTreeMap<usize, DiffWorker>,
|
diff_workers: BTreeMap<usize, DiffWorker>,
|
||||||
|
battery: Option<crate::battery::Battery>,
|
||||||
|
battery_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditorSession {
|
impl EditorSession {
|
||||||
@@ -267,8 +269,11 @@ impl EditorSession {
|
|||||||
pending_completion: false,
|
pending_completion: false,
|
||||||
hover_owner: None,
|
hover_owner: None,
|
||||||
diff_workers: BTreeMap::new(),
|
diff_workers: BTreeMap::new(),
|
||||||
|
battery: None,
|
||||||
|
battery_enabled: false,
|
||||||
};
|
};
|
||||||
session.synchronize_documents();
|
session.synchronize_documents();
|
||||||
|
session.poll_battery();
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,9 +931,46 @@ impl EditorSession {
|
|||||||
Ok(changed)
|
Ok(changed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn poll_battery(&mut self) -> bool {
|
||||||
|
let enabled = self.editor.label("battery").is_empty()
|
||||||
|
&& [
|
||||||
|
self.editor.top_bar_config(),
|
||||||
|
self.editor.bottom_bar_config(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.any(|bar| {
|
||||||
|
bar.left
|
||||||
|
.iter()
|
||||||
|
.chain(&bar.center)
|
||||||
|
.chain(&bar.right)
|
||||||
|
.any(|name| name == "battery")
|
||||||
|
});
|
||||||
|
let mut changed = false;
|
||||||
|
if enabled != self.battery_enabled {
|
||||||
|
self.battery_enabled = enabled;
|
||||||
|
// Unit sessions do not read external hardware; provider and renderer checks inject data.
|
||||||
|
#[cfg(not(test))]
|
||||||
|
{
|
||||||
|
self.battery = if enabled {
|
||||||
|
crate::battery::Battery::start().ok()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if !enabled {
|
||||||
|
changed |= self.editor.set_battery_charge(None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(charge) = self.battery.as_mut().and_then(|battery| battery.poll()) {
|
||||||
|
changed |= self.editor.set_battery_charge(charge);
|
||||||
|
}
|
||||||
|
changed
|
||||||
|
}
|
||||||
|
|
||||||
/// Drains ready plugin work without waiting for command or server responses.
|
/// Drains ready plugin work without waiting for command or server responses.
|
||||||
pub fn poll(&mut self) -> bool {
|
pub fn poll(&mut self) -> bool {
|
||||||
let mut changed = self.synchronize_documents();
|
let mut changed = self.synchronize_documents();
|
||||||
|
changed |= self.poll_battery();
|
||||||
changed |= self.poll_diff();
|
changed |= self.poll_diff();
|
||||||
for update in self.lsp.poll() {
|
for update in self.lsp.poll() {
|
||||||
match self.apply_update("core", update) {
|
match self.apply_update("core", update) {
|
||||||
|
|||||||
+69
-24
@@ -1173,6 +1173,7 @@ fn chrome_items<'a>(
|
|||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
}),
|
}),
|
||||||
|
"battery" if editor.label(name).is_empty() => Cow::Borrowed(editor.battery_label()),
|
||||||
_ => Cow::Borrowed(editor.label(name)),
|
_ => Cow::Borrowed(editor.label(name)),
|
||||||
};
|
};
|
||||||
// Empty values remain hidden even when a title or decoration is configured.
|
// Empty values remain hidden even when a title or decoration is configured.
|
||||||
@@ -5463,22 +5464,6 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn battery_chrome(editor: &mut Editor) {
|
|
||||||
configure_chrome(editor, |top, _| {
|
|
||||||
top.right = vec!["battery".into()];
|
|
||||||
top.segments.insert(
|
|
||||||
"battery".into(),
|
|
||||||
SegmentConfig {
|
|
||||||
title: String::new(),
|
|
||||||
padding: 1,
|
|
||||||
leading_edge: "░▒▓".into(),
|
|
||||||
trailing_edge: String::new(),
|
|
||||||
first_leading_edge: None,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tab_titles_update_templates_without_reinterpreting_file_names() {
|
fn tab_titles_update_templates_without_reinterpreting_file_names() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
@@ -6126,8 +6111,7 @@ mod tests {
|
|||||||
"edit {}",
|
"edit {}",
|
||||||
directory.file("active.txt", "ACTIVE").display()
|
directory.file("active.txt", "ACTIVE").display()
|
||||||
));
|
));
|
||||||
battery_chrome(&mut editor);
|
editor.set_battery_charge(Some(27));
|
||||||
editor.set_label("battery".into(), "status".into()).unwrap();
|
|
||||||
let mut views = BufferViewports::default();
|
let mut views = BufferViewports::default();
|
||||||
for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] {
|
for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] {
|
||||||
let mut terminal =
|
let mut terminal =
|
||||||
@@ -6138,7 +6122,7 @@ mod tests {
|
|||||||
if width > 0 && height >= 3 {
|
if width > 0 && height >= 3 {
|
||||||
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
|
let title = pane_screen(&terminal, Rect::new(0, 0, width, 1));
|
||||||
assert!(title.trim_start().starts_with('3'), "{title}");
|
assert!(title.trim_start().starts_with('3'), "{title}");
|
||||||
assert!(!title.contains("status"), "{title}");
|
assert!(!title.contains("bat 27%"), "{title}");
|
||||||
}
|
}
|
||||||
if width > 0 && height == 1 {
|
if width > 0 && height == 1 {
|
||||||
let (_, viewport) = views.document.geometry.unwrap();
|
let (_, viewport) = views.document.geometry.unwrap();
|
||||||
@@ -6159,11 +6143,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn singleton_custom_value_sanitizes_unicode_and_redraws_through_clear() {
|
fn default_native_battery_and_custom_override_redraw_through_clear() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
let mut editor =
|
let mut editor =
|
||||||
Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap();
|
Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap();
|
||||||
battery_chrome(&mut editor);
|
|
||||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(80, 12)).unwrap();
|
||||||
let mut views = BufferViewports::default();
|
let mut views = BufferViewports::default();
|
||||||
terminal
|
terminal
|
||||||
@@ -6171,6 +6154,15 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let original = screen_text(&terminal);
|
let original = screen_text(&terminal);
|
||||||
assert_eq!(views.document.geometry.unwrap().1.y, 0);
|
assert_eq!(views.document.geometry.unwrap().1.y, 0);
|
||||||
|
assert_eq!(editor.tab_count(), 1);
|
||||||
|
assert!(!screen_text(&terminal).contains("bat "));
|
||||||
|
assert!(editor.set_battery_charge(Some(27)));
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
|
.unwrap();
|
||||||
|
let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
|
||||||
|
assert!(title.ends_with("░▒▓ bat 27% "), "{title}");
|
||||||
|
assert_eq!(views.document.geometry.unwrap().1.y, 1);
|
||||||
|
|
||||||
editor
|
editor
|
||||||
.set_label("battery".into(), "電池 e\u{301}\t90%".into())
|
.set_label("battery".into(), "電池 e\u{301}\t90%".into())
|
||||||
@@ -6182,6 +6174,7 @@ mod tests {
|
|||||||
assert!(title.contains("1 source.txt"), "{title}");
|
assert!(title.contains("1 source.txt"), "{title}");
|
||||||
assert!(title.contains("電") && title.contains("池"), "{title}");
|
assert!(title.contains("電") && title.contains("池"), "{title}");
|
||||||
assert!(title.contains("e\u{301}�90%"), "{title}");
|
assert!(title.contains("e\u{301}�90%"), "{title}");
|
||||||
|
assert!(!title.contains("bat 27%"), "{title}");
|
||||||
let start = 80 - (editor.label("battery").width() + 5) as u16;
|
let start = 80 - (editor.label("battery").width() + 5) as u16;
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
for (offset, glyph) in [(0, "░"), (1, "▒"), (2, "▓"), (3, " ")] {
|
for (offset, glyph) in [(0, "░"), (1, "▒"), (2, "▓"), (3, " ")] {
|
||||||
@@ -6194,12 +6187,21 @@ mod tests {
|
|||||||
assert!(terminal.get_cursor_position().unwrap().y >= 1);
|
assert!(terminal.get_cursor_position().unwrap().y >= 1);
|
||||||
|
|
||||||
editor.set_label("battery".into(), "live".into()).unwrap();
|
editor.set_label("battery".into(), "live".into()).unwrap();
|
||||||
|
assert!(editor.set_battery_charge(Some(28)));
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| views.draw(frame, &mut editor))
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).ends_with("░▒▓ live "));
|
assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).ends_with("░▒▓ live "));
|
||||||
assert!(!screen_text(&terminal).contains("90%"));
|
assert!(!screen_text(&terminal).contains("90%"));
|
||||||
editor.set_label("battery".into(), String::new()).unwrap();
|
editor.set_label("battery".into(), String::new()).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
|
.unwrap();
|
||||||
|
let title = pane_screen(&terminal, Rect::new(0, 0, 80, 1));
|
||||||
|
assert!(title.ends_with("░▒▓ bat 28% "), "{title}");
|
||||||
|
assert!(!title.contains("live"), "{title}");
|
||||||
|
assert_eq!(views.document.geometry.unwrap().1.y, 1);
|
||||||
|
assert!(editor.set_battery_charge(None));
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| views.draw(frame, &mut editor))
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -6207,6 +6209,52 @@ mod tests {
|
|||||||
assert_eq!(screen_text(&terminal), original);
|
assert_eq!(screen_text(&terminal), original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn native_battery_renders_at_each_configured_anchor_in_either_bar() {
|
||||||
|
let mut editor = Editor::open(None, vec![]).unwrap();
|
||||||
|
editor.set_battery_charge(Some(27));
|
||||||
|
for at_top in [true, false] {
|
||||||
|
for anchor in 0..3 {
|
||||||
|
configure_chrome(&mut editor, |top, bottom| {
|
||||||
|
for line in [&mut *top, &mut *bottom] {
|
||||||
|
line.left.clear();
|
||||||
|
line.center.clear();
|
||||||
|
line.right.clear();
|
||||||
|
}
|
||||||
|
let line = if at_top { top } else { bottom };
|
||||||
|
[&mut line.left, &mut line.center, &mut line.right][anchor]
|
||||||
|
.push("battery".into());
|
||||||
|
});
|
||||||
|
let mut terminal =
|
||||||
|
Terminal::new(ratatui::backend::TestBackend::new(48, 6)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
draw_bars(frame, &editor, frame.area(), &mut SyntaxCache::new());
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let y = if at_top { 0 } else { 4 };
|
||||||
|
let row = pane_screen(&terminal, Rect::new(0, y, 48, 1));
|
||||||
|
assert_eq!(row.trim(), "░▒▓ bat 27%");
|
||||||
|
let x = [0, 18, 36][anchor];
|
||||||
|
assert_eq!(terminal.backend().buffer()[(x, y)].symbol(), "░");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
configure_chrome(&mut editor, |top, bottom| {
|
||||||
|
top.right.clear();
|
||||||
|
bottom.right.clear();
|
||||||
|
});
|
||||||
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(48, 6)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| {
|
||||||
|
let [workspace, _] =
|
||||||
|
draw_bars(frame, &editor, frame.area(), &mut SyntaxCache::new());
|
||||||
|
assert_eq!(workspace.y, 0);
|
||||||
|
assert_eq!(workspace.height, 5);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(!screen_text(&terminal).contains("bat 27%"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn custom_value_keeps_a_gap_and_yields_to_the_full_active_title() {
|
fn custom_value_keeps_a_gap_and_yields_to_the_full_active_title() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
@@ -6221,7 +6269,6 @@ mod tests {
|
|||||||
"edit {}",
|
"edit {}",
|
||||||
directory.file("active.txt", "ACTIVE").display()
|
directory.file("active.txt", "ACTIVE").display()
|
||||||
));
|
));
|
||||||
battery_chrome(&mut editor);
|
|
||||||
editor.set_label("battery".into(), "90%".into()).unwrap();
|
editor.set_label("battery".into(), "90%".into()).unwrap();
|
||||||
for width in [28, 29, 80] {
|
for width in [28, 29, 80] {
|
||||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap();
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap();
|
||||||
@@ -6267,7 +6314,6 @@ mod tests {
|
|||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
let mut editor =
|
let mut editor =
|
||||||
Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap();
|
Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap();
|
||||||
battery_chrome(&mut editor);
|
|
||||||
editor.execute("pane.split_vertical");
|
editor.execute("pane.split_vertical");
|
||||||
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
|
let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap();
|
||||||
let mut views = BufferViewports::default();
|
let mut views = BufferViewports::default();
|
||||||
@@ -6295,7 +6341,6 @@ mod tests {
|
|||||||
assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title);
|
assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title);
|
||||||
|
|
||||||
let mut editor = readonly_diff_editor(deletion_file());
|
let mut editor = readonly_diff_editor(deletion_file());
|
||||||
battery_chrome(&mut editor);
|
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| views.draw(frame, &mut editor))
|
.draw(|frame| views.draw(frame, &mut editor))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user