diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dae9b0e..9d45461 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,6 +21,8 @@ jobs: - uses: actions/checkout@v6 with: submodules: true + # git-cliff reads the tags and commits since the previous release. + fetch-depth: 0 - uses: dtolnay/rust-toolchain@nightly with: components: rust-src @@ -39,8 +41,15 @@ jobs: # Git Bash on Windows prints " *"; write the text-mode form the app verifies. printf '%s %s\n' "$(sha256sum "$asset" | cut -d ' ' -f 1)" "$asset" > "$asset.sha256" cat "$asset.sha256" + - name: Write the release notes + uses: orhun/git-cliff-action@v4 + with: + args: --current --strip header + env: + OUTPUT: release-notes.md - uses: softprops/action-gh-release@v3 with: + body_path: release-notes.md files: | enableLoopback-${{ matrix.target }}.exe enableLoopback-${{ matrix.target }}.exe.sha256 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..25efab4 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +Generated from the commit history by [git-cliff](https://git-cliff.org). + +## Unreleased + +- License the project under MIT ([a68bbb1](https://github.com/libnyanpasu/enable-loopback/commit/a68bbb15b9667dafabcacd728e62996cd1bd205f)) +- Center the window on the monitor under the cursor ([be9e97f](https://github.com/libnyanpasu/enable-loopback/commit/be9e97fcbeb869f0f9e74b667c289c597a48f07d)) +- Start the Binaries column at 200 DIP ([4a23be3](https://github.com/libnyanpasu/enable-loopback/commit/4a23be33a9e6fca898f25331ddfd3bc816498582)) +- Scroll the table in its list so both scroll bars stay reachable ([b676214](https://github.com/libnyanpasu/enable-loopback/commit/b6762145a0785cc526fbec5aa8c47d56266d2369)) +- Let rows follow a column resize while dragging ([3191842](https://github.com/libnyanpasu/enable-loopback/commit/31918424f02562b8366b7bd50302fc4ad601f31d)) +- Keep the table's scroll position when its rows are replaced ([f6adeec](https://github.com/libnyanpasu/enable-loopback/commit/f6adeec373fb328cc191c83ea0c22b76e2afa822)) +- Build table rows only near the list's viewport ([5174e44](https://github.com/libnyanpasu/enable-loopback/commit/5174e443aa44b3f7ca69e3d7048ae98fef82f354)) +- Create cell tooltips only for the hovered row ([21a257a](https://github.com/libnyanpasu/enable-loopback/commit/21a257af34f4552f15d1eb9d42be4bc3a741311b)) + +## [0.1.3](https://github.com/libnyanpasu/enable-loopback/releases/tag/v0.1.3) - 2026-10-21 + +- Link the C runtime statically ([7fdcd62](https://github.com/libnyanpasu/enable-loopback/commit/7fdcd62d260f51c008e3df630f9d377d69144522)) +- Offer to install Windows App Runtime from the Microsoft Store ([ab92f4e](https://github.com/libnyanpasu/enable-loopback/commit/ab92f4e88df86b4d3b28c8508129611f8bd57c92)) +- Opt the executable into the segment heap ([1a8de54](https://github.com/libnyanpasu/enable-loopback/commit/1a8de54613f35964f942563510c49f3048f75878)) + +## [0.1.2](https://github.com/libnyanpasu/enable-loopback/releases/tag/v0.1.2) - 2026-10-21 + +- Perf reduce release executable size ([#3](https://github.com/libnyanpasu/enable-loopback/pull/3)) ([71f9cf3](https://github.com/libnyanpasu/enable-loopback/commit/71f9cf3ecf8c6bf93029ad0a0b4b1bb54fb54729)) + +## [0.1.1](https://github.com/libnyanpasu/enable-loopback/releases/tag/v0.1.1) - 2026-10-20 + +- Open the row context menu on right-button release ([71c28de](https://github.com/libnyanpasu/enable-loopback/commit/71c28de866e1eb7869ccf9bad3ebde943d86363b)) + +## [0.1.0](https://github.com/libnyanpasu/enable-loopback/releases/tag/v0.1.0) - 2026-10-20 + +- Scaffold the localized WinUI loopback tool ([4ddb6d0](https://github.com/libnyanpasu/enable-loopback/commit/4ddb6d08b428ef2c9367c6c79fa19f2adb1c958e)) +- Check formatting, Clippy, and tests on Windows CI ([f463b63](https://github.com/libnyanpasu/enable-loopback/commit/f463b637202807d146f2e796389fcaed9c3713b2)) +- Implement owned NetIso FFI and registry enumeration ([b2e28d3](https://github.com/libnyanpasu/enable-loopback/commit/b2e28d30c796afe708d7615030f1a13fccaaed5e)) +- Implement pure list rendering and exemption rules ([dfb38bd](https://github.com/libnyanpasu/enable-loopback/commit/dfb38bd8af0d4b02b2201ea2ea325ca6226dbc80)) +- Add SID-based list selection state ([a727edb](https://github.com/libnyanpasu/enable-loopback/commit/a727edb8bed4418d581b7db7ad5c9d4afecd8487)) +- Execute CLI exemption commands without WinAppSDK ([69d3c7b](https://github.com/libnyanpasu/enable-loopback/commit/69d3c7be905fbbf0904d9e37fee97b0ce219eb38)) +- Enforce GUI single instance and publish its window handle ([b5e6603](https://github.com/libnyanpasu/enable-loopback/commit/b5e66035a075d0c777467c550e5a9e4fc0c4cccd)) +- Build the AppContainer list and asynchronous refresh flow ([89cb09f](https://github.com/libnyanpasu/enable-loopback/commit/89cb09f47789987635bd44fe4171bc1b3b05cfc4)) +- Wire row selection and context menu clipboard actions ([92c0e69](https://github.com/libnyanpasu/enable-loopback/commit/92c0e69ad6e1ce3abb4820c988dc6bec8929913b)) +- Save checked exemptions without losing failed edits ([8a30beb](https://github.com/libnyanpasu/enable-loopback/commit/8a30beb81576e7737ac3ff990d86c3ffdd89f95a)) +- Finish close confirmation, keyboard shortcuts and help ([f408ba7](https://github.com/libnyanpasu/enable-loopback/commit/f408ba7b34d4fc6b142bf7bf7b016d54aa38048b)) +- Publish x64 and arm64 executables with checksums on tags ([88633e9](https://github.com/libnyanpasu/enable-loopback/commit/88633e9d85b320a8671dfc57aa95a5f2567af093)) +- Build against the vendored libnyanpasu windows-rs fork ([725bd80](https://github.com/libnyanpasu/enable-loopback/commit/725bd80be02c1520b29f90aec54fa00d6fb5df80)) +- Rework the window around a TableView component with search ([94fad55](https://github.com/libnyanpasu/enable-loopback/commit/94fad55c5e1734e8d64515f03448f4dc9a361d49)) +- Mark the design as implemented ([9f87f6a](https://github.com/libnyanpasu/enable-loopback/commit/9f87f6aa86cf76b61d311ccae19c67f90821857e)) + diff --git a/README.md b/README.md index 0561528..1feb756 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,9 @@ Run tests with `cargo test`; only the GUI executable embeds the elevation manife The vendored windows-rs fork is a separate workspace. Check application formatting with `cargo fmt -p enable-loopback -- --check`; `cargo fmt --all` recursively visits the fork too and can exceed Windows' command-line limit. + +To release, set the version in `crates/enable-loopback/Cargo.toml`, regenerate +[CHANGELOG.md](CHANGELOG.md) with [git-cliff](https://git-cliff.org) as +`git cliff --tag vX.Y.Z -o CHANGELOG.md`, commit both with `Cargo.lock` as +`Release X.Y.Z`, and push the `vX.Y.Z` tag. The release workflow builds the +executables and uses the version's changelog section as the release notes. diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..e517d40 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,37 @@ +# git-cliff configuration: https://git-cliff.org/docs/configuration +# Regenerate CHANGELOG.md with `git cliff -o CHANGELOG.md` (add `--tag vX.Y.Z` before tagging). + +[changelog] +header = """ +# Changelog + +Generated from the commit history by [git-cliff](https://git-cliff.org). +""" +body = """ +{% if version %}\ +## [{{ version | trim_start_matches(pat="v") }}](https://github.com/libnyanpasu/enable-loopback/releases/tag/{{ version }}) - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ +## Unreleased +{% endif %} +{% for commit in commits %}\ +- {{ commit.message | split(pat="\n") | first | trim }} \ +([{{ commit.id | truncate(length=7, end="") }}](https://github.com/libnyanpasu/enable-loopback/commit/{{ commit.id }})) +{% endfor %} +""" +trim = false +postprocessors = [ + { pattern = '\(#([0-9]+)\)', replace = "([#${1}](https://github.com/libnyanpasu/enable-loopback/pull/${1}))" }, +] + +[git] +# Subjects are imperative sentences, not Conventional Commits. +conventional_commits = false +filter_unconventional = false +commit_parsers = [ + { message = "^Release \\d", skip = true }, + { message = "^Merge (pull request|branch)", skip = true }, + # The application commits that use a fork change describe it. + { message = "^Bump windows-rs fork", skip = true }, +] +tag_pattern = "v[0-9].*" +sort_commits = "oldest" diff --git a/crates/enable-loopback/Cargo.toml b/crates/enable-loopback/Cargo.toml index 3f832ba..9078153 100644 --- a/crates/enable-loopback/Cargo.toml +++ b/crates/enable-loopback/Cargo.toml @@ -17,7 +17,7 @@ nyanpasu-windows-dependencies = { git = "https://github.com/libnyanpasu/nyanpasu snafu = "0.8" sys-locale = "0.3" windows-reactor = { path = "../../vendor/windows-rs/crates/libs/reactor" } -windows = { path = "../../vendor/windows-rs/crates/libs/windows", features = ["libloaderapi", "debugapi", "winuser", "shellapi", "commctrl", "winerror", "winnt", "netfw", "sddl", "secext", "securitybaseapi", "shlwapi", "winbase", "heapapi", "winreg", "errhandlingapi", "processenv", "synchapi", "memoryapi", "handleapi", "processthreadsapi", "datetimeapi", "sysinfoapi"] } +windows = { path = "../../vendor/windows-rs/crates/libs/windows", features = ["libloaderapi", "debugapi", "winuser", "shellscalingapi", "shellapi", "commctrl", "winerror", "winnt", "netfw", "sddl", "secext", "securitybaseapi", "shlwapi", "winbase", "heapapi", "winreg", "errhandlingapi", "processenv", "synchapi", "memoryapi", "handleapi", "processthreadsapi", "datetimeapi", "sysinfoapi"] } [build-dependencies] resvg = { version = "0.45", default-features = false } diff --git a/crates/enable-loopback/src/model/columns.rs b/crates/enable-loopback/src/model/columns.rs index 14b8b07..6c017e5 100644 --- a/crates/enable-loopback/src/model/columns.rs +++ b/crates/enable-loopback/src/model/columns.rs @@ -13,7 +13,7 @@ pub struct ColumnWidths { impl Default for ColumnWidths { fn default() -> Self { Self::new( - [220.0, 220.0, 200.0, 160.0, 140.0, 140.0, MIN_COLUMN_WIDTH], + [220.0, 220.0, 200.0, 160.0, 140.0, 140.0, 200.0], [MIN_COLUMN_WIDTH; 7], ) } @@ -112,12 +112,12 @@ mod tests { } #[test] - fn total_and_fill_preserve_fixed_columns_and_binaries_minimum() { + fn total_and_fill_preserve_stored_widths() { let widths = ColumnWidths::default(); - assert_eq!(widths.total(), 1168.0); + assert_eq!(widths.total(), 1320.0); assert_eq!( widths.resolved(900.0), - [40.0, 220.0, 220.0, 200.0, 160.0, 140.0, 140.0, 48.0] + [40.0, 220.0, 220.0, 200.0, 160.0, 140.0, 140.0, 200.0] ); assert_eq!(widths.resolved(1400.0)[7], 280.0); let mut resized = widths; diff --git a/crates/enable-loopback/src/ui/mod.rs b/crates/enable-loopback/src/ui/mod.rs index d6d82c4..326f121 100644 --- a/crates/enable-loopback/src/ui/mod.rs +++ b/crates/enable-loopback/src/ui/mod.rs @@ -33,6 +33,8 @@ use windows::{ use windows_reactor::*; const PAGE_MARGIN: f64 = 24.0; +const WINDOW_WIDTH: f64 = 960.0; +const WINDOW_HEIGHT: f64 = 600.0; const SECTION_SPACING: f64 = 12.0; const SEARCH_WIDTH: f64 = 280.0; const TOOLBAR_WRAP_WIDTH: f64 = 900.0; @@ -83,6 +85,8 @@ struct MainWindow { search_focused: bool, selection: Selection, hwnd: usize, + /// Read only from the first declaration; None keeps the system default. + initial_position: Option, close: native::CloseState, hovering_help: bool, version: String, @@ -312,7 +316,7 @@ impl MainWindow { "column.users", "column.binaries", ]; - let widths = [220.0, 220.0, 200.0, 160.0, 140.0, 140.0, 48.0]; + let widths = [220.0, 220.0, 200.0, 160.0, 140.0, 140.0, 200.0]; component::( "table", table::Input { @@ -534,7 +538,7 @@ impl Component for MainWindow { dialog_timer: None, menu_timer: None, list_generation: 0, - viewport_width: 960.0 - 2.0 * PAGE_MARGIN, + viewport_width: WINDOW_WIDTH - 2.0 * PAGE_MARGIN, table_rows: Rc::new(Vec::new()), keyboard_focus: false, app: input.app.clone(), @@ -543,6 +547,9 @@ impl Component for MainWindow { search_focused: false, selection: Selection::default(), hwnd: 0, + initial_position: native::centered_position(WINDOW_WIDTH, WINDOW_HEIGHT) + .inspect_err(|error| crate::debug(&error.to_string())) + .ok(), close, hovering_help: false, version: native::version().unwrap_or_else(|error| { @@ -824,16 +831,18 @@ impl Component for MainWindow { } fn view(&self, _: &Startup, cx: &mut ViewContext) -> View { cx.window_title(rust_i18n::t!("window.title")); - cx.window_visuals( - WindowVisuals::new() - .backdrop(WindowBackdrop::Mica) - .client_size(960.0, 600.0) - .constraints(WindowConstraints { - min_width: Some(640.0), - min_height: Some(400.0), - ..Default::default() - }), - ); + let mut visuals = WindowVisuals::new() + .backdrop(WindowBackdrop::Mica) + .client_size(WINDOW_WIDTH, WINDOW_HEIGHT) + .constraints(WindowConstraints { + min_width: Some(640.0), + min_height: Some(400.0), + ..Default::default() + }); + if let Some(position) = self.initial_position { + visuals = visuals.initial_position(position); + } + cx.window_visuals(visuals); cx.on_window_size(cx.callback(Message::Size)); let mut status: Vec = Vec::new(); if self.list.refreshing { diff --git a/crates/enable-loopback/src/ui/native.rs b/crates/enable-loopback/src/ui/native.rs index cc44ec2..2cf1efb 100644 --- a/crates/enable-loopback/src/ui/native.rs +++ b/crates/enable-loopback/src/ui/native.rs @@ -130,6 +130,66 @@ pub(super) fn screen_point( Ok(windows_reactor::ScreenPoint::new(point.x, point.y)) } +/// Top-left that centers a window with this DIP client size in the work area of the +/// monitor under the cursor, where the user launched it. ScreenPoint is in physical pixels. +pub(super) fn centered_position( + client_width: f64, + client_height: f64, +) -> Result { + use windows::Win32::{ + POINT, RECT, + shellscalingapi::{GetDpiForMonitor, MDT_EFFECTIVE_DPI}, + winuser::{ + AdjustWindowRectExForDpi, GetCursorPos, GetMonitorInfoW, MONITOR_DEFAULTTONEAREST, + MONITORINFO, MonitorFromPoint, WS_OVERLAPPEDWINDOW, + }, + }; + let mut cursor = POINT::default(); + // SAFETY: writable POINT. + if !unsafe { GetCursorPos(&mut cursor) }.as_bool() { + return Err(failure("GetCursorPos")); + } + // SAFETY: NEAREST always returns a monitor. + let monitor = unsafe { MonitorFromPoint(cursor, MONITOR_DEFAULTTONEAREST as u32) }; + let mut info = MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + // SAFETY: live monitor handle and a sized MONITORINFO. + if !unsafe { GetMonitorInfoW(monitor, &mut info) }.as_bool() { + return Err(failure("GetMonitorInfoW")); + } + let (mut dpi, mut unused) = (0, 0); + // SAFETY: live monitor handle and writable outputs. + unsafe { GetDpiForMonitor(monitor, MDT_EFFECTIVE_DPI, &mut dpi, &mut unused) } + .ok() + .map_err(|source| Error::Native { + operation: "GetDpiForMonitor", + source, + })?; + let scale = f64::from(dpi) / 96.0; + let mut outer = RECT { + left: 0, + top: 0, + right: (client_width * scale).round() as i32, + bottom: (client_height * scale).round() as i32, + }; + // SAFETY: writable RECT; the style matches WinUI's overlapped window frame. + if !unsafe { AdjustWindowRectExForDpi(&mut outer, WS_OVERLAPPEDWINDOW as u32, false, 0, dpi) } + .as_bool() + { + return Err(failure("AdjustWindowRectExForDpi")); + } + let work = info.rcWork; + let width = outer.right - outer.left; + let height = outer.bottom - outer.top; + Ok(windows_reactor::ScreenPoint::new( + work.left + (work.right - work.left - width) / 2, + // A window taller than the work area keeps its caption reachable. + (work.top + (work.bottom - work.top - height) / 2).max(work.top), + )) +} + /// All four flags are shared only by UI-thread code. Rc makes accidental Send impossible. #[derive(Clone, Default)] pub(super) struct CloseState { diff --git a/crates/enable-loopback/src/ui/table/mod.rs b/crates/enable-loopback/src/ui/table/mod.rs index 5756532..3268822 100644 --- a/crates/enable-loopback/src/ui/table/mod.rs +++ b/crates/enable-loopback/src/ui/table/mod.rs @@ -12,6 +12,8 @@ use std::{ use windows_reactor::*; const SORT_GLYPH_SIZE: f64 = 10.0; +/// Rows have a fixed height, so a vertical offset maps to row indices. +const ROW_HEIGHT: f64 = 40.0; pub(super) const COLUMNS: [Column; 7] = [ Column::DisplayName, @@ -71,18 +73,54 @@ pub(super) struct TableView { row_widths: ColumnWidths, drag: Option, resize_timer: Option, + /// Coalesces row relayouts during a drag: all rows cost about two header updates. + rows_timer: Option, focus_timer: Option, list_ref: ElementRef, row_refs: BTreeMap>, checkbox_focus: Rc>, restore_focus: bool, + /// The list's horizontal scroll offset, which the header follows. + scroll_x: f64, + /// The list's vertical offset; read only when the rows are replaced, so no render. + scroll_y: Rc>, + /// Offsets for the next generation's ListView, which starts unscrolled. + restore: Option<(f64, f64)>, + /// Rows and offsets from before the rows were emptied in place (a refresh, or a + /// search without results), which also scrolls the list back to the start. + held: Option, } +/// Rows with the horizontal and vertical offsets they were shown at. +type HeldView = (Rc>>, f64, f64); + struct Drag { column: Column, pointer_id: u32, origin: f64, width: f64, + /// Widening the last column at the right end keeps that end, and the grip, in view. + pin_end: bool, +} + +/// The vertical offset that keeps an anchor row where it was on screen: the focused row +/// when it survives, otherwise the first surviving row from the top of the old view. +fn anchored_offset(old: &[Rc], new: &[Rc], focused: Option<&str>, offset: f64) -> f64 { + let positions: BTreeMap<&str, usize> = new + .iter() + .enumerate() + .map(|(index, row)| (row.key.as_str(), index)) + .collect(); + let survivor = + |(index, row): (usize, &Rc)| positions.get(row.key.as_str()).map(|&new| (index, new)); + let top = (offset / ROW_HEIGHT).floor().max(0.0) as usize; + let anchor = focused + .and_then(|key| old.iter().position(|row| row.key == key)) + .and_then(|index| survivor((index, &old[index]))) + .or_else(|| old.iter().enumerate().skip(top).find_map(survivor)); + anchor.map_or(0.0, |(old, new)| { + (offset + (new as f64 - old as f64) * ROW_HEIGHT).max(0.0) + }) } pub(super) enum Message { @@ -92,11 +130,13 @@ pub(super) enum Message { ResizeEnd(PointerEventInfo), ResizeLost, FinishResize, + FollowResize, Pointer((String, Column, PointerEventInfo)), Context((String, Column, PointerEventInfo)), ContextPoint(String, Column, Result), Scrolled(u64, String, bool, Result), FocusReady(u64, String), + HorizontalOffset(f64), } impl TableView { @@ -105,9 +145,18 @@ impl TableView { } fn commit_resize(&mut self) { - self.drag = None; + let pin_end = self.drag.take().is_some_and(|drag| drag.pin_end); self.resize_timer = None; + self.rows_timer = None; + self.follow_resize(pin_end); + } + + fn follow_resize(&mut self, pin_end: bool) { self.row_widths = self.widths; + if pin_end { + let end = self.content_width(self.row_widths) - (self.input.viewport_width - 2.0); + let _ = self.list_ref.request_scroll_to(Some(end.max(0.0)), None); + } } fn rows(&self, cx: &ViewContext) -> ListView { @@ -115,34 +164,63 @@ impl TableView { ListView::new() .grid_row(1) .automation_id("apps") + .horizontal_scroll(true) + .on_horizontal_offset_changed(cx.callback(Message::HorizontalOffset)) + .on_vertical_offset_changed({ + let scroll_y = self.scroll_y.clone(); + move |offset| scroll_y.set(offset) + }) .element_ref(&self.list_ref) .is_enabled(input.enabled) .selection_mode(ListViewSelectionMode::None) .item_container(ItemContainerOptions { padding: Some(Thickness::uniform(0.0)), - min_height: Some(40.0), + min_height: Some(ROW_HEIGHT), horizontal_content_alignment: Some(HorizontalAlignment::Stretch), suppress_selection_visuals: true, }) - .items(input.rows.iter().map(|item| { - DataItem::new(item.key.clone(), &item.cells[0]).content(component::( - item.key.clone(), - row::Input { - row: item.clone(), - widths: self.row_widths, - width: self.content_width(self.row_widths), - selected: input.selected.contains(&item.key), - focused: input.focused.as_ref() == Some(&item.key), - keyboard_focus: input.keyboard_focus, - focus_ref: self.row_refs[&item.key].clone(), - checkbox_focus: self.checkbox_focus.clone(), - on_toggle: input.on_toggle.clone(), - on_pointer: cx.callback(Message::Pointer), - on_context: cx.callback(Message::Context), - on_focus: input.on_focus.clone(), + // Only rows the list prepares containers for are built. A generation's keys are + // fixed, except that a refresh empties the rows in place, which changes the length. + .virtual_source({ + let rows = input.rows.clone(); + let keys = input.rows.clone(); + let selected = input.selected.clone(); + let focused = input.focused.clone(); + let keyboard_focus = input.keyboard_focus; + let widths = self.row_widths; + let width = self.content_width(self.row_widths); + let row_refs = self.row_refs.clone(); + let checkbox_focus = self.checkbox_focus.clone(); + let on_toggle = input.on_toggle.clone(); + let on_pointer = cx.callback(Message::Pointer); + let on_context = cx.callback(Message::Context); + let on_focus = input.on_focus.clone(); + VirtualSource::new( + input.generation, + input.rows.len(), + move |index| keys[index].key.clone(), + move |index| { + let item = &rows[index]; + component::( + item.key.clone(), + row::Input { + row: item.clone(), + widths, + width, + selected: selected.contains(&item.key), + focused: focused.as_ref() == Some(&item.key), + keyboard_focus, + focus_ref: row_refs[&item.key].clone(), + checkbox_focus: checkbox_focus.clone(), + on_toggle: on_toggle.clone(), + on_pointer: on_pointer.clone(), + on_context: on_context.clone(), + on_focus: on_focus.clone(), + }, + ) }, - )) - })) + ) + }) } fn headers(&self, cx: &ViewContext) -> Border { @@ -268,6 +346,7 @@ impl Component for TableView { row_widths: widths, drag: None, resize_timer: None, + rows_timer: None, focus_timer: None, list_ref: ElementRef::new(), row_refs: input @@ -277,6 +356,10 @@ impl Component for TableView { .collect(), checkbox_focus: Rc::new(Cell::new(false)), restore_focus: true, + scroll_x: 0.0, + scroll_y: Rc::new(Cell::new(0.0)), + restore: None, + held: None, } } @@ -291,8 +374,23 @@ impl Component for TableView { self.commit_resize(); } self.restore_focus = input.generation != self.input.generation; + if input.rows.is_empty() && !self.input.rows.is_empty() { + self.held = Some((self.input.rows.clone(), self.scroll_x, self.scroll_y.get())); + } if input.generation != self.input.generation { self.checkbox_focus.set(false); + let (rows, horizontal, vertical) = match self.held.take() { + Some(held) if self.input.rows.is_empty() => held, + _ => (self.input.rows.clone(), self.scroll_x, self.scroll_y.get()), + }; + if input.rows.is_empty() { + self.held = Some((rows, horizontal, vertical)); + self.restore = None; + } else { + let vertical = + anchored_offset(&rows, &input.rows, input.focused.as_deref(), vertical); + self.restore = Some((horizontal, vertical)); + } } if input.generation != self.input.generation || input.focused != self.input.focused { self.focus_timer = None; @@ -325,12 +423,14 @@ impl Component for TableView { && event.capture_succeeded == Some(true) => { self.resize_timer = None; + let end = self.content_width(self.widths) - (self.input.viewport_width - 2.0); self.drag = Some(Drag { column, pointer_id: event.pointer_id, origin: event.window_x, width: self.widths.resolved(self.content_width(self.widths)) [column as usize + 1], + pin_end: column == COLUMNS[COLUMNS.len() - 1] && self.scroll_x >= end - 1.0, }); } Message::ResizeMove(event) | Message::ResizeEnd(event) => { @@ -341,6 +441,11 @@ impl Component for TableView { .drag(drag.column, drag.width, event.window_x - drag.origin); if !event.is_left_button_pressed { self.commit_resize(); + } else if self.rows_timer.is_none() { + self.rows_timer = + Some(cx.set_local_timeout(Duration::from_millis(16), || { + Message::FollowResize + })); } } } @@ -350,6 +455,11 @@ impl Component for TableView { Some(cx.set_local_timeout(Duration::from_millis(1), || Message::FinishResize)); } Message::FinishResize => self.commit_resize(), + Message::FollowResize => { + self.rows_timer = None; + let pin_end = self.drag.as_ref().is_some_and(|drag| drag.pin_end); + self.follow_resize(pin_end); + } Message::Pointer((key, column, event)) if self.input.enabled && self.drag.is_none() => { self.input.on_pointer.call((key, column, event)); } @@ -390,6 +500,7 @@ impl Component for TableView { Message::Scrolled(_, _, _, Err(error)) => { crate::debug(&format!("Table scroll request failed: {error:?}")) } + Message::HorizontalOffset(offset) => self.scroll_x = offset, _ => {} } } @@ -404,6 +515,15 @@ impl Component for TableView { .and_then(|key| input.rows.iter().position(|row| &row.key == key)); let enabled = input.enabled && !self.checkbox_focus.get(); let restore_focus = input.restore_focus && (self.restore_focus || input.keyboard_focus); + // The replacement ListView keeps the anchor row where it was on screen. + let restore = self.restore; + let restore_reference = self.list_ref.clone(); + cx.use_effect("restore", generation, move || { + if let Some((horizontal, vertical)) = restore { + let _ = restore_reference.request_scroll_to(Some(horizontal), Some(vertical)); + } + None + }); cx.use_effect( "reveal", (generation, focused.clone(), enabled), @@ -428,19 +548,27 @@ impl Component for TableView { let on_key = input.on_key.clone(); let check_focus = self.checkbox_focus.clone(); let enabled = input.enabled; + // The list's own ScrollViewer scrolls both ways, keeping its vertical scroll bar + // at the card edge and horizontal wheels on the horizontal axis. The header + // follows its horizontal offset inside a non-scrolling ScrollViewer, which clips. let mut content: Vec = vec![ - ScrollViewer::new() - .horizontal_scroll_bar_visibility(ScrollBarVisibility::Auto) - .vertical_scroll_bar_visibility(ScrollBarVisibility::Disabled) - .content( - Grid::new() - .width(self.content_width(self.widths)) - .rows([GridLength::Auto, GridLength::STAR]) - .keyed_children([ - keyed("headers", self.headers(cx)), - keyed(input.generation, self.rows(cx)), - ]), - ) + Grid::new() + .rows([GridLength::Auto, GridLength::STAR]) + .keyed_children([ + keyed( + "headers", + ScrollViewer::new() + .horizontal_scroll_bar_visibility(ScrollBarVisibility::Disabled) + .vertical_scroll_bar_visibility(ScrollBarVisibility::Disabled) + .content( + self.headers(cx) + .width(self.content_width(self.widths)) + .horizontal_alignment(HorizontalAlignment::Left) + .margin(Thickness::new(-self.scroll_x, 0.0, 0.0, 0.0)), + ), + ), + keyed(input.generation, self.rows(cx)), + ]) .into(), ]; if let Some(text) = &input.empty_text { @@ -487,3 +615,44 @@ impl Component for TableView { .into() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn rows(keys: &[&str]) -> Vec> { + keys.iter() + .map(|key| { + Rc::new(Row { + key: (*key).to_owned(), + checked: false, + checkbox_label: String::new(), + cells: Default::default(), + highlight: false, + }) + }) + .collect() + } + + #[test] + fn anchor_keeps_focused_or_top_surviving_row_on_screen() { + let old = rows(&["a", "b", "c", "d", "e", "f"]); + // Top row "c" (index 2) is 10 DIP scrolled off; it moves to index 0. + let filtered = rows(&["c", "e", "f"]); + assert_eq!(anchored_offset(&old, &filtered, None, 90.0), 10.0); + // A vanished top row falls through to the next survivor, "e" (70 DIP below the + // top), which cannot stay that low at index 1, so the offset clamps to zero. + let without_c = rows(&["a", "e", "f"]); + assert_eq!(anchored_offset(&old, &without_c, None, 90.0), 0.0); + // With room above it, "e" keeps its 70 DIP: index 5 * 40 - 70. + let shifted = rows(&["v", "w", "x", "y", "z", "e"]); + assert_eq!(anchored_offset(&old, &shifted, None, 90.0), 130.0); + // The focused row "e" (index 4, 70 DIP below the top) keeps its screen position. + let reversed = rows(&["f", "e", "d", "c", "b", "a"]); + assert_eq!(anchored_offset(&old, &reversed, Some("e"), 90.0), 0.0); + assert_eq!(anchored_offset(&old, &reversed, Some("a"), 90.0), 290.0); + // No survivor below the top falls back to the start. + assert_eq!(anchored_offset(&old, &rows(&["a"]), None, 90.0), 0.0); + assert_eq!(anchored_offset(&old, &old, Some("zzz"), 90.0), 90.0); + } +} diff --git a/crates/enable-loopback/src/ui/table/row.rs b/crates/enable-loopback/src/ui/table/row.rs index e05478c..f563bc1 100644 --- a/crates/enable-loopback/src/ui/table/row.rs +++ b/crates/enable-loopback/src/ui/table/row.rs @@ -141,13 +141,16 @@ impl Component for TableRow { } else { label.into() }; - cells.push( - Border::new() - .grid_column(index as i32 + 1) - .padding(Thickness::xy(12.0, 0.0)) - .content(content) - .tooltip(text), - ); + let cell = Border::new() + .grid_column(index as i32 + 1) + .padding(Thickness::xy(12.0, 0.0)) + .content(content); + // Only the hovered row carries tooltips, so rows do not each hold seven. + cells.push(if self.hovered { + cell.tooltip(text) + } else { + cell.into() + }); } let fill: Brush = if input.selected { ThemeBrush::SubtleFillTertiary.into() @@ -163,8 +166,10 @@ impl Component for TableRow { .is_tab_stop(true) .focus_on_pointer_release(true) .automation_id(format!("row_{}", input.row.key)) + // The list names its item after the row's view. + .automation_name(&input.row.cells[0]) .width(input.width) - .height(40.0) + .height(super::ROW_HEIGHT) .background(Color::transparent()) .border_brush(ThemeBrush::DividerStroke) .border_thickness(Thickness::new(0.0, 0.0, 0.0, 1.0)) diff --git a/crates/enable-loopback/src/ui/tests.rs b/crates/enable-loopback/src/ui/tests.rs index 0e37428..ab5cb2c 100644 --- a/crates/enable-loopback/src/ui/tests.rs +++ b/crates/enable-loopback/src/ui/tests.rs @@ -66,15 +66,36 @@ impl Fixture { _primary: primary, } } - fn send(&mut self, message: Message) { - assert!(self.sender.send(message)); - self.host.drain(100).unwrap(); + /// Drains messages and timers, then prepares every row like a list showing them all. + fn settle(&mut self, budget: usize) { + self.host.drain(budget).unwrap(); loop { let timer = self.services.timers.lock().unwrap().pop_front(); let Some((_, callback)) = timer else { break }; callback(); - self.host.drain(100).unwrap(); + self.host.drain(budget).unwrap(); + } + let graph = self.host.runtime().graph(); + let Some(list) = find(graph, graph.root().unwrap(), "apps") else { + return; + }; + let count = graph.virtual_item_count(list).unwrap(); + let source_revision = graph.virtual_source_revision(list).unwrap(); + for index in 0..count { + self.host + .test_adapter_mut() + .queue_realization(RealizationRequest::Realize { + collection: list, + container: RealizedContainer(index as u64), + index, + source_revision, + }); } + self.host.drain(budget.max(count * 10)).unwrap(); + } + fn send(&mut self, message: Message) { + assert!(self.sender.send(message)); + self.settle(100); } fn send_table(&mut self, message: table::Message) { let sender = self @@ -82,13 +103,7 @@ impl Fixture { .sender_at::(&[Key::from("main"), Key::from("table")]) .unwrap(); assert!(sender.send(message)); - self.host.drain(1000).unwrap(); - loop { - let timer = self.services.timers.lock().unwrap().pop_front(); - let Some((_, callback)) = timer else { break }; - callback(); - self.host.drain(1000).unwrap(); - } + self.settle(1000); } fn emit(&mut self, id: &str, event: EventId, payload: EventPayload) { let graph = self.host.runtime().graph(); @@ -104,13 +119,7 @@ impl Fixture { self.host .test_adapter_mut() .queue_event(EventDispatch::new(object, event, callback, payload)); - self.host.drain(1000).unwrap(); - loop { - let timer = self.services.timers.lock().unwrap().pop_front(); - let Some((_, callback)) = timer else { break }; - callback(); - self.host.drain(1000).unwrap(); - } + self.settle(1000); } fn close_dialog(&mut self, result: ContentDialogResult) { self.send(Message::DialogClosed(result)); @@ -173,6 +182,7 @@ fn find(graph: &RetainedGraph, root: ObjectId, id: &str) -> Option { RelationId::PrimaryCommands, RelationId::SecondaryCommands, RelationId::Items, + RelationId::VirtualItems, ] { if let Some(child) = graph.child(root, relation) && let Some(found) = find(graph, child, id) @@ -568,7 +578,7 @@ fn captured_resize_with_260_rows_keeps_geometry_selection_and_sorting() { GridLength::Pixel(160.0), GridLength::Pixel(140.0), GridLength::Pixel(140.0), - GridLength::Pixel(48.0), + GridLength::Pixel(200.0), ] .into(), ) @@ -624,9 +634,11 @@ fn captured_resize_with_260_rows_keeps_geometry_selection_and_sorting() { f.property("header_columns", PropertyId::GridColumns), columns(240.0) ); + // Rows follow on a coalescing frame timer; its tick may already have run. + f.send_table(table::Message::FollowResize); assert_eq!( f.property(&format!("columns_{}", sid(131)), PropertyId::GridColumns), - columns(220.0) + columns(240.0) ); assert!( f.host @@ -762,6 +774,18 @@ fn nested_table_routes_selection_checkbox_keys_hover_and_keyboard_focus() { f.property(&chrome1, PropertyId::BorderThickness), PropertyValue::Thickness(Thickness::uniform(0.0)) ); + // Only the hovered row carries its seven cell tooltips. + let tooltips = |f: &Fixture| { + let graph = f.host.runtime().graph(); + let columns = find(graph, graph.root().unwrap(), &format!("columns_{}", sid(2))).unwrap(); + graph + .children(columns, RelationId::Children) + .unwrap() + .iter() + .filter(|cell| f.host.adapter().tooltip(**cell).is_some()) + .count() + }; + assert_eq!(tooltips(&f), 0); f.emit( &row2, EventId::PointerEntered, @@ -771,6 +795,7 @@ fn nested_table_routes_selection_checkbox_keys_hover_and_keyboard_focus() { f.property(&chrome2, PropertyId::Background), PropertyValue::Brush(Brush::Theme(ThemeBrush::SubtleFillSecondary)) ); + assert_eq!(tooltips(&f), 7); f.emit( &row2, EventId::PointerExited, @@ -780,4 +805,5 @@ fn nested_table_routes_selection_checkbox_keys_hover_and_keyboard_focus() { f.property(&chrome2, PropertyId::Background), PropertyValue::Brush(Color::transparent().into()) ); + assert_eq!(tooltips(&f), 0); } diff --git a/docs/manual-smoke.md b/docs/manual-smoke.md index 5982d44..9a07227 100644 --- a/docs/manual-smoke.md +++ b/docs/manual-smoke.md @@ -12,10 +12,13 @@ Use `--lang zh-cn`, `zh-tw`, `ja`, `ko`, or `ru` to check the other translations Do not invoke Save during this smoke: saving replaces the firewall exemption table. Undo checkbox changes before a clean close, or choose Exit in the discard dialog. -- [ ] The initial window is 960×600 DIP, has the embedded icon, Mica/theme colors, explanation bar and Learn more link. Resize down to the 640×400 minimum. Explanation, counter/toolbar, table card and status share 24-DIP page margins and 12-DIP section spacing. Toolbar uses plain icon/text buttons with 8-DIP gaps; Save uses the accent style when enabled. No command band, overflow edge or overlay covers the headers. The 280-DIP search box is at the left of the toolbar and the global counter at the right of the status bar. At the default 960 DIP width the toolbar is one row in every language; below 900 DIP the actions move to a second row, right-aligned. Toolbar, list and status remain usable. -- [ ] The table has a theme card fill, 1-DIP stroke and 8-DIP rounded corners. Row fills stay inside its rounded edges. The 32-DIP semibold header uses SubtleFillSecondary; every header cell has a 1-DIP DividerStroke separator. Rows are 40 DIP with 12-DIP text padding and horizontal dividers. Headers align exactly with rows, without inherited ListView inset, including after horizontal scrolling. Vertical scrolling keeps the header fixed. Long values are single-line ellipses; hovering a cell shows its full text. The file column receives spare width in a large window. -- [ ] Drag each header separator, including Binaries, both ways. The 8-DIP hit area shows SizeWestEast on both sides of each divider and captures the pointer; the checkbox column stays fixed and data columns stop at 48 DIP. Releasing outside the header finishes the resize; canceled/lost capture leaves a usable table. Dragging never sorts. The header resizes live and rows update on release/lost capture (260-row component measurements: ~11.1 ms/header update and ~20.9 ms/all-row commit; native layout excluded). Rows and header align after release; copy-column hit testing follows the resized widths. With 260 rows, assess resize responsiveness and check Binaries still fills spare width after maximizing. +- [ ] The initial window is 960×600 DIP, centered in the work area of the monitor under the cursor, has the embedded icon, Mica/theme colors, explanation bar and Learn more link. Resize down to the 640×400 minimum. Explanation, counter/toolbar, table card and status share 24-DIP page margins and 12-DIP section spacing. Toolbar uses plain icon/text buttons with 8-DIP gaps; Save uses the accent style when enabled. No command band, overflow edge or overlay covers the headers. The 280-DIP search box is at the left of the toolbar and the global counter at the right of the status bar. At the default 960 DIP width the toolbar is one row in every language; below 900 DIP the actions move to a second row, right-aligned. Toolbar, list and status remain usable. +- [ ] The table has a theme card fill, 1-DIP stroke and 8-DIP rounded corners. Row fills stay inside its rounded edges. The 32-DIP semibold header uses SubtleFillSecondary; every header cell has a 1-DIP DividerStroke separator. Rows are 40 DIP with 12-DIP text padding and horizontal dividers. Headers align exactly with rows, without inherited ListView inset, including after horizontal scrolling. Vertical scrolling keeps the header fixed. In a narrow window the vertical scroll bar stays at the card's right edge, and a horizontal mouse wheel scrolls horizontally. Long values are single-line ellipses; hovering a cell shows its full text, wrapped when it is wider than the tooltip. The file column starts at 200 DIP and receives spare width in a large window. +- [ ] Drag each header separator, including Binaries, both ways. The 8-DIP hit area shows SizeWestEast on both sides of each divider and captures the pointer; the checkbox column stays fixed and data columns stop at 48 DIP. Releasing outside the header finishes the resize; canceled/lost capture leaves a usable table. Dragging never sorts. The header resizes live and rows follow at most once per 16 ms, settling on release/lost capture (260-row component measurements: ~11.1 ms/header update and ~20.9 ms/all-row commit; native layout excluded). Rows and header align after release; copy-column hit testing follows the resized widths. With 260 rows, assess resize responsiveness and check Binaries still fills spare width after maximizing. - [ ] Scroll through the list and check that rows remain responsive. The row count in the counter stays constant; only visible/nearby WinUI containers are realized. +- [ ] Scroll right and down without clicking a row, then refresh, search, clear the search and sort: the horizontal offset stays and the row at the top stays where it was (the next surviving row when it is filtered out). After clicking a row, that row keeps its place instead. Scrolled to the right end, widening Binaries keeps the end and its grip in view. +- [ ] With a few hundred rows, scroll quickly, search, refresh, tick checkboxes and move with the arrow keys: rows are filled as they scroll in, and focus and selection follow. Narrator or UIA names each list item by its display name. The process starts near 70 MB private working set (about 107 MB before rows were built only near the viewport). +- [ ] Move the pointer from outside the table straight onto a truncated cell: its tooltip appears, and so do those of the row's other cells. - [ ] Click a row's text: only that row is selected. Ctrl+click another row adds/removes it. Shift+click selects the inclusive range from the anchor, including reverse ranges. Checkbox clicks toggle exemption without changing the selection. - [ ] Selection remains visible while another row has keyboard focus. Check light, dark and Windows high contrast. Unselected rows are transparent; hover uses SubtleFillSecondary, selection uses SubtleFillTertiary plus a 3×16-DIP accent pill. A matching current-user row has a TextSecondary dot in Users, independently of selection; copy excludes it. Keyboard focus within a row shows a 2-DIP ring, cleared when focus leaves or pointer input resumes. No opaque row bands or native selection chrome remain. - [ ] With focus in the list, Shift+Up/Down extends and shrinks the range; plain Up/Down moves to one row. With SelectionMode=None, move across the viewport edge and confirm the focused row scrolls into view. Tab into a checkbox and confirm focus stays there; arrows restore row focus. Space toggles the focused row's exemption; Space on a focused checkbox uses its normal behavior. diff --git a/vendor/windows-rs b/vendor/windows-rs index 75c2364..7876e1b 160000 --- a/vendor/windows-rs +++ b/vendor/windows-rs @@ -1 +1 @@ -Subproject commit 75c2364d1ba98d4b1fb9a9f28e2a9b0fc067e6e6 +Subproject commit 7876e1bd6af4f4875d529d0cfed0aa58e70984f6