diff --git a/docs/architecture.md b/docs/architecture.md index c4c9497..aae25e1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. - `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. 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 --git a/docs/configuration.md b/docs/configuration.md index 3b2d356..dd6d45c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. -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 Vamp.configure do |config| @@ -68,21 +68,22 @@ Vamp.configure do |config| }, battery: { padding: 1, leading_edge: "░▒▓", trailing_edge: "" } } - config.labels = { battery: "bat 38%" } 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. 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 diff --git a/docs/development.md b/docs/development.md index e472030..b46aad8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. - 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. +- 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. - 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. diff --git a/docs/plugins.md b/docs/plugins.md index f68effd..8070462 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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. ```ruby -Vamp.plugin "battery_label" +Vamp.plugin "build_label" # @param editor [Vamp::Editor] # @param args [String] -Vamp.command "battery" do |editor, args| - editor.label("battery", args) +Vamp.command "build_label" do |editor, args| + editor.label("build_status", args) 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 -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. diff --git a/examples/config.rb b/examples/config.rb index bd1fed5..956de1b 100644 --- a/examples/config.rb +++ b/examples/config.rb @@ -7,7 +7,7 @@ Vamp.configure do |config| config.file_explorer_side = "right" config.file_explorer_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 = { left: [:mode, :path, :language, :diagnostics, :selections, :count, :pending, :buffer], center: [], right: [:position], @@ -22,7 +22,6 @@ Vamp.configure do |config| }, 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+k", config.core.up, mode: config.modes.selection config.plugin "plugins/greeting.rb" diff --git a/src/battery.rs b/src/battery.rs new file mode 100644 index 0000000..5fcfcf5 --- /dev/null +++ b/src/battery.rs @@ -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, + wake: Option>, + thread: Option>, +} + +impl Battery { + #[cfg(not(test))] + pub(crate) fn start() -> io::Result { + 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 + Send + 'static, + mut wait: impl FnMut(&Receiver<()>) -> bool + Send + 'static, + ) -> io::Result { + 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> { + 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 { + // 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 { + 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 { + (!object.is_null()).then(|| Self(object)) + } + + fn string(value: &CStr) -> Option { + // 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 { + 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 { + // 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 { + 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 { + match fs::read_to_string(path.join("capacity")) { + Ok(capacity) => { + return capacity + .trim() + .parse::() + .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 { + (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 { + 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>) -> (Battery, Receiver<()>, Arc) { + struct ReaderDropped(Arc); + 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 { + 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); + } +} diff --git a/src/chrome.rs b/src/chrome.rs index c0620fe..774a2fe 100644 --- a/src/chrome.rs +++ b/src/chrome.rs @@ -47,8 +47,11 @@ impl ChromeLine { Self { left: vec!["tabs".into()], center: Vec::new(), - right: Vec::new(), - segments: BTreeMap::from([("tabs".into(), default_segment("tabs"))]), + right: vec!["battery".into()], + 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 { let mut segment = SegmentConfig::default(); - if matches!(name, "tabs" | "position") { + if matches!(name, "tabs" | "position" | "battery") { segment.leading_edge = "░▒▓".into(); } if matches!(name, "tabs" | "mode") { @@ -291,6 +294,7 @@ mod tests { "count", "language", "position", + "battery", "workspace", ]; let options = serde_json::json!({ diff --git a/src/editor.rs b/src/editor.rs index 4698615..94c1e5a 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -1354,6 +1354,8 @@ pub struct Editor { top_bar: ChromeLine, bottom_bar: ChromeLine, labels: BTreeMap, + battery_charge: Option, + battery_label: String, quit: bool, capabilities: Vec, features: HashMap, @@ -1442,6 +1444,8 @@ impl Editor { top_bar: ChromeLine::top(), bottom_bar: ChromeLine::bottom(), labels: BTreeMap::new(), + battery_charge: None, + battery_label: String::new(), quit: false, capabilities, features: HashMap::new(), @@ -3840,6 +3844,9 @@ impl Editor { pub fn label(&self, name: &str) -> &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> { self.active.path.as_deref() } @@ -3921,6 +3928,20 @@ impl Editor { Ok(true) } + pub(crate) fn set_battery_charge(&mut self, charge: Option) -> 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> { self.install(feature, false) } @@ -17816,6 +17837,54 @@ mod tests { 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] fn named_labels_sanitize_and_survive_tabs_until_independently_cleared() { let mut editor = editor(); diff --git a/src/lib.rs b/src/lib.rs index 0345084..aa1672c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +mod battery; pub mod chrome; pub mod clipboard; pub mod diff; diff --git a/src/session.rs b/src/session.rs index b33405b..4c09f20 100644 --- a/src/session.rs +++ b/src/session.rs @@ -170,6 +170,8 @@ pub struct EditorSession { pending_completion: bool, hover_owner: Option, diff_workers: BTreeMap, + battery: Option, + battery_enabled: bool, } impl EditorSession { @@ -267,8 +269,11 @@ impl EditorSession { pending_completion: false, hover_owner: None, diff_workers: BTreeMap::new(), + battery: None, + battery_enabled: false, }; session.synchronize_documents(); + session.poll_battery(); Ok(session) } @@ -926,9 +931,46 @@ impl EditorSession { 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. pub fn poll(&mut self) -> bool { let mut changed = self.synchronize_documents(); + changed |= self.poll_battery(); changed |= self.poll_diff(); for update in self.lsp.poll() { match self.apply_update("core", update) { diff --git a/src/tui.rs b/src/tui.rs index e556db3..ad328bd 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1173,6 +1173,7 @@ fn chrome_items<'a>( } else { String::new() }), + "battery" if editor.label(name).is_empty() => Cow::Borrowed(editor.battery_label()), _ => Cow::Borrowed(editor.label(name)), }; // Empty values remain hidden even when a title or decoration is configured. @@ -5463,22 +5464,6 @@ mod tests { .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] fn tab_titles_update_templates_without_reinterpreting_file_names() { let directory = Directory::new(); @@ -6126,8 +6111,7 @@ mod tests { "edit {}", directory.file("active.txt", "ACTIVE").display() )); - battery_chrome(&mut editor); - editor.set_label("battery".into(), "status".into()).unwrap(); + editor.set_battery_charge(Some(27)); let mut views = BufferViewports::default(); for (width, height) in [(12, 9), (7, 9), (1, 9), (0, 9), (12, 1), (12, 0)] { let mut terminal = @@ -6138,7 +6122,7 @@ mod tests { if width > 0 && height >= 3 { let title = pane_screen(&terminal, Rect::new(0, 0, width, 1)); assert!(title.trim_start().starts_with('3'), "{title}"); - assert!(!title.contains("status"), "{title}"); + assert!(!title.contains("bat 27%"), "{title}"); } if width > 0 && height == 1 { let (_, viewport) = views.document.geometry.unwrap(); @@ -6159,11 +6143,10 @@ mod tests { } #[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 mut editor = 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 views = BufferViewports::default(); terminal @@ -6171,6 +6154,15 @@ mod tests { .unwrap(); let original = screen_text(&terminal); 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 .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("電") && title.contains("池"), "{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 buffer = terminal.backend().buffer(); for (offset, glyph) in [(0, "░"), (1, "▒"), (2, "▓"), (3, " ")] { @@ -6194,12 +6187,21 @@ mod tests { assert!(terminal.get_cursor_position().unwrap().y >= 1); editor.set_label("battery".into(), "live".into()).unwrap(); + assert!(editor.set_battery_charge(Some(28))); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); assert!(pane_screen(&terminal, Rect::new(0, 0, 80, 1)).ends_with("░▒▓ live ")); assert!(!screen_text(&terminal).contains("90%")); 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 .draw(|frame| views.draw(frame, &mut editor)) .unwrap(); @@ -6207,6 +6209,52 @@ mod tests { 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] fn custom_value_keeps_a_gap_and_yields_to_the_full_active_title() { let directory = Directory::new(); @@ -6221,7 +6269,6 @@ mod tests { "edit {}", directory.file("active.txt", "ACTIVE").display() )); - battery_chrome(&mut editor); editor.set_label("battery".into(), "90%".into()).unwrap(); for width in [28, 29, 80] { let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(width, 2)).unwrap(); @@ -6267,7 +6314,6 @@ mod tests { let directory = Directory::new(); let mut editor = Editor::open(Some(directory.file("source.txt", "DOCUMENT")), vec![]).unwrap(); - battery_chrome(&mut editor); editor.execute("pane.split_vertical"); let mut terminal = Terminal::new(ratatui::backend::TestBackend::new(120, 30)).unwrap(); let mut views = BufferViewports::default(); @@ -6295,7 +6341,6 @@ mod tests { assert_eq!(pane_screen(&terminal, Rect::new(0, 0, 120, 1)), title); let mut editor = readonly_diff_editor(deletion_file()); - battery_chrome(&mut editor); terminal .draw(|frame| views.draw(frame, &mut editor)) .unwrap();