From 8a6c683953740483a01a2c2221abe2b6e2867662 Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:44:07 +0800 Subject: [PATCH 01/10] Bump windows-rs fork for list scrolling and wrapped tooltips The fork adds two-axis ListView scrolling with offset events and scroll requests, which the table uses next. It also wraps text tooltips the way WinUI wraps string content, so a cell's tooltip no longer clips values wider than the tooltip. --- docs/manual-smoke.md | 2 +- vendor/windows-rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/manual-smoke.md b/docs/manual-smoke.md index 5982d44..ed85dd1 100644 --- a/docs/manual-smoke.md +++ b/docs/manual-smoke.md @@ -13,7 +13,7 @@ Do not invoke Save during this smoke: saving replaces the firewall exemption tab 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. +- [ ] 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, wrapped when it is wider than the tooltip. 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. - [ ] 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. - [ ] 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. diff --git a/vendor/windows-rs b/vendor/windows-rs index 75c2364..b81c927 160000 --- a/vendor/windows-rs +++ b/vendor/windows-rs @@ -1 +1 @@ -Subproject commit 75c2364d1ba98d4b1fb9a9f28e2a9b0fc067e6e6 +Subproject commit b81c927f41de5e788ec0ffc67847413670ed1956 From be9e97fcbeb869f0f9e74b667c289c597a48f07d Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:44:14 +0800 Subject: [PATCH 02/10] Center the window on the monitor under the cursor The window opened at the system's cascading default position. It now opens centered in the work area of the monitor under the cursor. The client size is converted to that monitor's DPI and framed with AdjustWindowRectExForDpi. --- crates/enable-loopback/Cargo.toml | 2 +- crates/enable-loopback/src/ui/mod.rs | 31 ++++++++----- crates/enable-loopback/src/ui/native.rs | 60 +++++++++++++++++++++++++ docs/manual-smoke.md | 2 +- 4 files changed, 82 insertions(+), 13 deletions(-) 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/ui/mod.rs b/crates/enable-loopback/src/ui/mod.rs index d6d82c4..8998ac3 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, @@ -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/docs/manual-smoke.md b/docs/manual-smoke.md index ed85dd1..20c98d6 100644 --- a/docs/manual-smoke.md +++ b/docs/manual-smoke.md @@ -12,7 +12,7 @@ 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 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. Long values are single-line ellipses; hovering a cell shows its full text, wrapped when it is wider than the tooltip. 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. - [ ] 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. From 4a23be33a9e6fca898f25331ddfd3bc816498582 Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:44:20 +0800 Subject: [PATCH 03/10] Start the Binaries column at 200 DIP Binaries started at its 48 DIP minimum and only grew to fill spare width. In a window narrower than the columns it stayed at the minimum and showed a single character of its header. --- crates/enable-loopback/src/model/columns.rs | 8 ++++---- crates/enable-loopback/src/ui/mod.rs | 2 +- crates/enable-loopback/src/ui/tests.rs | 2 +- docs/manual-smoke.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) 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 8998ac3..326f121 100644 --- a/crates/enable-loopback/src/ui/mod.rs +++ b/crates/enable-loopback/src/ui/mod.rs @@ -316,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 { diff --git a/crates/enable-loopback/src/ui/tests.rs b/crates/enable-loopback/src/ui/tests.rs index 0e37428..71096e3 100644 --- a/crates/enable-loopback/src/ui/tests.rs +++ b/crates/enable-loopback/src/ui/tests.rs @@ -568,7 +568,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(), ) diff --git a/docs/manual-smoke.md b/docs/manual-smoke.md index 20c98d6..1d50659 100644 --- a/docs/manual-smoke.md +++ b/docs/manual-smoke.md @@ -13,7 +13,7 @@ Do not invoke Save during this smoke: saving replaces the firewall exemption tab Undo checkbox changes before a clean close, or choose Exit in the discard dialog. - [ ] 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. Long values are single-line ellipses; hovering a cell shows its full text, wrapped when it is wider than the tooltip. The file column receives spare width in a large window. +- [ ] 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, 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 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. - [ ] 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. - [ ] 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. From b6762145a0785cc526fbec5aa8c47d56266d2369 Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:44:27 +0800 Subject: [PATCH 04/10] Scroll the table in its list so both scroll bars stay reachable The header and the list sat in an outer horizontal ScrollViewer, and the list scrolled vertically inside it. In a narrow window the list's vertical scroll bar was at the right edge of the full content width, out of view. A horizontal wheel reached the inner list first, which scrolled vertically. The list now scrolls both ways itself, so its vertical scroll bar stays at the card's edge and a horizontal wheel scrolls horizontally. The header moves by the list's horizontal offset inside a non-scrolling ScrollViewer that clips it, with real layout, so header hit testing stays correct. --- crates/enable-loopback/src/ui/table/mod.rs | 41 +++++++++++++++------- docs/manual-smoke.md | 2 +- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/crates/enable-loopback/src/ui/table/mod.rs b/crates/enable-loopback/src/ui/table/mod.rs index 5756532..3fbd585 100644 --- a/crates/enable-loopback/src/ui/table/mod.rs +++ b/crates/enable-loopback/src/ui/table/mod.rs @@ -76,6 +76,8 @@ pub(super) struct TableView { row_refs: BTreeMap>, checkbox_focus: Rc>, restore_focus: bool, + /// The list's horizontal scroll offset, which the header follows. + scroll_x: f64, } struct Drag { @@ -97,6 +99,7 @@ pub(super) enum Message { ContextPoint(String, Column, Result), Scrolled(u64, String, bool, Result), FocusReady(u64, String), + HorizontalOffset(f64), } impl TableView { @@ -115,6 +118,8 @@ impl TableView { ListView::new() .grid_row(1) .automation_id("apps") + .horizontal_scroll(true) + .on_horizontal_offset_changed(cx.callback(Message::HorizontalOffset)) .element_ref(&self.list_ref) .is_enabled(input.enabled) .selection_mode(ListViewSelectionMode::None) @@ -277,6 +282,7 @@ impl Component for TableView { .collect(), checkbox_focus: Rc::new(Cell::new(false)), restore_focus: true, + scroll_x: 0.0, } } @@ -293,6 +299,8 @@ impl Component for TableView { self.restore_focus = input.generation != self.input.generation; if input.generation != self.input.generation { self.checkbox_focus.set(false); + // A new generation replaces the ListView, which starts unscrolled. + self.scroll_x = 0.0; } if input.generation != self.input.generation || input.focused != self.input.focused { self.focus_timer = None; @@ -390,6 +398,7 @@ impl Component for TableView { Message::Scrolled(_, _, _, Err(error)) => { crate::debug(&format!("Table scroll request failed: {error:?}")) } + Message::HorizontalOffset(offset) => self.scroll_x = offset, _ => {} } } @@ -428,19 +437,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 { diff --git a/docs/manual-smoke.md b/docs/manual-smoke.md index 1d50659..92eeaf2 100644 --- a/docs/manual-smoke.md +++ b/docs/manual-smoke.md @@ -13,7 +13,7 @@ Do not invoke Save during this smoke: saving replaces the firewall exemption tab Undo checkbox changes before a clean close, or choose Exit in the discard dialog. - [ ] 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. 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. +- [ ] 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 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. - [ ] 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. - [ ] 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. From 31918424f02562b8366b7bd50302fc4ad601f31d Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:44:38 +0800 Subject: [PATCH 05/10] Let rows follow a column resize while dragging Rows kept their widths until the drag ended, so the list did not move with the header. Re-laying out every row costs about two header updates, so rows now follow at most once per 16 ms during a drag and settle on release. --- crates/enable-loopback/src/ui/table/mod.rs | 14 ++++++++++++++ crates/enable-loopback/src/ui/tests.rs | 4 +++- docs/manual-smoke.md | 2 +- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/enable-loopback/src/ui/table/mod.rs b/crates/enable-loopback/src/ui/table/mod.rs index 3fbd585..82a4d6d 100644 --- a/crates/enable-loopback/src/ui/table/mod.rs +++ b/crates/enable-loopback/src/ui/table/mod.rs @@ -71,6 +71,8 @@ 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>, @@ -94,6 +96,7 @@ pub(super) enum Message { ResizeEnd(PointerEventInfo), ResizeLost, FinishResize, + FollowResize, Pointer((String, Column, PointerEventInfo)), Context((String, Column, PointerEventInfo)), ContextPoint(String, Column, Result), @@ -110,6 +113,7 @@ impl TableView { fn commit_resize(&mut self) { self.drag = None; self.resize_timer = None; + self.rows_timer = None; self.row_widths = self.widths; } @@ -273,6 +277,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 @@ -349,6 +354,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 + })); } } } @@ -358,6 +368,10 @@ 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; + self.row_widths = self.widths; + } Message::Pointer((key, column, event)) if self.input.enabled && self.drag.is_none() => { self.input.on_pointer.call((key, column, event)); } diff --git a/crates/enable-loopback/src/ui/tests.rs b/crates/enable-loopback/src/ui/tests.rs index 71096e3..a6df56d 100644 --- a/crates/enable-loopback/src/ui/tests.rs +++ b/crates/enable-loopback/src/ui/tests.rs @@ -624,9 +624,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 diff --git a/docs/manual-smoke.md b/docs/manual-smoke.md index 92eeaf2..6facf17 100644 --- a/docs/manual-smoke.md +++ b/docs/manual-smoke.md @@ -14,7 +14,7 @@ Undo checkbox changes before a clean close, or choose Exit in the discard dialog - [ ] 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 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. +- [ ] 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. - [ ] 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. From f6adeec373fb328cc191c83ea0c22b76e2afa822 Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:44:46 +0800 Subject: [PATCH 06/10] Keep the table's scroll position when its rows are replaced Refreshing, searching and sorting replace the ListView, so the list jumped back to the start. The new list now restores the horizontal offset and keeps an anchor row where it was on screen. The anchor is the focused row if it survives; otherwise it is the first surviving row from the top of the old view. A refresh empties the rows before reloading them, so the rows and offsets from before that are held until the new rows arrive. Widening Binaries while scrolled to the right end kept the growth out of view, so the column looked unresizable. That drag now scrolls along to keep the end and the grip in view. --- crates/enable-loopback/src/ui/table/mod.rs | 125 ++++++++++++++++++++- crates/enable-loopback/src/ui/table/row.rs | 2 +- docs/manual-smoke.md | 1 + 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/crates/enable-loopback/src/ui/table/mod.rs b/crates/enable-loopback/src/ui/table/mod.rs index 82a4d6d..161120c 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, @@ -80,13 +82,45 @@ pub(super) struct TableView { 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 { @@ -111,10 +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 { @@ -124,12 +166,16 @@ impl TableView { .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, }) @@ -288,6 +334,9 @@ impl Component for TableView { 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, } } @@ -302,10 +351,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); - // A new generation replaces the ListView, which starts unscrolled. - self.scroll_x = 0.0; + 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; @@ -338,12 +400,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) => { @@ -370,7 +434,8 @@ impl Component for TableView { Message::FinishResize => self.commit_resize(), Message::FollowResize => { self.rows_timer = None; - self.row_widths = self.widths; + 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)); @@ -427,6 +492,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), @@ -518,3 +592,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..a5bed67 100644 --- a/crates/enable-loopback/src/ui/table/row.rs +++ b/crates/enable-loopback/src/ui/table/row.rs @@ -164,7 +164,7 @@ impl Component for TableRow { .focus_on_pointer_release(true) .automation_id(format!("row_{}", input.row.key)) .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/docs/manual-smoke.md b/docs/manual-smoke.md index 6facf17..222667c 100644 --- a/docs/manual-smoke.md +++ b/docs/manual-smoke.md @@ -16,6 +16,7 @@ Undo checkbox changes before a clean close, or choose Exit in the discard dialog - [ ] 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. - [ ] 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. From 20dfcc155e34d4e2e28e8d9d20ac234c8bff53ac Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:29:18 +0800 Subject: [PATCH 07/10] Bump windows-rs fork for virtualized ListView items The fork adds ListView::virtual_source, which builds an item's view only while the list has a container prepared for it, and a test-support count of its items. --- vendor/windows-rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/windows-rs b/vendor/windows-rs index b81c927..7876e1b 160000 --- a/vendor/windows-rs +++ b/vendor/windows-rs @@ -1 +1 @@ -Subproject commit b81c927f41de5e788ec0ffc67847413670ed1956 +Subproject commit 7876e1bd6af4f4875d529d0cfed0aa58e70984f6 From 5174e443aa44b3f7ca69e3d7048ae98fef82f354 Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:30:18 +0800 Subject: [PATCH 08/10] Build table rows only near the list's viewport Every row's element tree was built up front, although the list realizes only the containers near its viewport. With about 280 rows they held some 58 MB of the 107 MB private working set at startup. The table now declares its rows through a virtual source keyed by the list generation, so only the rows the list prepares are built: about 48, for 73 MB. Each row names itself after its display name, which the list copies to its item, so assistive technology still reads app names instead of indices. Recording tests stand in for the native list and prepare every row. --- crates/enable-loopback/src/ui/table/mod.rs | 59 +++++++++++++++------- crates/enable-loopback/src/ui/table/row.rs | 2 + crates/enable-loopback/src/ui/tests.rs | 46 ++++++++++------- docs/manual-smoke.md | 1 + 4 files changed, 72 insertions(+), 36 deletions(-) diff --git a/crates/enable-loopback/src/ui/table/mod.rs b/crates/enable-loopback/src/ui/table/mod.rs index 161120c..3268822 100644 --- a/crates/enable-loopback/src/ui/table/mod.rs +++ b/crates/enable-loopback/src/ui/table/mod.rs @@ -179,25 +179,48 @@ impl TableView { 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 { diff --git a/crates/enable-loopback/src/ui/table/row.rs b/crates/enable-loopback/src/ui/table/row.rs index a5bed67..ddeaa0d 100644 --- a/crates/enable-loopback/src/ui/table/row.rs +++ b/crates/enable-loopback/src/ui/table/row.rs @@ -163,6 +163,8 @@ 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(super::ROW_HEIGHT) .background(Color::transparent()) diff --git a/crates/enable-loopback/src/ui/tests.rs b/crates/enable-loopback/src/ui/tests.rs index a6df56d..dce3a95 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) diff --git a/docs/manual-smoke.md b/docs/manual-smoke.md index 222667c..a5051ad 100644 --- a/docs/manual-smoke.md +++ b/docs/manual-smoke.md @@ -17,6 +17,7 @@ Undo checkbox changes before a clean close, or choose Exit in the discard dialog - [ ] 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). - [ ] 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. From 21a257af34f4552f15d1eb9d42be4bc3a741311b Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:30:53 +0800 Subject: [PATCH 09/10] Create cell tooltips only for the hovered row Every built row attached a tooltip to each of its seven cells, about 2.5 MB across the rows the list keeps prepared. Only the row under the pointer needs them, so a row now attaches them while it is hovered. --- crates/enable-loopback/src/ui/table/row.rs | 17 ++++++++++------- crates/enable-loopback/src/ui/tests.rs | 14 ++++++++++++++ docs/manual-smoke.md | 1 + 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/enable-loopback/src/ui/table/row.rs b/crates/enable-loopback/src/ui/table/row.rs index ddeaa0d..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() diff --git a/crates/enable-loopback/src/ui/tests.rs b/crates/enable-loopback/src/ui/tests.rs index dce3a95..ab5cb2c 100644 --- a/crates/enable-loopback/src/ui/tests.rs +++ b/crates/enable-loopback/src/ui/tests.rs @@ -774,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, @@ -783,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, @@ -792,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 a5051ad..9a07227 100644 --- a/docs/manual-smoke.md +++ b/docs/manual-smoke.md @@ -18,6 +18,7 @@ Undo checkbox changes before a clean close, or choose Exit in the discard dialog - [ ] 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. From a7190f5aa8a972ffbc084bdf7bec1524f33ef691 Mon Sep 17 00:00:00 2001 From: Jonson Petard <41122242+greenhat616@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:32:41 +0800 Subject: [PATCH 10/10] Generate the changelog and release notes with git-cliff Releases had no notes, and the project kept no changelog. cliff.toml lists each version's commit subjects with links. Subjects are imperative sentences rather than Conventional Commits, so they are not grouped; release, merge and fork bump commits are left out. The release workflow writes the tagged version's section as the release notes, and the README describes the release steps that keep CHANGELOG.md current. --- .github/workflows/release.yml | 9 +++++++ CHANGELOG.md | 47 +++++++++++++++++++++++++++++++++++ README.md | 6 +++++ cliff.toml | 37 +++++++++++++++++++++++++++ 4 files changed, 99 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 cliff.toml 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"