From e7d7734db2dedc1ced55b3a2e8330991e7fa21b2 Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:06:29 -0600 Subject: [PATCH 1/6] DoD Studio window: a strip on every tab shows the demo and highlights loading, and the highlights start finding as soon as a demo plays (#797) Co-Authored-By: Claude Fable 5.1 --- docs/dodstudio_commands.md | 5 +- goldsrc-hooks/src/streaks.rs | 13 ++ goldsrc-hooks/src/studio_panel.rs | 58 +++++++- .../src/studio_panel/hook/load_progress.rs | 15 ++- .../src/studio_panel/hook/load_strip.rs | 125 ++++++++++++++++++ .../src/studio_panel/hook/streaks_tab.rs | 5 +- goldsrc-hooks/ui/DodStudio.res | 13 ++ 7 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 goldsrc-hooks/src/studio_panel/hook/load_strip.rs diff --git a/docs/dodstudio_commands.md b/docs/dodstudio_commands.md index 05a5a7e60..12c68b00e 100644 --- a/docs/dodstudio_commands.md +++ b/docs/dodstudio_commands.md @@ -324,7 +324,10 @@ and Go also puts the camera on that player (`dodstudio_spec_target`). Go or a double-click jumps to 5 s before the first kill. It reads Studio's analyzer cache (`%APPDATA%\dod-studio\analyzer_cache`), so a demo Studio's Demo Analyzer or Master Queue already read shows at once; anything else is analysed in the -game, with a progress bar, and saved there for Studio too. A demo too big for +game, with a progress bar, and saved there for Studio too. That analysis +starts as soon as a demo plays, with the window open or not; while it or the +demo player's own load still runs, a strip along the top of every tab says how +far each has got (`Loading: Demo 45% Highlights 60%`, #797). A demo too big for the game's memory (analysis takes about 12 times the demo's size; the pre-Anniversary game has under 1 GB free) is refused with a note to open it in the Demo Analyzer instead. On the diff --git a/goldsrc-hooks/src/streaks.rs b/goldsrc-hooks/src/streaks.rs index f87221280..e54643254 100644 --- a/goldsrc-hooks/src/streaks.rs +++ b/goldsrc-hooks/src/streaks.rs @@ -269,6 +269,19 @@ pub fn status() -> Option<(u64, Status, u32)> { )) } +/// The current analysis' progress (0 to 100) while it runs, `Some(None)` +/// when none runs, or `None` while the worker is saving its result. Cheaper +/// than [`status`] for the loading strip (#797), which asks every frame: it +/// never copies the streaks found. +pub fn loading_percent() -> Option> { + let job = JOB.try_lock().ok()?; + Some( + job.as_ref() + .filter(|j| matches!(j.status, Status::Loading)) + .map(|_| PERCENT.load(Ordering::Acquire)), + ) +} + /// The streaks, and where they came from (for the log). fn find(path: &Path, generation: u64) -> Result<(Found, &'static str), String> { let root = cache_root(); diff --git a/goldsrc-hooks/src/studio_panel.rs b/goldsrc-hooks/src/studio_panel.rs index 9fdf74705..7510df083 100644 --- a/goldsrc-hooks/src/studio_panel.rs +++ b/goldsrc-hooks/src/studio_panel.rs @@ -1382,6 +1382,28 @@ fn help_bounds(sheet: (i32, i32, i32, i32)) -> (i32, i32, i32, i32) { const HELP_LINE: &str = "HelpLine"; const HELP_TALL: i32 = 22; +/// The loading strip's panel name, in `DodStudio.res` (#797). +const LOAD_STRIP: &str = "LoadStrip"; +/// How far the pages' controls move down while the strip shows. +const LOAD_STRIP_TALL: i32 = 20; + +/// Where the loading strip goes on a page `page_wide` across: along its top, +/// above the controls, which [`LOAD_STRIP_TALL`] pushes down. +fn load_strip_bounds(page_wide: i32) -> (i32, i32, i32, i32) { + (8, 2, (page_wide - 16).max(1), LOAD_STRIP_TALL - 2) +} + +/// What the loading strip says: how far the demo player has read the demo +/// and how far the highlights analysis has got, each while it runs; `None` +/// once both are done, and the strip goes. +fn load_strip_text(demo: Option, highlights: Option) -> Option { + let parts: Vec = [("Demo", demo), ("Highlights", highlights)] + .into_iter() + .filter_map(|(what, percent)| Some(format!("{what} {}%", percent?))) + .collect(); + (!parts.is_empty()).then(|| format!("Loading: {}", parts.join(" "))) +} + /// Every control's `"helptext"` in a `.res` file, by its name (an older layout's /// `"tooltiptext"` too; vgui2 shows that one as a tooltip on a check box): /// what the help line shows for it. (GameUI reads the key but shows no tooltip, so @@ -1652,6 +1674,8 @@ mod hook { mod list_icons; /// The Playback tab's loading progress (#465). mod load_progress; + /// The loading strip along the top of every tab (#797). + mod load_strip; /// The Demos tab's Player box, a dropdown narrowed as you type (#565). mod player_picker; /// The Review tab (#623). @@ -3344,8 +3368,11 @@ mod hook { // bigger than drawn still keeps its bottom row at the bottom // (#609). let rects: Vec<_> = design.controls.iter().map(|&(_, at)| at).collect(); + // The loading strip takes the top of the tab (#797): fit the + // controls to the rest and move them down under it. + let inset = load_strip::inset(); let (fit_design, fit_now) = - crate::window_layout::content_frame(&rects, size, (w, h)); + crate::window_layout::content_frame(&rects, size, (w, (h - inset).max(1))); if design.logged != Some((w, h)) { design.logged = Some((w, h)); crate::debug::report(&format!( @@ -3366,7 +3393,7 @@ mod hook { } else { crate::window_layout::fit_rect(at, fit_design, fit_now) }; - vgui.place(control, want); + vgui.place(control, (want.0, want.1 + inset, want.2, want.3)); } } } @@ -3822,6 +3849,7 @@ mod hook { pub(super) fn poll() { hook_taskbar(); name_stock_demo_rows(); + load_strip::request_highlights(); let pending = VIEWDEMO_PENDING.load(Ordering::Relaxed); if OBJECT.load(Ordering::Relaxed) == 0 && pending == 0 @@ -3859,6 +3887,7 @@ mod hook { }; hold_minimum(&vgui, frame, vp, &mut lent); if vgui.visible(vp) { + load_strip::update(&vgui, vp); fit_pages(&vgui, &mut lent); sync_settings(&vgui, &mut lent); update_help(&vgui, vp, &mut lent); @@ -5007,6 +5036,31 @@ mod tests { assert_eq!(action("reset_settings"), Action::ResetSettings); } + #[test] + fn the_loading_strip_names_each_load_still_running() { + assert_eq!( + load_strip_text(Some(45), Some(60)).as_deref(), + Some("Loading: Demo 45% Highlights 60%") + ); + assert_eq!( + load_strip_text(Some(0), None).as_deref(), + Some("Loading: Demo 0%") + ); + assert_eq!( + load_strip_text(None, Some(100)).as_deref(), + Some("Loading: Highlights 100%") + ); + assert_eq!(load_strip_text(None, None), None); + } + + #[test] + fn the_loading_strip_sits_above_the_controls_it_pushes_down() { + let (x, y, w, h) = load_strip_bounds(540); + assert_eq!((x, w), (8, 524)); + assert!(y >= 0 && y + h <= LOAD_STRIP_TALL); + assert!(load_strip_bounds(4).2 >= 1); + } + #[test] fn the_tab_strip_fills_the_client_area_inside_a_margin() { assert_eq!( diff --git a/goldsrc-hooks/src/studio_panel/hook/load_progress.rs b/goldsrc-hooks/src/studio_panel/hook/load_progress.rs index 533db9192..7436474f7 100644 --- a/goldsrc-hooks/src/studio_panel/hook/load_progress.rs +++ b/goldsrc-hooks/src/studio_panel/hook/load_progress.rs @@ -29,10 +29,7 @@ pub(super) unsafe fn update(vgui: &Vgui) { if page == 0 || !vgui.visible(page) { return; } - let percent = crate::demo_seek::buffered_while_loading().and_then(|buffered| { - let total = length_of_current_demo()?; - Some(crate::demo_file::load_percent(buffered, total)) - }); + let percent = demo_percent(); let mut shown = SHOWN.lock().unwrap_or_else(|e| e.into_inner()); if *shown == percent { return; @@ -74,6 +71,16 @@ pub(super) unsafe fn update(vgui: &Vgui) { } } +/// How much of the playing demo the demo player has read, while it is still +/// reading. Shared with the loading strip (#797), so both say the same, and +/// worked out whatever tab is showing. +pub(super) fn demo_percent() -> Option { + crate::demo_seek::buffered_while_loading().and_then(|buffered| { + let total = length_of_current_demo()?; + Some(crate::demo_file::load_percent(buffered, total)) + }) +} + /// The playing demo's playback length, read once per demo. fn length_of_current_demo() -> Option { let name = crate::demo_reload::current_demo()?; diff --git a/goldsrc-hooks/src/studio_panel/hook/load_strip.rs b/goldsrc-hooks/src/studio_panel/hook/load_strip.rs new file mode 100644 index 000000000..8b5c14e94 --- /dev/null +++ b/goldsrc-hooks/src/studio_panel/hook/load_strip.rs @@ -0,0 +1,125 @@ +//! The loading strip (#797): one line along the top of whichever tab is +//! showing, saying how far the demo player has read the demo and how far the +//! highlights analysis has got, while either still runs. The tabs' controls +//! sit below it while it shows ([`inset`]); it goes once both are done. +//! +//! It is `DodStudio.res`'s `LoadStrip` label, moved onto the page showing: +//! a page paints over whatever of the window lies under it, so a strip left +//! on the window would be hidden by the page it overlaps. +//! +//! The highlights analysis starts as soon as a demo plays ([`request_highlights`]), +//! window open or not; the Highlights tab only shows its result. The demo +//! load needs nothing: it runs at the same rate with the window closed. + +use std::ffi::CString; +use std::sync::atomic::{AtomicI32, AtomicU32, Ordering}; + +use super::*; + +/// The demo the highlights were last asked for, so the path is worked out +/// once per demo rather than every frame. +static REQUESTED: std::sync::Mutex = std::sync::Mutex::new(String::new()); +/// The strip, and the text on it (`None` while hidden): a rebuilt window's +/// new strip is written again. +static SHOWN: std::sync::Mutex<(Vpanel, Option)> = std::sync::Mutex::new((0, None)); +/// The highlights' last percent, [`NONE`] when none runs: kept for the frames +/// the analysis' worker holds its result's lock. +static HIGHLIGHTS: AtomicU32 = AtomicU32::new(NONE); +const NONE: u32 = u32::MAX; +/// How far the pages' controls sit down: [`LOAD_STRIP_TALL`] while the strip +/// shows, else 0. +static INSET: AtomicI32 = AtomicI32::new(0); + +/// Starts finding the playing demo's highlights. Runs every frame, window or +/// not; asks once per demo ([`crate::streaks::request`] ignores a repeat). +pub(super) fn request_highlights() { + let Some(name) = crate::demo_reload::current_demo() else { + return; + }; + let mut requested = REQUESTED.lock().unwrap_or_else(|e| e.into_inner()); + if *requested == name { + return; + } + crate::streaks::request(&crate::streaks::demo_path(&dod_dir(), &name)); + *requested = name; +} + +/// How far the tabs' controls move down for the strip. +pub(super) fn inset() -> i32 { + INSET.load(Ordering::Acquire) +} + +/// Shows, moves or hides the strip. Runs every frame while the window shows, +/// before the pages are fitted. +pub(super) unsafe fn update(vgui: &Vgui, frame_vp: Vpanel) { + unsafe { + let demo = load_progress::demo_percent(); + let highlights = match crate::streaks::loading_percent() { + Some(now) => { + HIGHLIGHTS.store(now.unwrap_or(NONE), Ordering::Release); + now + } + None => Some(HIGHLIGHTS.load(Ordering::Acquire)).filter(|&p| p != NONE), + }; + let text = load_strip_text(demo, highlights); + let page = PAGE_OBJECTS + .iter() + .map(|p| vpanel_of(p.load(Ordering::Acquire) as *mut c_void)) + .find(|&vp| vp != 0 && vgui.visible(vp)); + let Some(strip) = find(vgui, frame_vp, page) else { + // A layout edited before the strip existed: no room to make. + INSET.store(0, Ordering::Release); + return; + }; + INSET.store( + if text.is_some() { LOAD_STRIP_TALL } else { 0 }, + Ordering::Release, + ); + if let Some(page) = page { + if vgui.parent_of(strip) != page { + vgui.set_parent(strip, page); + } + let (_, _, wide, _) = vgui.rect(page); + vgui.place(strip, load_strip_bounds(wide)); + } + if vgui.visible(strip) != text.is_some() { + vgui.set_visible(strip, text.is_some()); + } + let mut shown = SHOWN.lock().unwrap_or_else(|e| e.into_inner()); + if shown.0 == strip && shown.1 == text { + return; + } + if shown.1.is_some() != text.is_some() { + crate::debug::report(&format!( + "studio_panel: loading strip: {}", + text.as_deref().unwrap_or("gone, both loads done") + )); + } + if let Some(text) = &text { + let object = vgui.object(strip); + if let (false, Ok(c_text)) = (object.is_null(), CString::new(text.as_str())) { + let set_text: SetTextFn = slot(object, LABEL_SLOT_SET_TEXT); + set_text(object, c_text.as_ptr()); + } + } + *shown = (strip, text); + } +} + +/// The strip: on the page showing (where it usually is), still on the window +/// (before its first move), or on a page shown before. +unsafe fn find(vgui: &Vgui, frame_vp: Vpanel, showing: Option) -> Option { + unsafe { + if let Some(vp) = showing.and_then(|page| vgui.child_named(page, LOAD_STRIP)) { + return Some(vp); + } + if let Some(vp) = vgui.child_named(frame_vp, LOAD_STRIP) { + return Some(vp); + } + PAGE_OBJECTS + .iter() + .map(|p| vpanel_of(p.load(Ordering::Acquire) as *mut c_void)) + .filter(|&vp| vp != 0 && Some(vp) != showing) + .find_map(|page| vgui.child_named(page, LOAD_STRIP)) + } +} diff --git a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs index c1a4a3b2d..ad84c6363 100644 --- a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs +++ b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs @@ -124,8 +124,9 @@ pub(super) unsafe fn build( } } -/// Keeps the tab showing the playing demo's streaks. Runs every frame; asks -/// for an analysis only while the tab is showing. +/// Keeps the tab showing the playing demo's streaks. Runs every frame while +/// the tab shows; the analysis itself starts as soon as the demo plays +/// (`load_strip::request_highlights`, #797), so asking here again is a no-op. pub(super) unsafe fn update(vgui: &Vgui) { unsafe { let page = vpanel_of(PAGE_OBJECTS[STREAKS_PAGE].load(Ordering::Acquire) as *mut c_void); diff --git a/goldsrc-hooks/ui/DodStudio.res b/goldsrc-hooks/ui/DodStudio.res index 1802256dc..7aa4f87ed 100644 --- a/goldsrc-hooks/ui/DodStudio.res +++ b/goldsrc-hooks/ui/DodStudio.res @@ -25,4 +25,17 @@ "labelText" "" "textAlignment" "west" } + "LoadStrip" + { + "ControlName" "Label" + "fieldName" "LoadStrip" + "xpos" "8" + "ypos" "2" + "wide" "540" + "tall" "18" + "visible" "0" + "enabled" "1" + "labelText" "" + "textAlignment" "west" + } } From 9a24ced7dd1586df66c9eb0df85a5283c21f9d54 Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:12:09 -0600 Subject: [PATCH 2/6] Highlights tab: the status line stays one line above the Min kills row and progress bar (#797) Co-Authored-By: Claude Fable 5.1 --- goldsrc-hooks/src/studio_panel.rs | 6 +++-- .../src/studio_panel/hook/streaks_tab.rs | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/goldsrc-hooks/src/studio_panel.rs b/goldsrc-hooks/src/studio_panel.rs index 7510df083..ddce39d70 100644 --- a/goldsrc-hooks/src/studio_panel.rs +++ b/goldsrc-hooks/src/studio_panel.rs @@ -3388,12 +3388,14 @@ mod hook { } for &(control, at) in &design.controls { // A tab's list fills whatever height the tab gains (#612). - let want = if FILL_HEIGHT.contains(&vgui.name(control).as_str()) { + let name = vgui.name(control); + let want = if FILL_HEIGHT.contains(&name.as_str()) { crate::window_layout::fit_rect_tall(at, fit_design, fit_now) } else { crate::window_layout::fit_rect(at, fit_design, fit_now) }; - vgui.place(control, (want.0, want.1 + inset, want.2, want.3)); + let tall = streaks_tab::status_tall(vgui, page, &name, want.3); + vgui.place(control, (want.0, want.1 + inset, want.2, tall)); } } } diff --git a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs index ad84c6363..50290d00b 100644 --- a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs +++ b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs @@ -230,6 +230,32 @@ fn ready_text(found: &Found, file: &str) -> String { } } +/// The status line's height for a control `name` on `page` fitted `tall`: +/// one line while the progress bar or the filter row under it shows, so a +/// wrapped second line never draws over them (#797: once the loading strip +/// moved the tab's controls, the line re-wrapped onto the Min kills row). A +/// failure, with nothing under it, keeps the room to wrap (#684). Any other +/// control keeps `tall`. +pub(super) unsafe fn status_tall(vgui: &Vgui, page: Vpanel, name: &str, tall: i32) -> i32 { + if name != STATUS_LABEL { + return tall; + } + let below = unsafe { + [PROGRESS, MIN_KILLS_BOX] + .iter() + .any(|&n| vgui.child_named(page, n).is_some_and(|vp| vgui.visible(vp))) + }; + if below { + tall.min(STATUS_ONE_LINE) + } else { + tall + } +} + +/// The status line's height as one line: it ends above the progress bar and +/// the filter row (`Highlights.res`: the line at y 6, those at 27 and 30). +const STATUS_ONE_LINE: i32 = 20; + /// The filter boxes: none while nothing is listed (`None`), Min kills once /// the streaks are found, and Player too for an HLTV demo (`Some(true)`). unsafe fn show_filters(vgui: &Vgui, page: Vpanel, hltv: Option) { From a9c70a8eacca2b176462221176d17545b7ea90cd Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:39:39 -0600 Subject: [PATCH 3/6] Highlights tab: no status line or Min kills; the Player box names a POV demo's player, greyed out (#797) The status line wrapped under the filter row, and once the loading strip says how far the analysis is, it only repeated the file name. Messages with nothing to list (no demo, no kills, a failure) go on the hint line beside Go instead. The Player box shows for every demo: an HLTV demo's narrows the list, a POV demo's holds the recording player's name and is disabled. The list moves up into the freed rows. Co-Authored-By: Claude Opus 5.5 --- docs/dodstudio_commands.md | 6 +- goldsrc-hooks/src/streaks.rs | 3 +- goldsrc-hooks/src/studio_panel.rs | 6 +- .../src/studio_panel/hook/streaks_tab.rs | 164 +++++++++--------- goldsrc-hooks/ui/Highlights.res | 55 +----- 5 files changed, 91 insertions(+), 143 deletions(-) diff --git a/docs/dodstudio_commands.md b/docs/dodstudio_commands.md index 12c68b00e..0d2b5bfc2 100644 --- a/docs/dodstudio_commands.md +++ b/docs/dodstudio_commands.md @@ -318,9 +318,9 @@ The Demos tab lists the demos, with each one's map and date, and loads one with no demo playing. Click a column heading to sort by it; the boxes above filter by name or map (Search), map, HLTV or POV, and age in days. The Highlights tab lists every life with a kill in the demo playing -(player, kills, weapons, time), narrowed by a Min kills box. A POV demo lists -only the recording player's; an HLTV demo lists everyone's, with a Player box, -and Go also puts the camera on that player (`dodstudio_spec_target`). Go or a +(player, kills, weapons, time). A POV demo lists only the recording player's, +named in a greyed-out Player box; an HLTV demo lists everyone's, the Player box +narrows them, and Go also puts the camera on that player (`dodstudio_spec_target`). Go or a double-click jumps to 5 s before the first kill. It reads Studio's analyzer cache (`%APPDATA%\dod-studio\analyzer_cache`), so a demo Studio's Demo Analyzer or Master Queue already read shows at once; anything else is analysed in the diff --git a/goldsrc-hooks/src/streaks.rs b/goldsrc-hooks/src/streaks.rs index e54643254..d7d3b0c87 100644 --- a/goldsrc-hooks/src/streaks.rs +++ b/goldsrc-hooks/src/streaks.rs @@ -27,8 +27,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; -/// Every life with a kill is listed; the tab's Min kills box narrows the -/// list, so nothing is hidden by a number fixed here. +/// Every life with a kill is listed: one kill is a highlight too. pub const MIN_KILLS: usize = 1; /// Go jumps this long before a streak's first kill. pub const LEAD_IN_SECS: f32 = 5.0; diff --git a/goldsrc-hooks/src/studio_panel.rs b/goldsrc-hooks/src/studio_panel.rs index ddce39d70..7510df083 100644 --- a/goldsrc-hooks/src/studio_panel.rs +++ b/goldsrc-hooks/src/studio_panel.rs @@ -3388,14 +3388,12 @@ mod hook { } for &(control, at) in &design.controls { // A tab's list fills whatever height the tab gains (#612). - let name = vgui.name(control); - let want = if FILL_HEIGHT.contains(&name.as_str()) { + let want = if FILL_HEIGHT.contains(&vgui.name(control).as_str()) { crate::window_layout::fit_rect_tall(at, fit_design, fit_now) } else { crate::window_layout::fit_rect(at, fit_design, fit_now) }; - let tall = streaks_tab::status_tall(vgui, page, &name, want.3); - vgui.place(control, (want.0, want.1 + inset, want.2, tall)); + vgui.place(control, (want.0, want.1 + inset, want.2, want.3)); } } } diff --git a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs index 50290d00b..96bbd32a5 100644 --- a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs +++ b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs @@ -5,10 +5,12 @@ //! Player, Kills, Time, Dur. and Details, its Load button is Go, and its `OnCommand` is //! ours: Go, or a double-click, seeks to just before the streak. //! -//! Every life with a kill is listed; a Min kills box narrows the list. A POV -//! demo lists only the recording player's (the footage follows no one else). -//! An HLTV demo lists everyone's, a Player box narrows them, and Go also puts -//! the camera on the streak's player (`dodstudio_spec_target`). +//! Every life with a kill is listed. A POV demo lists only the recording +//! player's (the footage follows no one else), named in a greyed-out Player +//! box. An HLTV demo lists everyone's, the Player box narrows them, and Go +//! also puts the camera on the streak's player (`dodstudio_spec_target`). +//! The hint line under the list says when there is nothing to list, and why; +//! how far the analysis has got is the loading strip's and the progress bar's. use std::ffi::{CString, c_char, c_void}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -23,12 +25,12 @@ pub(super) fn dialog() -> usize { DIALOG.load(Ordering::Acquire) } static ON_COMMAND: AtomicUsize = AtomicUsize::new(0); -/// What the status line and the list show now, so each is redrawn only when +/// What the hint line and the list show now, so each is redrawn only when /// it changes. static SHOWN_STATUS: std::sync::Mutex = std::sync::Mutex::new(String::new()); static SHOWN_ROWS: std::sync::Mutex = std::sync::Mutex::new(String::new()); -/// The Player and Min kills boxes' text the list was last narrowed by; -/// `None` after a refill. +/// The Player box's text the list was last narrowed by; `None` after a +/// refill. static FILTERED: std::sync::Mutex> = std::sync::Mutex::new(None); /// The row keys Go reads: the bar time to seek to, and in an HLTV demo the @@ -47,14 +49,12 @@ const COLUMNS: [(&CStr, &CStr, i32); 5] = [ ]; const PLAYER_COLUMN: i32 = 1; const ROW_COLUMN_WIDE: i32 = 44; -const STATUS_LABEL: &str = "StreakStatus"; +/// The line under the list, beside Go (`Highlights.res`). +const HINT_LABEL: &str = "Hint"; const PROGRESS: &str = "StreakProgress"; -/// The Player box, and its label: shown for an HLTV demo only. +/// The Player box, and its label: shown once the streaks are found. const PLAYER_BOX: &str = "StreakPlayer"; const PLAYER_LABEL: &str = "StreakPlayerLabel"; -/// The Min kills box, and its label: shown once the streaks are found. -const MIN_KILLS_BOX: &str = "StreakMinKills"; -const MIN_KILLS_LABEL: &str = "StreakMinKillsLabel"; type KeyValuesCtor = unsafe extern "thiscall" fn( *mut c_void, @@ -163,13 +163,15 @@ pub(super) unsafe fn update(vgui: &Vgui) { let Some((generation, status, percent)) = streaks::status() else { return; }; - let file = path - .file_name() - .map_or(name.clone(), |f| f.to_string_lossy().into_owned()); match status { Status::Loading => { - let text = format!("Finding the highlights in {file}... {percent}%"); - show(vgui, page, build, (&text, &text), Some(percent)); + show( + vgui, + page, + build, + (&format!("loading {percent}"), GO_HINT), + Some(percent), + ); fill( list, base, @@ -181,9 +183,9 @@ pub(super) unsafe fn update(vgui: &Vgui) { show_filters(vgui, page, None); } Status::Ready(found) => { - let text = ready_text(&found, &file); + let text = ready_hint(&found); show(vgui, page, build, (&text, &text), None); - fill( + let refilled = fill( list, base, build, @@ -191,11 +193,14 @@ pub(super) unsafe fn update(vgui: &Vgui) { &found.streaks, found.hltv, ); + if refilled { + fill_player_box(vgui, page, &found); + } show_filters(vgui, page, Some(found.hltv)); narrow(vgui, page, list, found.hltv); } Status::Failed(why) => { - let text = format!("Could not find the highlights in {file}: {why}"); + let text = format!("Could not find the highlights: {why}"); show(vgui, page, build, (&text, &text), None); fill( list, @@ -211,86 +216,73 @@ pub(super) unsafe fn update(vgui: &Vgui) { } } -/// The status line once the streaks are found. -fn ready_text(found: &Found, file: &str) -> String { - let n = found.streaks.len(); - let lead = streaks::LEAD_IN_SECS; - match (&found.recorder, n) { - (Some(recorder), 0) => format!("No kills by {recorder} in {file}."), - (Some(recorder), _) => format!( - "{n} highlights by {recorder} in {file}. Go jumps to {lead} s before the first kill." - ), - (None, 0) => format!("No kills in {file}."), - (None, _) if found.hltv => { - format!("{n} highlights in {file}. Go jumps to {lead} s before and watches the player.") - } - (None, _) => { - format!("{n} highlights in {file}. Go jumps to {lead} s before the first kill.") +/// The hint under the list while the streaks are found or loading: what Go +/// does. +const GO_HINT: &str = "Double-click a streak, or pick one and Go."; + +/// The hint under the list once the streaks are found: what Go does, or that +/// there is nothing to go to. +fn ready_hint(found: &Found) -> String { + match (&found.recorder, found.streaks.is_empty()) { + (Some(recorder), true) => format!("No kills by {recorder} in this demo."), + (None, true) => "No kills in this demo.".to_string(), + (_, false) if found.hltv => { + "Double-click a streak, or pick one and Go: the camera follows its player.".to_string() } + (_, false) => GO_HINT.to_string(), } } -/// The status line's height for a control `name` on `page` fitted `tall`: -/// one line while the progress bar or the filter row under it shows, so a -/// wrapped second line never draws over them (#797: once the loading strip -/// moved the tab's controls, the line re-wrapped onto the Min kills row). A -/// failure, with nothing under it, keeps the room to wrap (#684). Any other -/// control keeps `tall`. -pub(super) unsafe fn status_tall(vgui: &Vgui, page: Vpanel, name: &str, tall: i32) -> i32 { - if name != STATUS_LABEL { - return tall; - } - let below = unsafe { - [PROGRESS, MIN_KILLS_BOX] - .iter() - .any(|&n| vgui.child_named(page, n).is_some_and(|vp| vgui.visible(vp))) - }; - if below { - tall.min(STATUS_ONE_LINE) - } else { - tall +/// The Player box for a newly listed demo: empty and editable in an HLTV +/// demo, the recording player's name and greyed out in a POV demo, which +/// lists no one else. +unsafe fn fill_player_box(vgui: &Vgui, page: Vpanel, found: &Found) { + unsafe { + let Some(entry) = vgui + .child_named(page, PLAYER_BOX) + .map(|vp| vgui.object(vp)) + .filter(|o| !o.is_null()) + else { + return; + }; + let name = match (&found.recorder, found.hltv) { + (Some(recorder), false) => recorder.replace('\0', ""), + _ => String::new(), + }; + if let Ok(name) = CString::new(name) { + let set_text: SetTextFn = slot(entry, TEXT_ENTRY_SLOT_SET_TEXT); + set_text(entry, name.as_ptr()); + } + let set_enabled: ListIntVoidFn = slot(entry, PANEL_SLOT_SET_ENABLED); + set_enabled(entry, i32::from(found.hltv)); } } -/// The status line's height as one line: it ends above the progress bar and -/// the filter row (`Highlights.res`: the line at y 6, those at 27 and 30). -const STATUS_ONE_LINE: i32 = 20; - -/// The filter boxes: none while nothing is listed (`None`), Min kills once -/// the streaks are found, and Player too for an HLTV demo (`Some(true)`). +/// The Player box and its label: hidden while nothing is listed (`None`), +/// shown once the streaks are found. unsafe fn show_filters(vgui: &Vgui, page: Vpanel, hltv: Option) { unsafe { - let shown = [ - (PLAYER_BOX, hltv == Some(true)), - (PLAYER_LABEL, hltv == Some(true)), - (MIN_KILLS_BOX, hltv.is_some()), - (MIN_KILLS_LABEL, hltv.is_some()), - ]; - for (name, on) in shown { + for name in [PLAYER_BOX, PLAYER_LABEL] { if let Some(vp) = vgui.child_named(page, name) - && vgui.visible(vp) != on + && vgui.visible(vp) != hltv.is_some() { - vgui.set_visible(vp, on); + vgui.set_visible(vp, hltv.is_some()); } } } } -/// Shows only the rows whose player matches the Player box (every word, case -/// ignored) with at least the Min kills box's kills, when either box changed -/// or the list was refilled. +/// In an HLTV demo, shows only the rows whose player matches the Player box +/// (every word, case ignored), when the box changed or the list was +/// refilled. A POV demo's box names its one player, so every row shows. unsafe fn narrow(vgui: &Vgui, page: Vpanel, list: *mut c_void, hltv: bool) { unsafe { - let player = if hltv { + let wanted = if hltv { box_text(vgui, page, PLAYER_BOX).trim().to_string() } else { String::new() }; - let min_kills: usize = box_text(vgui, page, MIN_KILLS_BOX) - .trim() - .parse() - .unwrap_or(0); - let wanted = format!("{player}|{min_kills}"); + let player = wanted.as_str(); let mut filtered = FILTERED.lock().unwrap_or_else(|e| e.into_inner()); if filtered.as_deref() == Some(wanted.as_str()) { return; @@ -315,8 +307,7 @@ unsafe fn narrow(vgui: &Vgui, page: Vpanel, list: *mut c_void, hltv: bool) { text(raw) } }; - let kills: usize = get(COLUMNS[1].0).trim().parse().unwrap_or(0); - let shown = matches_filter(&get(COLUMNS[0].0), &player) && kills >= min_kills; + let shown = matches_filter(&get(COLUMNS[0].0), player); set_visible(list, id, shown as u32); } id = next(list, id); @@ -325,8 +316,8 @@ unsafe fn narrow(vgui: &Vgui, page: Vpanel, list: *mut c_void, hltv: bool) { } } -/// Sets the status line (when `key` changed) and the progress bar: shown -/// with `percent`, hidden without. +/// Sets the hint line (when `key` changed) and the progress bar: shown with +/// `percent`, hidden without. unsafe fn show( vgui: &Vgui, page: Vpanel, @@ -339,7 +330,7 @@ unsafe fn show( if *shown == key { return; } - if let Some(label) = vgui.child_named(page, STATUS_LABEL) { + if let Some(label) = vgui.child_named(page, HINT_LABEL) { set_label(vgui.object(label), text); } if let Some(bar) = vgui.child_named(page, PROGRESS) { @@ -358,7 +349,7 @@ unsafe fn show( } } -/// Lists `streaks`, when `key` (which list it is) changed. +/// Lists `streaks`, when `key` (which list it is) changed; whether it did. unsafe fn fill( list: *mut c_void, base: usize, @@ -366,11 +357,11 @@ unsafe fn fill( key: &str, streaks: &[Streak], hltv: bool, -) { +) -> bool { unsafe { let mut shown = SHOWN_ROWS.lock().unwrap_or_else(|e| e.into_inner()); if *shown == key { - return; + return false; } *shown = key.to_string(); // Every row shows again: narrow it afresh. @@ -431,6 +422,7 @@ unsafe fn fill( streaks.len() )); } + true } } diff --git a/goldsrc-hooks/ui/Highlights.res b/goldsrc-hooks/ui/Highlights.res index d0a5bb16f..1cad98022 100644 --- a/goldsrc-hooks/ui/Highlights.res +++ b/goldsrc-hooks/ui/Highlights.res @@ -1,26 +1,11 @@ "dodstudio_ui/Highlights.res" { - "StreakStatus" - { - "ControlName" "Label" - "fieldName" "StreakStatus" - "xpos" "8" - "ypos" "6" - "wide" "520" - "tall" "52" - "visible" "1" - "enabled" "1" - "labelText" "" - "textAlignment" "north-west" - "wrap" "1" - "helptext" "The demo playing now, and how far along finding its highlights is." - } "StreakProgress" { "ControlName" "ProgressBar" "fieldName" "StreakProgress" "xpos" "8" - "ypos" "30" + "ypos" "9" "wide" "520" "tall" "14" "visible" "0" @@ -32,9 +17,9 @@ "ControlName" "Label" "fieldName" "StreakListSlot" "xpos" "8" - "ypos" "58" + "ypos" "34" "wide" "520" - "tall" "144" + "tall" "168" "visible" "1" "enabled" "1" "labelText" "" @@ -73,51 +58,25 @@ "ControlName" "Label" "fieldName" "StreakPlayerLabel" "xpos" "8" - "ypos" "27" + "ypos" "6" "wide" "44" "tall" "20" "visible" "0" "enabled" "1" "labelText" "Player" "textAlignment" "west" - "helptext" "Only this player's streaks: part of a name, or several words." + "helptext" "Only this player's streaks: part of a name, or several words. A POV demo has only the player who recorded it." } "StreakPlayer" { "ControlName" "TextEntry" "fieldName" "StreakPlayer" "xpos" "56" - "ypos" "27" + "ypos" "6" "wide" "180" "tall" "20" "visible" "0" "enabled" "1" - "helptext" "Only this player's streaks: part of a name, or several words." - } - "StreakMinKillsLabel" - { - "ControlName" "Label" - "fieldName" "StreakMinKillsLabel" - "xpos" "248" - "ypos" "27" - "wide" "56" - "tall" "20" - "visible" "0" - "enabled" "1" - "labelText" "Min kills" - "textAlignment" "west" - "helptext" "Only highlights with at least this many kills. Empty shows every one." - } - "StreakMinKills" - { - "ControlName" "TextEntry" - "fieldName" "StreakMinKills" - "xpos" "308" - "ypos" "27" - "wide" "40" - "tall" "20" - "visible" "0" - "enabled" "1" - "helptext" "Only highlights with at least this many kills. Empty shows every one." + "helptext" "Only this player's streaks: part of a name, or several words. A POV demo has only the player who recorded it." } } From b6d6938573c7716ae71465fed9446b4ace06cd72 Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:07:28 -0600 Subject: [PATCH 4/6] Highlights tab and Demo Analyzer: a demo that fails to parse says why in words, not a byte dump (#797) Co-Authored-By: Claude Opus 5.5 --- analysis/src/integrity.rs | 2 +- analysis/src/lib.rs | 7 ++++++- goldsrc-hooks/src/streaks.rs | 9 ++++++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/analysis/src/integrity.rs b/analysis/src/integrity.rs index 40b901048..d38ef346b 100644 --- a/analysis/src/integrity.rs +++ b/analysis/src/integrity.rs @@ -238,7 +238,7 @@ pub fn check_demo_bytes(bytes: &[u8]) -> Result { /// `nom_fail` puts its message in the nom error's `input`, which `Display` /// prints as a list of byte values; any other nom error's `input` is the rest /// of the file, so that is cut short instead of printed whole. -fn parse_error_reason(error: &str) -> String { +pub fn parse_error_reason(error: &str) -> String { const MAX: usize = 200; let message = error .split_once("input: [") diff --git a/analysis/src/lib.rs b/analysis/src/lib.rs index d9b1f5d55..bba51f983 100644 --- a/analysis/src/lib.rs +++ b/analysis/src/lib.rs @@ -773,7 +773,12 @@ impl Analysis { })); let demo = match demo_res { Ok(Ok(d)) => d, - Ok(Err(e)) => return Err(format!("Parse error: {}", e)), + Ok(Err(e)) => { + return Err(format!( + "Parse error: {}", + integrity::parse_error_reason(&e.to_string()) + )); + } Err(_) => return Err("Parser panicked during demo structural decoding".to_string()), }; diff --git a/goldsrc-hooks/src/streaks.rs b/goldsrc-hooks/src/streaks.rs index d7d3b0c87..bef053791 100644 --- a/goldsrc-hooks/src/streaks.rs +++ b/goldsrc-hooks/src/streaks.rs @@ -313,7 +313,14 @@ fn find(path: &Path, generation: u64) -> Result<(Found, &'static str), String> { "DoD Studio read it, but its result is not in the cache".to_string() }); } - Some(Err(why)) => return Err(format!("DoD Studio could not read it: {why}")), + // An older Studio sends the parse error with its byte dump; the + // Highlights tab has one line for it. + Some(Err(why)) => { + return Err(format!( + "DoD Studio could not read it: {}", + analysis::integrity::parse_error_reason(&why) + )); + } None => {} } let size = std::fs::metadata(path) From 394c43a0571dc6120bfb658cac0f27018a8df304 Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:15:40 -0600 Subject: [PATCH 5/6] Highlights tab: rows stay in Row # order after typing a name and deleting it (#797) ListPanel::SetItemVisible puts a row shown again at the end of the visible rows, so narrowing and widening the Player filter shuffled them. Co-Authored-By: Claude Opus 5.5 --- goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs index d7229f101..8ccd2dc94 100644 --- a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs +++ b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs @@ -197,7 +197,7 @@ pub(super) unsafe fn update(vgui: &Vgui) { fill_player_box(vgui, page, &found); } show_filters(vgui, page, Some(found.hltv)); - narrow(vgui, page, list, found.hltv); + narrow(vgui, page, list, build, found.hltv); } Status::Failed(why) => { let text = format!("Could not find the highlights: {why}"); @@ -275,7 +275,7 @@ unsafe fn show_filters(vgui: &Vgui, page: Vpanel, hltv: Option) { /// In an HLTV demo, shows only the rows whose player matches the Player box /// (every word, case ignored), when the box changed or the list was /// refilled. A POV demo's box names its one player, so every row shows. -unsafe fn narrow(vgui: &Vgui, page: Vpanel, list: *mut c_void, hltv: bool) { +unsafe fn narrow(vgui: &Vgui, page: Vpanel, list: *mut c_void, build: &Build, hltv: bool) { unsafe { let wanted = if hltv { box_text(vgui, page, PLAYER_BOX).trim().to_string() @@ -312,6 +312,11 @@ unsafe fn narrow(vgui: &Vgui, page: Vpanel, list: *mut c_void, hltv: bool) { } id = next(list, id); } + // A row shown again goes to the end of the list's visible rows, not + // back to its place, so typing a name and then deleting some of it + // left those rows out of order. Sort again (by Row # unless a heading + // was clicked), and start at the top of the shorter list (#796). + crate::list_panel::after_refill(list, build.list_sort_column, Some(0)); *filtered = Some(wanted); } } From 62b2068c05eeeb860bc3ee55cfcee85ec88bb1e1 Mon Sep 17 00:00:00 2001 From: ccoventry <17183194+ccoventry@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:19:32 -0600 Subject: [PATCH 6/6] Highlights tab: the Player filter lists the matching rows again in Row # order, with no heading sort forced (#797) Instead of hiding rows (SetItemVisible puts a row shown again at the end) and then sorting by Row #, typing in the Player box refills the list with the matching streaks in highlight order. A heading the user clicked still sorts it. Co-Authored-By: Claude Opus 5.5 --- .../src/studio_panel/hook/streaks_tab.rs | 103 +++++++----------- 1 file changed, 37 insertions(+), 66 deletions(-) diff --git a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs index 8ccd2dc94..7245f9650 100644 --- a/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs +++ b/goldsrc-hooks/src/studio_panel/hook/streaks_tab.rs @@ -29,9 +29,9 @@ static ON_COMMAND: AtomicUsize = AtomicUsize::new(0); /// it changes. static SHOWN_STATUS: std::sync::Mutex = std::sync::Mutex::new(String::new()); static SHOWN_ROWS: std::sync::Mutex = std::sync::Mutex::new(String::new()); -/// The Player box's text the list was last narrowed by; `None` after a -/// refill. -static FILTERED: std::sync::Mutex> = std::sync::Mutex::new(None); +/// The list the Player box was last set for, so it is set once per demo and +/// then left to what is typed. +static BOXED_FOR: std::sync::Mutex = std::sync::Mutex::new(String::new()); /// The row keys Go reads: the bar time to seek to, and in an HLTV demo the /// player's number to put the camera on. @@ -89,6 +89,7 @@ pub(super) unsafe fn build( .unwrap_or_else(|e| e.into_inner()) .clear(); SHOWN_ROWS.lock().unwrap_or_else(|e| e.into_inner()).clear(); + BOXED_FOR.lock().unwrap_or_else(|e| e.into_inner()).clear(); let list = *((dialog as *const u8).add(build.frame_size) as *const *mut c_void); if list.is_null() { @@ -153,7 +154,7 @@ pub(super) unsafe fn update(vgui: &Vgui) { ), None, ); - fill(list, base, build, "none", &[], false); + fill(list, base, build, "none", &[], false, ""); show_filters(vgui, page, None); return; }; @@ -179,25 +180,37 @@ pub(super) unsafe fn update(vgui: &Vgui) { &format!("{generation}:loading"), &[], false, + "", ); show_filters(vgui, page, None); } Status::Ready(found) => { let text = ready_hint(&found); show(vgui, page, build, (&text, &text), None); - let refilled = fill( + let demo_key = format!("{generation}:ready"); + { + let mut boxed = BOXED_FOR.lock().unwrap_or_else(|e| e.into_inner()); + if *boxed != demo_key { + fill_player_box(vgui, page, &found); + *boxed = demo_key.clone(); + } + } + // A POV demo's box names its one player: every row shows. + let player = if found.hltv { + box_text(vgui, page, PLAYER_BOX).trim().to_string() + } else { + String::new() + }; + fill( list, base, build, - &format!("{generation}:ready"), + &format!("{demo_key}|{player}"), &found.streaks, found.hltv, + &player, ); - if refilled { - fill_player_box(vgui, page, &found); - } show_filters(vgui, page, Some(found.hltv)); - narrow(vgui, page, list, build, found.hltv); } Status::Failed(why) => { let text = format!("Could not find the highlights: {why}"); @@ -209,6 +222,7 @@ pub(super) unsafe fn update(vgui: &Vgui) { &format!("{generation}:failed"), &[], false, + "", ); show_filters(vgui, page, None); } @@ -272,55 +286,6 @@ unsafe fn show_filters(vgui: &Vgui, page: Vpanel, hltv: Option) { } } -/// In an HLTV demo, shows only the rows whose player matches the Player box -/// (every word, case ignored), when the box changed or the list was -/// refilled. A POV demo's box names its one player, so every row shows. -unsafe fn narrow(vgui: &Vgui, page: Vpanel, list: *mut c_void, build: &Build, hltv: bool) { - unsafe { - let wanted = if hltv { - box_text(vgui, page, PLAYER_BOX).trim().to_string() - } else { - String::new() - }; - let player = wanted.as_str(); - let mut filtered = FILTERED.lock().unwrap_or_else(|e| e.into_inner()); - if filtered.as_deref() == Some(wanted.as_str()) { - return; - } - let first: ListFirstFn = slot(list, LIST_SLOT_FIRST_ITEM); - let next: ListItemIdFn = slot(list, LIST_SLOT_NEXT_ITEM); - let is_valid: ListIntFn = slot(list, LIST_SLOT_IS_VALID_ITEM_ID); - let get_item: ListItemFn = slot(list, LIST_SLOT_GET_ITEM); - let set_visible: ListSetVisibleFn = slot(list, LIST_SLOT_SET_ITEM_VISIBLE); - let mut id = first(list); - let mut guard = 0; - while is_valid(list, id) & 0xff != 0 && guard < 100_000 { - guard += 1; - let row = get_item(list, id); - if !row.is_null() { - let get_string: GetStringFn = slot(row, KEYVALUES_SLOT_GET_STRING); - let get = |key: &CStr| { - let raw = get_string(row, key.as_ptr(), c"".as_ptr()); - if raw.is_null() { - String::new() - } else { - text(raw) - } - }; - let shown = matches_filter(&get(COLUMNS[0].0), player); - set_visible(list, id, shown as u32); - } - id = next(list, id); - } - // A row shown again goes to the end of the list's visible rows, not - // back to its place, so typing a name and then deleting some of it - // left those rows out of order. Sort again (by Row # unless a heading - // was clicked), and start at the top of the shorter list (#796). - crate::list_panel::after_refill(list, build.list_sort_column, Some(0)); - *filtered = Some(wanted); - } -} - /// Sets the hint line (when `key` changed) and the progress bar: shown with /// `percent`, hidden without. unsafe fn show( @@ -354,7 +319,11 @@ unsafe fn show( } } -/// Lists `streaks`, when `key` (which list it is) changed; whether it did. +/// Lists the `streaks` whose player matches `player` (every word, case +/// ignored; "" matches all), when `key` (which list, and which filter) +/// changed. Each keeps its Row # from the whole list. Typing in the Player +/// box lists the rows again rather than hiding the others: a row +/// `SetItemVisible` shows again goes to the end of the list, out of order. unsafe fn fill( list: *mut c_void, base: usize, @@ -362,15 +331,14 @@ unsafe fn fill( key: &str, streaks: &[Streak], hltv: bool, -) -> bool { + player: &str, +) { unsafe { let mut shown = SHOWN_ROWS.lock().unwrap_or_else(|e| e.into_inner()); if *shown == key { - return false; + return; } *shown = key.to_string(); - // Every row shows again: narrow it afresh. - *FILTERED.lock().unwrap_or_else(|e| e.into_inner()) = None; let delete_all: ListVoidFn = slot(list, LIST_SLOT_DELETE_ALL_ITEMS); delete_all(list); // One player in a POV demo: no Player column. @@ -380,6 +348,9 @@ unsafe fn fill( let ctor: KeyValuesCtor = std::mem::transmute(base + build.keyvalues_ctor); let add_item: AddItemFn = slot(list, LIST_SLOT_ADD_ITEM); for (index, streak) in streaks.iter().enumerate() { + if !matches_filter(&streak.player, player) { + continue; + } let target = match (hltv, streak.player_number) { (true, Some(number)) => number.to_string(), _ => String::new(), @@ -408,7 +379,8 @@ unsafe fn fill( } } // The list keeps its own copy; `row` stays ours, and is left - // (a few dozen bytes a row, once per demo) rather than freed + // (a few hundred bytes a row, per demo and per change to the + // Player box) rather than freed // through a slot nothing here has verified. add_item(list, row, 0, 0, 0); } @@ -431,7 +403,6 @@ unsafe fn fill( streaks.len() )); } - true } }