diff --git a/build_support/localization.rs b/build_support/localization.rs index 22ded5c..e73add0 100644 --- a/build_support/localization.rs +++ b/build_support/localization.rs @@ -114,6 +114,10 @@ const MENU_STATUS_HELP_KEYS: &[(&str, &str)] = &[ "IDM_SERVICE_GOTOPROCESS", "crate::ui::resource_ids::IDM_SERVICE_GOTOPROCESS", ), + ( + "IDM_PROC_FILTER", + "crate::ui::resource_ids::IDM_PROC_FILTER", + ), ]; pub(crate) fn generate() { diff --git a/localization/de.toml b/localization/de.toml index a0110c5..c24ccc5 100644 --- a/localization/de.toml +++ b/localization/de.toml @@ -317,6 +317,8 @@ ServiceStartFailed = "Der Dienst konnte nicht gestartet werden." ServiceStopFailed = "Der Dienst konnte nicht beendet werden." ServiceStateUnchanged = "Der Dienst hat den angeforderten Zustand nicht erreicht." ServiceProcessPid = "PID" +ProcessNameFilterLabel = "Filter:" +ProcessNameFilterMenu = "Nach &Name filtern\tStrg+F" [menu_status_help] IDM_RUN = "Runs a new program" @@ -352,3 +354,4 @@ IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" IDM_SERVICE_START = "Startet den ausgewählten Dienst" IDM_SERVICE_STOP = "Beendet den ausgewählten Dienst" IDM_SERVICE_GOTOPROCESS = "Wählt den Prozess des Diensts auf der Seite Prozesse aus" +IDM_PROC_FILTER = "Zeigt nur Prozesse, deren Abbildname den eingegebenen Text enthält" diff --git a/localization/en_us.toml b/localization/en_us.toml index 1975f4a..ba59671 100644 --- a/localization/en_us.toml +++ b/localization/en_us.toml @@ -324,6 +324,8 @@ ServiceStartFailed = "Unable to start the service." ServiceStopFailed = "Unable to stop the service." ServiceStateUnchanged = "The service did not enter the requested state." ServiceProcessPid = "PID" +ProcessNameFilterLabel = "Filter:" +ProcessNameFilterMenu = "Filter by &Name\tCtrl+F" [menu_status_help] IDM_RUN = "Runs a new program" @@ -359,3 +361,4 @@ IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" IDM_SERVICE_START = "Starts the selected service" IDM_SERVICE_STOP = "Stops the selected service" IDM_SERVICE_GOTOPROCESS = "Selects the service process on the Processes page" +IDM_PROC_FILTER = "Shows only processes whose image name contains the typed text" diff --git a/localization/es.toml b/localization/es.toml index ea81720..3c2566c 100644 --- a/localization/es.toml +++ b/localization/es.toml @@ -317,6 +317,8 @@ ServiceStartFailed = "No se pudo iniciar el servicio." ServiceStopFailed = "No se pudo detener el servicio." ServiceStateUnchanged = "El servicio no entró en el estado solicitado." ServiceProcessPid = "PID" +ProcessNameFilterLabel = "Filtro:" +ProcessNameFilterMenu = "Filtrar por &nombre\tCtrl+F" [menu_status_help] IDM_RUN = "Runs a new program" @@ -352,3 +354,4 @@ IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" IDM_SERVICE_START = "Inicia el servicio seleccionado" IDM_SERVICE_STOP = "Detiene el servicio seleccionado" IDM_SERVICE_GOTOPROCESS = "Selecciona el proceso del servicio en la página Procesos" +IDM_PROC_FILTER = "Muestra solo los procesos cuyo nombre de imagen contiene el texto escrito" diff --git a/localization/fr.toml b/localization/fr.toml index c42955e..c0b57f7 100644 --- a/localization/fr.toml +++ b/localization/fr.toml @@ -317,6 +317,8 @@ ServiceStartFailed = "Impossible de démarrer le service." ServiceStopFailed = "Impossible d'arrêter le service." ServiceStateUnchanged = "Le service n'a pas atteint l'état demandé." ServiceProcessPid = "PID" +ProcessNameFilterLabel = "Filtre :" +ProcessNameFilterMenu = "Filtrer par &nom\tCtrl+F" [menu_status_help] IDM_RUN = "Runs a new program" @@ -352,3 +354,4 @@ IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" IDM_SERVICE_START = "Démarre le service sélectionné" IDM_SERVICE_STOP = "Arrête le service sélectionné" IDM_SERVICE_GOTOPROCESS = "Sélectionne le processus du service dans la page Processus" +IDM_PROC_FILTER = "Affiche uniquement les processus dont le nom d'image contient le texte saisi" diff --git a/localization/pt.toml b/localization/pt.toml index 847ce6b..2573d3a 100644 --- a/localization/pt.toml +++ b/localization/pt.toml @@ -317,6 +317,8 @@ ServiceStartFailed = "Não foi possível iniciar o serviço." ServiceStopFailed = "Não foi possível parar o serviço." ServiceStateUnchanged = "O serviço não entrou no estado solicitado." ServiceProcessPid = "PID" +ProcessNameFilterLabel = "Filtro:" +ProcessNameFilterMenu = "Filtrar por &nome\tCtrl+F" [menu_status_help] IDM_RUN = "Runs a new program" @@ -352,3 +354,4 @@ IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" IDM_SERVICE_START = "Inicia o serviço selecionado" IDM_SERVICE_STOP = "Para o serviço selecionado" IDM_SERVICE_GOTOPROCESS = "Seleciona o processo do serviço na página Processos" +IDM_PROC_FILTER = "Mostra apenas os processos cujo nome de imagem contém o texto digitado" diff --git a/localization/ru.toml b/localization/ru.toml index ae9df1b..ca8ffb9 100644 --- a/localization/ru.toml +++ b/localization/ru.toml @@ -317,6 +317,8 @@ ServiceStartFailed = "Не удалось запустить службу." ServiceStopFailed = "Не удалось остановить службу." ServiceStateUnchanged = "Служба не перешла в запрошенное состояние." ServiceProcessPid = "PID" +ProcessNameFilterLabel = "Фильтр:" +ProcessNameFilterMenu = "Фильтр по &имени\tCtrl+F" [menu_status_help] IDM_RUN = "Runs a new program" @@ -352,3 +354,4 @@ IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" IDM_SERVICE_START = "Запускает выбранную службу" IDM_SERVICE_STOP = "Останавливает выбранную службу" IDM_SERVICE_GOTOPROCESS = "Выбирает процесс службы на странице процессов" +IDM_PROC_FILTER = "Показывает только процессы, имя образа которых содержит введенный текст" diff --git a/localization/zh_cn.toml b/localization/zh_cn.toml index 77b2fbe..5fb4310 100644 --- a/localization/zh_cn.toml +++ b/localization/zh_cn.toml @@ -314,6 +314,8 @@ ServiceStartFailed = "无法启动该服务。" ServiceStopFailed = "无法停止该服务。" ServiceStateUnchanged = "该服务没有进入请求的状态。" ServiceProcessPid = "PID" +ProcessNameFilterLabel = "筛选:" +ProcessNameFilterMenu = "按名称筛选(&N)\tCtrl+F" [menu_status_help] IDM_RUN = "Runs a new program" @@ -349,3 +351,4 @@ IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" IDM_SERVICE_START = "启动所选服务" IDM_SERVICE_STOP = "停止所选服务" IDM_SERVICE_GOTOPROCESS = "在进程页选中该服务的进程" +IDM_PROC_FILTER = "只显示映像名称包含所输入文本的进程" diff --git a/localization/zh_tw.toml b/localization/zh_tw.toml index 70843bf..9732bd7 100644 --- a/localization/zh_tw.toml +++ b/localization/zh_tw.toml @@ -314,6 +314,8 @@ ServiceStartFailed = "無法啟動服務。" ServiceStopFailed = "無法停止服務。" ServiceStateUnchanged = "服務沒有進入要求的狀態。" ServiceProcessPid = "PID" +ProcessNameFilterLabel = "篩選:" +ProcessNameFilterMenu = "依名稱篩選(&N)\tCtrl+F" [menu_status_help] IDM_RUN = "Runs a new program" @@ -349,3 +351,4 @@ IDM_TASK_BRINGTOFRONT = "Brings the window front, but does not switch to it" IDM_SERVICE_START = "啟動選取的服務" IDM_SERVICE_STOP = "停止選取的服務" IDM_SERVICE_GOTOPROCESS = "在處理程序頁選取該服務的處理程序" +IDM_PROC_FILTER = "只顯示映像名稱包含所輸入文字的處理程序" diff --git a/src/app/mod.rs b/src/app/mod.rs index 8345545..8256b57 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1172,6 +1172,9 @@ impl App { .unwrap_or(false), ); } + if self.options.current_page == PageId::Processes.persisted() { + self.enable_menu_item(menu, IDM_PROC_FILTER, true); + } if self.options.current_page == PageId::Services.persisted() { let commands = self.pages[PageId::Services.index()] .service_command_state() @@ -2249,6 +2252,13 @@ impl App { SendMessageW(task_hwnd, WM_COMMAND, command_id as usize, 0); } } + IDM_PROC_FILTER => { + if self.options.current_page == PageId::Processes.persisted() { + self.pages[PageId::Processes.index()].focus_process_name_filter(); + } else { + MessageBeep(0); + } + } IDM_PROCCOLS | IDM_AFFINITY | IDM_PROC_DEBUG diff --git a/src/app/page_host.rs b/src/app/page_host.rs index bab377d..3c2692f 100644 --- a/src/app/page_host.rs +++ b/src/app/page_host.rs @@ -506,7 +506,8 @@ impl PageState { match msg { WM_COMMAND => match self { Self::Gpu(state) => Some(state.handle_command(wparam)), - Self::Task(_) | Self::Process(_) | Self::Users(_) | Self::Services(_) => { + Self::Process(state) => Some(state.handle_window_command(wparam)), + Self::Task(_) | Self::Users(_) | Self::Services(_) => { Some(self.handle_page_command((wparam & 0xFFFF) as u16)) } _ => None, @@ -900,6 +901,12 @@ impl DialogPage { self.state.handle_process_command(command_id, options) } + pub fn focus_process_name_filter(&mut self) { + if let PageState::Process(state) = &mut self.state { + state.focus_name_filter(); + } + } + pub fn handle_user_command(&mut self, command_id: u16) -> bool { self.state.handle_user_command(command_id) } diff --git a/src/pages/processes/filter.rs b/src/pages/processes/filter.rs new file mode 100644 index 0000000..3eb55a2 --- /dev/null +++ b/src/pages/processes/filter.rs @@ -0,0 +1,474 @@ +// +------------------------------------------------------------------------- +// +// taskmgr-rs - 进程名称筛选 +// +// 文件: src/pages/processes/filter.rs +// +// 日期: 2026年10月20日 +// 环境: Linux 6.12.94+ x86_64;Rust 1.99.1 +// 作者: OpenAI Codex +// -------------------------------------------------------------------------- + +//! UI-thread view of an already committed process snapshot. +//! +//! The filter never opens a process, token, or icon, and it does not change +//! what the sampler collects. An empty needle shows every row. A needle with +//! no hits yields an empty visible list; that is not a refresh error. +//! Hidden rows are omitted from the virtual-list model so the owner-data +//! callback never formats them. + +use super::SelectionScrollPolicy; +use crate::system::process_identity::ProcIdentity; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(super) struct ProcessNameFilter { + needle: String, +} + +impl ProcessNameFilter { + pub(super) fn from_text(raw: &str) -> Self { + Self { + needle: raw.trim().to_lowercase(), + } + } + + pub(super) fn is_empty(&self) -> bool { + self.needle.is_empty() + } + + /// `image_name_lower` must already be the lowercase image name, without an + /// architecture suffix. Matching is a substring test. + pub(super) fn matches(&self, image_name_lower: &str) -> bool { + self.needle.is_empty() || image_name_lower.contains(&self.needle) + } +} + +/// Why the visible process list is being rebuilt. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ProcessViewUpdate { + /// Timer or F5. Keep the current scroll position. + Refresh, + /// The user changed the filter text. + FilterChanged, + /// An explicit navigation, such as Go To Process, must bring the row into view. + RevealIdentity, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ProcessNameView { + /// Indexes into the unfiltered snapshot, in that snapshot's order. + pub(super) visible_indices: Vec, + /// Identity to restore. Kept while that process remains in the unfiltered + /// snapshot, including when the active filter hides its row. + pub(super) identity_cache: Option, + /// Index into `visible_indices`. `None` when the list has nothing to highlight. + pub(super) selected_list_index: Option, + pub(super) scroll: SelectionScrollPolicy, +} + +pub(super) fn plan_process_name_view( + rows: &[(ProcIdentity, &str)], + filter: &ProcessNameFilter, + identity_cache: Option, + visible_selection: Option, + neighbor_hint: Option, + update: ProcessViewUpdate, +) -> ProcessNameView { + let visible_indices = rows + .iter() + .enumerate() + .filter(|(_, (_, image_name_lower))| filter.matches(image_name_lower)) + .map(|(index, _)| index) + .collect::>(); + + let cache_entry = identity_cache.and_then(|identity| { + entry_index(rows, identity).map(|entry_index| (identity, entry_index)) + }); + + if let Some((cached_id, entry_index)) = cache_entry { + if let Some(list_index) = list_index(&visible_indices, rows, cached_id) { + return view( + visible_indices, + Some(cached_id), + Some(list_index), + rows, + visible_selection, + update, + ); + } + + // The cached process is still in the snapshot, but the active filter + // hides it. Keep the cache and highlight a row the user can see. + let list_index = + fallback_list_index(rows, &visible_indices, Some(entry_index), visible_selection); + return view( + visible_indices, + Some(cached_id), + list_index, + rows, + visible_selection, + update, + ); + } + + let had_selection = identity_cache.is_some() || visible_selection.is_some(); + let surviving_highlight = visible_selection + .and_then(|identity| list_index(&visible_indices, rows, identity)) + .or(neighbor_hint.and_then(|identity| list_index(&visible_indices, rows, identity))); + let list_index = if surviving_highlight.is_some() { + surviving_highlight + } else if had_selection && !visible_indices.is_empty() { + Some(0) + } else { + None + }; + let new_cache = list_index.map(|index| rows[visible_indices[index]].0); + view( + visible_indices, + new_cache, + list_index, + rows, + visible_selection, + update, + ) +} + +pub(super) fn neighbor_after( + identities: &[ProcIdentity], + identity: ProcIdentity, +) -> Option { + let index = identities + .iter() + .position(|candidate| *candidate == identity)?; + identities.get(index + 1).copied().or_else(|| { + index + .checked_sub(1) + .and_then(|previous| identities.get(previous).copied()) + }) +} + +fn view( + visible_indices: Vec, + identity_cache: Option, + selected_list_index: Option, + rows: &[(ProcIdentity, &str)], + previous_highlight: Option, + update: ProcessViewUpdate, +) -> ProcessNameView { + let highlight = selected_list_index.map(|index| rows[visible_indices[index]].0); + let reveal_selection = match update { + ProcessViewUpdate::RevealIdentity => true, + ProcessViewUpdate::Refresh => false, + ProcessViewUpdate::FilterChanged => highlight != previous_highlight, + }; + let scroll = super::refresh_selection_scroll_policy(reveal_selection); + ProcessNameView { + visible_indices, + identity_cache, + selected_list_index, + scroll, + } +} + +fn entry_index(rows: &[(ProcIdentity, &str)], identity: ProcIdentity) -> Option { + rows.iter() + .position(|(candidate, _)| *candidate == identity) +} + +fn list_index( + visible_indices: &[usize], + rows: &[(ProcIdentity, &str)], + identity: ProcIdentity, +) -> Option { + visible_indices + .iter() + .position(|&index| rows[index].0 == identity) +} + +fn fallback_list_index( + rows: &[(ProcIdentity, &str)], + visible_indices: &[usize], + anchor_entry: Option, + preferred: Option, +) -> Option { + if let Some(identity) = preferred + && let Some(index) = list_index(visible_indices, rows, identity) + { + return Some(index); + } + let Some(anchor) = anchor_entry else { + return if visible_indices.is_empty() { + None + } else { + Some(0) + }; + }; + visible_indices + .iter() + .enumerate() + .min_by_key(|(_, entry_index)| entry_index.abs_diff(anchor)) + .map(|(list_index, _)| list_index) +} + +#[cfg(test)] +mod tests { + use super::{ProcessNameFilter, ProcessViewUpdate, neighbor_after, plan_process_name_view}; + use crate::pages::processes::SelectionScrollPolicy; + use crate::system::process_identity::ProcIdentity; + + struct Row { + identity: ProcIdentity, + name: &'static str, + } + + fn id(pid: u32) -> ProcIdentity { + ProcIdentity::new(pid, u64::from(pid) + 10) + } + + fn sample() -> Vec { + vec![ + Row { + identity: id(1), + name: "alpha.exe", + }, + Row { + identity: id(2), + name: "bravo.exe", + }, + Row { + identity: id(3), + name: "charlie.exe", + }, + ] + } + + fn plan( + rows: &[Row], + filter: &str, + cache: Option, + visible: Option, + neighbor: Option, + update: ProcessViewUpdate, + ) -> super::ProcessNameView { + let borrowed = rows + .iter() + .map(|row| (row.identity, row.name)) + .collect::>(); + plan_process_name_view( + &borrowed, + &ProcessNameFilter::from_text(filter), + cache, + visible, + neighbor, + update, + ) + } + + fn highlighted(rows: &[Row], view: &super::ProcessNameView) -> Option { + view.selected_list_index + .map(|index| rows[view.visible_indices[index]].identity) + } + + fn visible_names<'a>(rows: &'a [Row], view: &super::ProcessNameView) -> Vec<&'a str> { + view.visible_indices + .iter() + .map(|&index| rows[index].name) + .collect() + } + + #[test] + fn empty_and_whitespace_filters_show_the_full_list() { + let rows = sample(); + for filter in ["", " ", "\t"] { + let view = plan( + &rows, + filter, + Some(id(2)), + Some(id(2)), + None, + ProcessViewUpdate::Refresh, + ); + assert_eq!( + visible_names(&rows, &view), + ["alpha.exe", "bravo.exe", "charlie.exe"] + ); + assert_eq!(view.identity_cache, Some(id(2))); + assert_eq!(highlighted(&rows, &view), Some(id(2))); + assert_eq!(view.scroll, SelectionScrollPolicy::PreserveViewport); + } + } + + #[test] + fn name_filter_is_a_case_insensitive_image_name_substring() { + let rows = sample(); + let view = plan( + &rows, + " CHAR ", + Some(id(3)), + Some(id(3)), + None, + ProcessViewUpdate::FilterChanged, + ); + assert_eq!(visible_names(&rows, &view), ["charlie.exe"]); + assert_eq!(highlighted(&rows, &view), Some(id(3))); + assert!(!ProcessNameFilter::from_text("32").matches("demo.exe")); + assert!(ProcessNameFilter::from_text("demo").matches("demo.exe")); + } + + #[test] + fn no_matches_leave_an_empty_list_and_keep_the_cached_identity() { + let rows = sample(); + let view = plan( + &rows, + "zzz", + Some(id(2)), + Some(id(2)), + None, + ProcessViewUpdate::FilterChanged, + ); + assert!(view.visible_indices.is_empty()); + assert_eq!(highlighted(&rows, &view), None); + assert_eq!(view.identity_cache, Some(id(2))); + assert_eq!(view.scroll, SelectionScrollPolicy::RevealSelection); + } + + #[test] + fn hidden_selection_moves_to_a_visible_match_without_dropping_the_cache() { + let rows = sample(); + let view = plan( + &rows, + "c", + Some(id(2)), + Some(id(2)), + None, + ProcessViewUpdate::FilterChanged, + ); + assert_eq!(visible_names(&rows, &view), ["charlie.exe"]); + assert_eq!(highlighted(&rows, &view), Some(id(3))); + assert_eq!(view.identity_cache, Some(id(2))); + assert_eq!(view.scroll, SelectionScrollPolicy::RevealSelection); + } + + #[test] + fn clearing_the_filter_restores_a_cached_identity_that_is_still_present() { + let rows = sample(); + let view = plan( + &rows, + "", + Some(id(2)), + Some(id(3)), + None, + ProcessViewUpdate::FilterChanged, + ); + assert_eq!(highlighted(&rows, &view), Some(id(2))); + assert_eq!(view.identity_cache, Some(id(2))); + assert_eq!(view.scroll, SelectionScrollPolicy::RevealSelection); + } + + #[test] + fn refresh_keeps_the_visible_highlight_and_the_hidden_cache() { + let rows = sample(); + let view = plan( + &rows, + "alp", + Some(id(2)), + Some(id(1)), + None, + ProcessViewUpdate::Refresh, + ); + assert_eq!(visible_names(&rows, &view), ["alpha.exe"]); + assert_eq!(highlighted(&rows, &view), Some(id(1))); + assert_eq!(view.identity_cache, Some(id(2))); + assert_eq!(view.scroll, SelectionScrollPolicy::PreserveViewport); + } + + #[test] + fn a_departed_process_moves_selection_to_its_neighbor() { + let rows = [ + Row { + identity: id(1), + name: "alpha.exe", + }, + Row { + identity: id(3), + name: "charlie.exe", + }, + ]; + let view = plan( + &rows, + "", + Some(id(2)), + Some(id(2)), + Some(id(3)), + ProcessViewUpdate::Refresh, + ); + assert_eq!(highlighted(&rows, &view), Some(id(3))); + assert_eq!(view.identity_cache, Some(id(3))); + assert_eq!(view.scroll, SelectionScrollPolicy::PreserveViewport); + } + + #[test] + fn an_unselected_list_is_not_given_a_selection_by_filtering() { + let rows = sample(); + let view = plan( + &rows, + "alp", + None, + None, + None, + ProcessViewUpdate::FilterChanged, + ); + assert_eq!(visible_names(&rows, &view), ["alpha.exe"]); + assert_eq!(highlighted(&rows, &view), None); + assert_eq!(view.identity_cache, None); + } + + #[test] + fn explicit_navigation_reveals_the_cached_process() { + let rows = sample(); + let view = plan( + &rows, + "", + Some(id(3)), + Some(id(1)), + None, + ProcessViewUpdate::RevealIdentity, + ); + assert_eq!(highlighted(&rows, &view), Some(id(3))); + assert_eq!(view.scroll, SelectionScrollPolicy::RevealSelection); + } + + #[test] + fn pid_reuse_keeps_the_cached_creation_time() { + let rows = [ + Row { + identity: ProcIdentity::new(7, 100), + name: "old.exe", + }, + Row { + identity: ProcIdentity::new(7, 200), + name: "new.exe", + }, + ]; + let view = plan( + &rows, + "new", + Some(ProcIdentity::new(7, 100)), + Some(ProcIdentity::new(7, 100)), + None, + ProcessViewUpdate::FilterChanged, + ); + assert_eq!(visible_names(&rows, &view), ["new.exe"]); + assert_eq!(highlighted(&rows, &view), Some(ProcIdentity::new(7, 200))); + assert_eq!(view.identity_cache, Some(ProcIdentity::new(7, 100))); + } + + #[test] + fn neighbor_after_prefers_the_following_row() { + let identities = [id(1), id(2), id(3)]; + assert_eq!(neighbor_after(&identities, id(1)), Some(id(2))); + assert_eq!(neighbor_after(&identities, id(3)), Some(id(2))); + assert_eq!(neighbor_after(&identities, id(9)), None); + assert_eq!(neighbor_after(&[id(4)], id(4)), None); + } +} diff --git a/src/pages/processes/mod.rs b/src/pages/processes/mod.rs index 165d155..9719281 100644 --- a/src/pages/processes/mod.rs +++ b/src/pages/processes/mod.rs @@ -9,12 +9,15 @@ // -------------------------------------------------------------------------- mod actions; +mod filter; mod model; mod sampler; // 进程页实现。 // 这里负责采集进程列表、计算每轮刷新之间的增量数据、维护排序状态, // 并处理结束进程、调试、设置优先级和亲和性等操作。 +// 名称筛选是 UI 线程上的视图:只过滤已经提交的快照,不查询进程、令牌或图标, +// 也不把筛选条件交给采样线程。 // // 线程模型: // SingleFlightWorker 工作线程在后台执行 collect_process_entries() 采样, @@ -36,28 +39,30 @@ use windows_sys::Win32::System::Threading::{ IDLE_PRIORITY_CLASS, REALTIME_PRIORITY_CLASS, }; use windows_sys::Win32::UI::Controls::{ - BST_CHECKED, BST_UNCHECKED, CheckDlgButton, IsDlgButtonChecked, LVCF_FMT, LVCF_SUBITEM, - LVCF_TEXT, LVCF_WIDTH, LVCFMT_LEFT, LVCFMT_RIGHT, LVCOLUMNW, LVIF_STATE, LVIF_TEXT, - LVIS_FOCUSED, LVIS_SELECTED, LVITEMW, LVM_DELETECOLUMN, LVM_ENSUREVISIBLE, + BST_CHECKED, BST_UNCHECKED, CheckDlgButton, EM_SETSEL, IsDlgButtonChecked, LVCF_FMT, + LVCF_SUBITEM, LVCF_TEXT, LVCF_WIDTH, LVCFMT_LEFT, LVCFMT_RIGHT, LVCOLUMNW, LVIF_STATE, + LVIF_TEXT, LVIS_FOCUSED, LVIS_SELECTED, LVITEMW, LVM_DELETECOLUMN, LVM_ENSUREVISIBLE, LVM_GETCOLUMNORDERARRAY, LVM_GETCOLUMNWIDTH, LVM_GETCOUNTPERPAGE, LVM_GETITEMCOUNT, LVM_GETNEXTITEM, LVM_GETTOPINDEX, LVM_INSERTCOLUMNW, LVM_REDRAWITEMS, LVM_SETITEMCOUNT, LVM_SETITEMSTATE, LVN_COLUMNCLICK, LVN_GETDISPINFOW, LVN_ITEMCHANGED, LVNI_SELECTED, LVS_SHOWSELALWAYS, LVSICF_NOINVALIDATEALL, LVSICF_NOSCROLL, NMHDR, NMLISTVIEW, NMLVDISPINFOW, }; -use windows_sys::Win32::UI::Input::KeyboardAndMouse::EnableWindow; +use windows_sys::Win32::UI::Input::KeyboardAndMouse::{EnableWindow, SetFocus}; use windows_sys::Win32::UI::WindowsAndMessaging::{ - BeginDeferWindowPos, DeferWindowPos, EndDeferWindowPos, EndDialog, GWL_STYLE, GetClientRect, - GetCursorPos, GetDlgItem, GetWindowLongW, IDCANCEL, IDOK, SWP_NOACTIVATE, SWP_NOMOVE, - SWP_NOSIZE, SWP_NOZORDER, SendMessageW, SetWindowLongW, TPM_RETURNCMD, TrackPopupMenuEx, - WM_COMMAND, WM_INITDIALOG, WM_SETREDRAW, + BeginDeferWindowPos, DeferWindowPos, EN_CHANGE, EndDeferWindowPos, EndDialog, GWL_STYLE, + GetClientRect, GetCursorPos, GetDlgItem, GetWindowLongW, GetWindowTextLengthW, GetWindowTextW, + IDCANCEL, IDOK, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SendMessageW, + SetWindowLongW, SetWindowTextW, TPM_RETURNCMD, TrackPopupMenuEx, WM_COMMAND, WM_INITDIALOG, + WM_SETREDRAW, }; use self::actions::load_debugger_path; +use self::filter::{ProcessNameFilter, ProcessViewUpdate, neighbor_after, plan_process_name_view}; use self::model::{DirtyColumns, ProcEntry, column_text, compare_entries, update_process_entry}; use self::sampler::{ProcWorkerRequest, ProcWorkerResult, ProcWorkerState}; use crate::config::options::{ColumnId, Options, UpdateSpeed}; use crate::infrastructure::native::{ - copy_text_to_callback_buffer, finish_list_view_update, get_window_userdata, loword, + copy_text_to_callback_buffer, finish_list_view_update, get_window_userdata, hiword, loword, record_win32_error, set_window_userdata, subclass_list_view, to_wide_null, window_rect_relative_to_page, }; @@ -175,10 +180,8 @@ enum SelectionScrollPolicy { RevealSelection, } -fn refresh_selection_scroll_policy( - requested_selection: Option, -) -> SelectionScrollPolicy { - if requested_selection.is_some() { +fn refresh_selection_scroll_policy(reveal_selection: bool) -> SelectionScrollPolicy { + if reveal_selection { SelectionScrollPolicy::RevealSelection } else { SelectionScrollPolicy::PreserveViewport @@ -302,7 +305,13 @@ pub struct ProcessPageState { main_hwnd: HWND, entries: Vec, displayed_identities: Vec, + /// Indexes into `entries` for the rows the virtual list is currently showing. + visible_rows: Vec, + /// Image-name view. Empty shows the full snapshot. Not stored in `Options`. + name_filter: ProcessNameFilter, active_columns: Vec, + /// Process to restore. This stays set when a filter hides the row, as long as + /// the identity is still in the unfiltered snapshot. selected_identity: Option, pending_find_identity: Option, sort_column: ColumnId, @@ -318,6 +327,8 @@ pub struct ProcessPageState { worker: Option>, last_refresh_error: Option, last_row_error: Option, + suppress_selection_notify: bool, + suppress_filter_notify: bool, } impl Default for ProcessPageState { @@ -328,6 +339,8 @@ impl Default for ProcessPageState { main_hwnd: null_mut(), entries: Vec::with_capacity(128), displayed_identities: Vec::with_capacity(128), + visible_rows: Vec::with_capacity(128), + name_filter: ProcessNameFilter::default(), active_columns: Vec::with_capacity(NUM_COLUMN), selected_identity: None, pending_find_identity: None, @@ -344,6 +357,8 @@ impl Default for ProcessPageState { worker: None, last_refresh_error: None, last_row_error: None, + suppress_selection_notify: false, + suppress_filter_notify: false, } } } @@ -457,6 +472,8 @@ impl ProcessPageState { self.stop_worker_thread(); self.entries.clear(); self.displayed_identities.clear(); + self.visible_rows.clear(); + self.name_filter = ProcessNameFilter::default(); } /// Handles a notification forwarded by the process page dialog procedure. @@ -485,7 +502,9 @@ impl ProcessPageState { } code if code == LVN_ITEMCHANGED => { let notify = &*(lparam as *const NMLISTVIEW); - if (notify.uChanged & LVIF_STATE) != 0 { + // Programmatic selection while applying a filter must not replace the + // cached identity with the temporary visible highlight. + if !self.suppress_selection_notify && (notify.uChanged & LVIF_STATE) != 0 { self.selected_identity = self.current_selected_identity(); self.update_ui_state(); } @@ -504,10 +523,9 @@ impl ProcessPageState { self.sort_column = clicked; self.sort_direction = -1; } - self.selected_identity = - self.current_selected_identity().or(self.selected_identity); + let visible_selection = self.current_selected_identity(); self.resort_entries(); - self.rebuild_listview(SelectionScrollPolicy::PreserveViewport); + self.rebuild_listview(visible_selection, None, ProcessViewUpdate::Refresh); self.refresh_processes(); 1 } @@ -516,6 +534,19 @@ impl ProcessPageState { } } + /// Handles a `WM_COMMAND` delivered to the process page, including edit notifications. + pub fn handle_window_command(&mut self, wparam: WPARAM) -> isize { + let command_id = loword(wparam); + if command_id as i32 == IDC_PROCESS_FILTER { + if hiword(wparam) == EN_CHANGE as u16 { + self.on_name_filter_changed(); + } + return 1; + } + self.handle_command(command_id, None); + 1 + } + // 将命令 ID 分派到具体的进程操作(结束、调试、优先级、亲和性等)。 pub fn handle_command(&mut self, command_id: u16, options: Option<&mut Options>) { let Some(command) = ProcCommand::from_command_id(command_id, IDC_TERMINATE as u16) else { @@ -563,13 +594,19 @@ impl ProcessPageState { pub fn show_context_menu(&mut self, x: i32, y: i32) { unsafe { - // 右键菜单会按当前选中进程和系统能力动态裁剪。 - self.selected_identity = self.current_selected_identity(); - let Some(entry) = self.selected_entry() else { + // 右键菜单作用于列表里看得见的行,不把隐藏的缓存身份写成新的选择。 + let Some(identity) = self.current_selected_identity() else { + return; + }; + let Some(entry_index) = self + .entries + .iter() + .position(|entry| entry.identity == identity) + else { return; }; - let popup = match self.build_context_menu(entry) { + let popup = match self.build_context_menu(&self.entries[entry_index]) { Ok(popup) => popup, Err(error) => { record_win32_error("process popup menu creation", error); @@ -710,6 +747,7 @@ impl ProcessPageState { let terminate_hwnd = GetDlgItem(self.hwnd_page, IDC_TERMINATE); let list_hwnd = self.list_hwnd(); + let filter_hwnd = GetDlgItem(self.hwnd_page, IDC_PROCESS_FILTER); if terminate_hwnd.is_null() || list_hwnd.is_null() { EndDeferWindowPos(hdwp); return; @@ -731,45 +769,77 @@ impl ProcessPageState { ); let list_rect = window_rect_relative_to_page(list_hwnd, self.hwnd_page); + let list_width = (terminate_rect.right - list_rect.left + dx).max(0); hdwp = DeferWindowPos( hdwp, list_hwnd, null_mut(), 0, 0, - (terminate_rect.right - list_rect.left + dx).max(0), + list_width, (terminate_rect.top - list_rect.top + dy - DEFAULT_MARGIN).max(0), SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE, ); + if !filter_hwnd.is_null() { + let filter_rect = window_rect_relative_to_page(filter_hwnd, self.hwnd_page); + let filter_width = (list_rect.left + list_width - filter_rect.left).max(0); + hdwp = DeferWindowPos( + hdwp, + filter_hwnd, + null_mut(), + 0, + 0, + filter_width, + filter_rect.bottom - filter_rect.top, + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE, + ); + } + EndDeferWindowPos(hdwp); } } - // 从进程页跳转到指定 PID 的行并高亮选中。由任务页的“转到进程”命令触发。 + // 从进程页跳转到指定进程并高亮选中。由“转到进程”命令触发。 + // 先清空名称筛选,这样目标行在快照里时会重新成为可见选择。 pub fn find_process(&mut self, identity: ProcIdentity) -> bool { if !identity.is_verified() { return false; } - let Some(index) = self - .entries - .iter() - .position(|entry| entry.identity == identity) - else { + let cleared_filter = !self.name_filter.is_empty(); + if cleared_filter { + self.clear_name_filter(); + } + if self.entries.iter().any(|entry| entry.identity == identity) { + let visible_selection = self.current_selected_identity(); + self.selected_identity = Some(identity); + self.rebuild_listview(visible_selection, None, ProcessViewUpdate::RevealIdentity); + true + } else { self.pending_find_identity = Some(identity); + if cleared_filter { + // The edit is already blank. Rebuild the committed snapshot + // before the worker runs, so a failed collection cannot leave + // the previous filtered rows under an empty filter. + let visible_selection = self.current_selected_identity(); + self.rebuild_listview(visible_selection, None, ProcessViewUpdate::Refresh); + } else { + self.selected_identity = Some(identity); + } self.refresh_processes(); - return true; - }; + true + } + } - self.selected_identity = Some(self.entries[index].identity); - let list_hwnd = self.list_hwnd(); - self.set_list_selection( - list_hwnd, - Some(index), - SelectionScrollPolicy::RevealSelection, - ); - self.update_ui_state(); - true + pub fn focus_name_filter(&mut self) { + unsafe { + let edit = GetDlgItem(self.hwnd_page, IDC_PROCESS_FILTER); + if edit.is_null() { + return; + } + SetFocus(edit); + SendMessageW(edit, EM_SETSEL, 0, -1); + } } fn list_hwnd(&self) -> HWND { @@ -867,7 +937,10 @@ impl ProcessPageState { } fn apply_process_snapshot(&mut self, entries: Vec) { - let previous_selection = self.current_selected_identity().or(self.selected_identity); + let visible_selection = self.current_selected_identity(); + let neighbor_hint = self + .selected_identity + .and_then(|identity| neighbor_after(&self.entry_identities(), identity)); let current_pass = self.pass_count; let visible_columns = DirtyColumns::from_columns(&self.active_columns); let mut sort_dirty = false; @@ -900,9 +973,13 @@ impl ProcessPageState { .pending_find_identity .take() .filter(|identity| self.entries.iter().any(|entry| entry.identity == *identity)); - let selection_scroll_policy = refresh_selection_scroll_policy(requested_selection); - self.selected_identity = requested_selection.or(previous_selection); - self.rebuild_listview(selection_scroll_policy); + let update = if let Some(identity) = requested_selection { + self.selected_identity = Some(identity); + ProcessViewUpdate::RevealIdentity + } else { + ProcessViewUpdate::Refresh + }; + self.rebuild_listview(visible_selection, neighbor_hint, update); self.pass_count = self.pass_count.wrapping_add(1); } @@ -931,68 +1008,114 @@ impl ProcessPageState { previous_len != self.entries.len() } - fn rebuild_listview(&mut self, selection_scroll_policy: SelectionScrollPolicy) { - unsafe { - // 进程列表使用 LVS_OWNERDATA;刷新只更新虚拟项数量和索引映射, - // 不再为每个进程创建、删除或移动 Win32 ListView 项。 - let list_hwnd = self.list_hwnd(); - if list_hwnd.is_null() { - return; - } - let selected_identity = self.selected_identity; - let selected_index = selected_identity.and_then(|identity| { - self.entries + fn rebuild_listview( + &mut self, + visible_selection: Option, + neighbor_hint: Option, + update: ProcessViewUpdate, + ) { + // 进程列表使用 LVS_OWNERDATA。虚拟项只覆盖筛选后的行,隐藏行不会进入 + // LVN_GETDISPINFOW,因此也不会被格式化。没有列表窗口时仍提交模型,避免 + // 空白筛选和上一轮可见行长期不一致。 + let rows = self + .entries + .iter() + .map(|entry| (entry.identity, entry.image_name_lower.as_str())) + .collect::>(); + let plan = plan_process_name_view( + &rows, + &self.name_filter, + self.selected_identity, + visible_selection, + neighbor_hint, + update, + ); + let highlight_identity = plan.selected_list_index.and_then(|list_index| { + plan.visible_indices + .get(list_index) + .and_then(|&entry_index| self.entries.get(entry_index)) + .map(|entry| entry.identity) + }); + let list_hwnd = self.list_hwnd(); + if list_hwnd.is_null() { + self.visible_rows.clone_from(&plan.visible_indices); + self.displayed_identities.clear(); + self.displayed_identities.extend( + self.visible_rows .iter() - .position(|entry| entry.identity == identity) - }); + .filter_map(|&index| self.entries.get(index).map(|entry| entry.identity)), + ); + self.selected_identity = plan.identity_cache; + return; + } + unsafe { let existing_count = SendMessageW(list_hwnd, LVM_GETITEMCOUNT, 0, 0) as usize; - let structure_changed = existing_count != self.entries.len(); - let order_changed = self.displayed_identities.len() != self.entries.len() + let structure_changed = existing_count != plan.visible_indices.len(); + let next_identities = plan + .visible_indices + .iter() + .map(|&index| self.entries[index].identity); + let order_changed = self.displayed_identities.len() != plan.visible_indices.len() || self .displayed_identities .iter() .copied() - .ne(self.entries.iter().map(|entry| entry.identity)); + .ne(next_identities); let bulk_update = structure_changed || order_changed; + let selection_changed = highlight_identity != visible_selection; if bulk_update { SendMessageW(list_hwnd, WM_SETREDRAW, 0, 0); } let mut dirty_rows = DirtyRowRange::default(); - for (index, entry) in self.entries.iter_mut().enumerate() { - if entry.dirty_columns.any() { - entry.dirty_columns = DirtyColumns::default(); - dirty_rows.mark(index); + for (entry_index, entry) in self.entries.iter_mut().enumerate() { + if !entry.dirty_columns.any() { + continue; + } + entry.dirty_columns = DirtyColumns::default(); + if let Ok(list_index) = plan.visible_indices.binary_search(&entry_index) { + dirty_rows.mark(list_index); } } + self.visible_rows.clone_from(&plan.visible_indices); + if structure_changed { SendMessageW( list_hwnd, LVM_SETITEMCOUNT, - self.entries.len(), + plan.visible_indices.len(), (LVSICF_NOINVALIDATEALL | LVSICF_NOSCROLL) as LPARAM, ); } - if bulk_update { + if bulk_update || selection_changed { // Dynamic sort keys can move the selected process on every sample. Refreshes must - // preserve the user's wheel position; only an explicit navigation may reveal it. - self.set_list_selection(list_hwnd, selected_index, selection_scroll_policy); + // preserve the user's wheel position; only an explicit navigation or a filter + // change that moves the highlight may reveal it. + self.suppress_selection_notify = true; + self.set_list_selection(list_hwnd, plan.selected_list_index, plan.scroll); + self.suppress_selection_notify = false; + } else if plan.scroll == SelectionScrollPolicy::RevealSelection + && let Some(index) = plan.selected_list_index + { + SendMessageW(list_hwnd, LVM_ENSUREVISIBLE, index, 0); + } + + if bulk_update { finish_list_view_update(list_hwnd); } else { - dirty_rows.redraw_visible(list_hwnd, self.entries.len()); + dirty_rows.redraw_visible(list_hwnd, plan.visible_indices.len()); } self.displayed_identities.clear(); - self.displayed_identities - .extend(self.entries.iter().map(|entry| entry.identity)); - - if selected_index.is_none() { - self.selected_identity = None; - } - + self.displayed_identities.extend( + self.visible_rows + .iter() + .filter_map(|&index| self.entries.get(index).map(|entry| entry.identity)), + ); + self.selected_identity = plan.identity_cache; self.update_ui_state(); } } @@ -1048,8 +1171,7 @@ impl ProcessPageState { return; } - let entry = self.entries.get(item.iItem as usize); - let Some(entry) = entry else { + let Some(entry) = self.entry_for_list_index(item.iItem as usize) else { *item.pszText = 0; return; }; @@ -1096,12 +1218,12 @@ impl ProcessPageState { ); } - if !self.entries.is_empty() { + if !self.visible_rows.is_empty() { SendMessageW( list_hwnd, LVM_REDRAWITEMS, 0, - self.entries.len().saturating_sub(1) as LPARAM, + self.visible_rows.len().saturating_sub(1) as LPARAM, ); } } @@ -1147,6 +1269,9 @@ impl ProcessPageState { fn current_selected_identity(&self) -> Option { unsafe { let list_hwnd = self.list_hwnd(); + if list_hwnd.is_null() { + return None; + } let index = SendMessageW( list_hwnd, LVM_GETNEXTITEM, @@ -1157,13 +1282,68 @@ impl ProcessPageState { return None; } - self.entries.get(index as usize).map(|entry| entry.identity) + self.entry_for_list_index(index as usize) + .map(|entry| entry.identity) + } + } + + fn entry_for_list_index(&self, list_index: usize) -> Option<&ProcEntry> { + let entry_index = *self.visible_rows.get(list_index)?; + self.entries.get(entry_index) + } + + fn entry_identities(&self) -> Vec { + self.entries.iter().map(|entry| entry.identity).collect() + } + + fn on_name_filter_changed(&mut self) { + if self.suppress_filter_notify { + return; + } + let filter = ProcessNameFilter::from_text(&self.read_filter_text()); + if filter == self.name_filter { + return; + } + self.name_filter = filter; + // An empty visible list is a successful filter result, not a refresh error. + let visible_selection = self.current_selected_identity(); + self.rebuild_listview(visible_selection, None, ProcessViewUpdate::FilterChanged); + } + + fn read_filter_text(&self) -> String { + unsafe { + let edit = GetDlgItem(self.hwnd_page, IDC_PROCESS_FILTER); + if edit.is_null() { + return String::new(); + } + let length = GetWindowTextLengthW(edit); + if length <= 0 { + return String::new(); + } + let mut buffer = vec![0u16; length as usize + 1]; + let Ok(capacity) = i32::try_from(buffer.len()) else { + return String::new(); + }; + let copied = GetWindowTextW(edit, buffer.as_mut_ptr(), capacity); + if copied <= 0 { + return String::new(); + } + let copied = usize::try_from(copied).unwrap_or(0); + String::from_utf16_lossy(&buffer[..copied.min(buffer.len())]) } } - fn selected_entry(&self) -> Option<&ProcEntry> { - let identity = self.selected_identity?; - self.entries.iter().find(|entry| entry.identity == identity) + fn clear_name_filter(&mut self) { + self.name_filter = ProcessNameFilter::default(); + self.suppress_filter_notify = true; + unsafe { + let edit = GetDlgItem(self.hwnd_page, IDC_PROCESS_FILTER); + if !edit.is_null() { + let empty = to_wide_null(""); + SetWindowTextW(edit, empty.as_ptr()); + } + } + self.suppress_filter_notify = false; } // 打开“选择列”对话框。通过 ColumnDialogContext 传递页面和选项指针给 dialog proc。 @@ -1364,8 +1544,8 @@ mod tests { merge_wts_process_identity, signed_kb_delta, system_time_delta, wts_identity_matches, }; use super::{ - ProcIdentity, ProcessPageState, SelectionScrollPolicy, refresh_selection_scroll_policy, - reorder_process_columns, write_process_column_layout, + ProcIdentity, ProcessNameFilter, ProcessPageState, SelectionScrollPolicy, + refresh_selection_scroll_policy, reorder_process_columns, write_process_column_layout, }; use crate::config::options::{ColumnId, Options}; use crate::system::process_identity::query_process_identity_for_pid; @@ -1381,7 +1561,8 @@ mod tests { use std::thread; use std::time::{Duration, Instant}; use windows_sys::Win32::Foundation::{ - ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, WAIT_TIMEOUT, + ERROR_ACCESS_DENIED, ERROR_BROKEN_PIPE, ERROR_INVALID_PARAMETER, WAIT_OBJECT_0, + WAIT_TIMEOUT, }; use windows_sys::Win32::System::Registry::{REG_EXPAND_SZ, REG_SZ}; use windows_sys::Win32::System::Threading::{ @@ -1578,7 +1759,7 @@ mod tests { #[test] fn periodic_refresh_preserves_the_process_list_viewport() { assert_eq!( - refresh_selection_scroll_policy(None), + refresh_selection_scroll_policy(false), SelectionScrollPolicy::PreserveViewport ); } @@ -1586,7 +1767,7 @@ mod tests { #[test] fn explicit_find_reveals_the_requested_process() { assert_eq!( - refresh_selection_scroll_policy(Some(ProcIdentity::new(1234, 10))), + refresh_selection_scroll_policy(true), SelectionScrollPolicy::RevealSelection ); } @@ -1918,6 +2099,38 @@ mod tests { assert_eq!(state.last_refresh_error, Some(5)); } + #[test] + fn find_process_shows_the_committed_snapshot_before_refreshing_a_missing_target() { + let mut alpha = empty_process_entry("alpha.exe"); + alpha.identity = ProcIdentity::new(1, 11); + alpha.pid = 1; + let mut bravo = empty_process_entry("bravo.exe"); + bravo.identity = ProcIdentity::new(2, 22); + bravo.pid = 2; + let missing = ProcIdentity::new(9, 99); + let mut state = ProcessPageState { + entries: vec![alpha, bravo], + visible_rows: vec![1], + displayed_identities: vec![ProcIdentity::new(2, 22)], + name_filter: ProcessNameFilter::from_text("bravo"), + selected_identity: Some(ProcIdentity::new(2, 22)), + ..ProcessPageState::default() + }; + + assert!(state.find_process(missing)); + + assert!(state.name_filter.is_empty()); + assert_eq!(state.visible_rows, vec![0, 1]); + assert_eq!( + state.displayed_identities, + vec![ProcIdentity::new(1, 11), ProcIdentity::new(2, 22)] + ); + assert_eq!(state.entries.len(), 2); + assert_eq!(state.pending_find_identity, Some(missing)); + assert_eq!(state.selected_identity, Some(ProcIdentity::new(2, 22))); + assert_eq!(state.last_refresh_error, Some(ERROR_BROKEN_PIPE)); + } + #[test] fn system_time_delta_requires_a_monotonic_baseline() { assert_eq!(system_time_delta(100, None), None); diff --git a/src/ui/assets.rs b/src/ui/assets.rs index be583f4..1c9d9bf 100644 --- a/src/ui/assets.rs +++ b/src/ui/assets.rs @@ -29,7 +29,8 @@ use windows_sys::Win32::UI::WindowsAndMessaging::{ use crate::infrastructure::native::OwnedIcon; use crate::ui::resource_ids::{ IDB_METER_LIT_GREEN, IDB_METER_LIT_RED, IDB_METER_UNLIT, IDC_ENDTASK, IDC_NEXTTAB, IDC_PREVTAB, - IDC_SWITCHTO, IDI_APPLICATION, IDI_DEFAULT_PROCESS, IDM_HIDE, IDM_REFRESH, TRAY_ICON_IDS, + IDC_SWITCHTO, IDI_APPLICATION, IDI_DEFAULT_PROCESS, IDM_HIDE, IDM_PROC_FILTER, IDM_REFRESH, + TRAY_ICON_IDS, }; pub const APPLICATION_ICON_RESOURCE: u16 = IDI_APPLICATION; @@ -119,6 +120,11 @@ pub fn create_accelerator_table() -> HACCEL { key: VK_F5, cmd: IDM_REFRESH, }, + ACCEL { + fVirt: FVIRTKEY | FCONTROL | FNOINVERT, + key: u16::from(b'F'), + cmd: IDM_PROC_FILTER, + }, ACCEL { fVirt: FVIRTKEY | FNOINVERT, key: VK_RETURN, diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index 397a290..cf8a8ad 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -18,9 +18,9 @@ use windows_sys::Win32::Foundation::{ use windows_sys::Win32::UI::Controls::{LVS_OWNERDATA, LVS_REPORT, LVS_SINGLESEL}; use windows_sys::Win32::UI::WindowsAndMessaging::{ BS_AUTOCHECKBOX, BS_DEFPUSHBUTTON, BS_GROUPBOX, BS_OWNERDRAW, CBS_DROPDOWNLIST, - CreateDialogIndirectParamW, DialogBoxIndirectParamW, ES_AUTOVSCROLL, ES_MULTILINE, SBS_VERT, - WS_BORDER, WS_CAPTION, WS_CHILD, WS_DISABLED, WS_POPUP, WS_SYSMENU, WS_TABSTOP, WS_THICKFRAME, - WS_VISIBLE, WS_VSCROLL, + CreateDialogIndirectParamW, DialogBoxIndirectParamW, ES_AUTOHSCROLL, ES_AUTOVSCROLL, + ES_MULTILINE, SBS_VERT, WS_BORDER, WS_CAPTION, WS_CHILD, WS_DISABLED, WS_POPUP, WS_SYSMENU, + WS_TABSTOP, WS_THICKFRAME, WS_VISIBLE, WS_VSCROLL, }; use crate::ui::resource_ids::*; @@ -43,6 +43,8 @@ const SBS_VERT_STYLE: u32 = SBS_VERT as u32; const CBS_DROPDOWNLIST_STYLE: u32 = CBS_DROPDOWNLIST as u32; const SS_RIGHT_STYLE: u32 = 0x0000_0002; const SS_ENDELLIPSIS_STYLE: u32 = 0x0000_4000; +const SS_CENTERIMAGE_STYLE: u32 = 0x0000_0200; +const ES_AUTOHSCROLL_STYLE: u32 = ES_AUTOHSCROLL as u32; const DIALOG_FONT_NAME: &str = "MS Shell Dlg"; const DIALOG_FONT_SIZE: u16 = 8; const CPU_LABELS: [&str; 64] = [ @@ -683,6 +685,16 @@ fn build_process_dialog() -> DialogSpec<'static> { font_name: DIALOG_FONT_NAME, font_size: DIALOG_FONT_SIZE, controls: vec![ + static_text( + "Filter:", + IDC_PROCESS_FILTER_LABEL, + SS_CENTERIMAGE_STYLE, + 9, + 8, + 52, + 10, + ), + edit_text(IDC_PROCESS_FILTER, ES_AUTOHSCROLL_STYLE, 64, 7, 321, 12), ControlSpec { class_name: "SysListView32", text: "List2", @@ -696,9 +708,9 @@ fn build_process_dialog() -> DialogSpec<'static> { | LVS_OWNERDATA, ex_style: 0, x: 9, - y: 9, + y: 22, cx: 376, - cy: 131, + cy: 118, }, button("&End Process", IDC_TERMINATE, 0, 320, 144, 66, 14), ], @@ -1358,10 +1370,10 @@ pub fn dialog_box( mod tests { use super::{DialogTemplateBuilder, dialog_spec}; use crate::ui::resource_ids::{ - IDC_AFFINITY_GROUP_SELECTOR, IDC_NICTOTALS, IDC_PROCLIST, IDC_SERVICELIST, IDC_TASKLIST, - IDD_AFFINITY, IDD_CPUPAGE, IDD_DIAGNOSTICS, IDD_GPUPAGE, IDD_MAINWND, IDD_MESSAGE, - IDD_NETPAGE, IDD_PERFPAGE, IDD_PROCPAGE, IDD_SELECTPROCCOLS, IDD_SERVICESPAGE, - IDD_TASKPAGE, IDD_USERSPAGE, + IDC_AFFINITY_GROUP_SELECTOR, IDC_NICTOTALS, IDC_PROCESS_FILTER, IDC_PROCESS_FILTER_LABEL, + IDC_PROCLIST, IDC_SERVICELIST, IDC_TASKLIST, IDD_AFFINITY, IDD_CPUPAGE, IDD_DIAGNOSTICS, + IDD_GPUPAGE, IDD_MAINWND, IDD_MESSAGE, IDD_NETPAGE, IDD_PERFPAGE, IDD_PROCPAGE, + IDD_SELECTPROCCOLS, IDD_SERVICESPAGE, IDD_TASKPAGE, IDD_USERSPAGE, }; use windows_sys::Win32::UI::Controls::LVS_OWNERDATA; @@ -1391,6 +1403,29 @@ mod tests { } } + #[test] + fn process_page_places_the_name_filter_above_the_list() { + let spec = dialog_spec(IDD_PROCPAGE).unwrap(); + let filter = spec + .controls + .iter() + .find(|control| control.id == IDC_PROCESS_FILTER as u16) + .unwrap(); + let list = spec + .controls + .iter() + .find(|control| control.id == IDC_PROCLIST as u16) + .unwrap(); + assert_eq!(filter.class_name, "Edit"); + assert!( + spec.controls + .iter() + .any(|control| control.id == IDC_PROCESS_FILTER_LABEL as u16) + ); + assert!(filter.y < list.y); + assert_eq!(list.style & LVS_OWNERDATA, LVS_OWNERDATA); + } + #[test] fn owner_data_lists_are_the_process_and_service_pages() { let cases = [ diff --git a/src/ui/localization/text_key.rs b/src/ui/localization/text_key.rs index 05862f5..356526a 100644 --- a/src/ui/localization/text_key.rs +++ b/src/ui/localization/text_key.rs @@ -325,4 +325,6 @@ pub enum TextKey { ServiceStopFailed, ServiceStateUnchanged, ServiceProcessPid, + ProcessNameFilterLabel, + ProcessNameFilterMenu, } diff --git a/src/ui/localization/ui.rs b/src/ui/localization/ui.rs index aaa762c..e6f8b5f 100644 --- a/src/ui/localization/ui.rs +++ b/src/ui/localization/ui.rs @@ -35,6 +35,11 @@ pub fn localize_dialog(hwnd: HWND, dialog_id: u16) { } IDD_PROCPAGE => { set_dialog_item_text(hwnd, IDC_TERMINATE, TextKey::EndProcess); + set_dialog_item_text( + hwnd, + IDC_PROCESS_FILTER_LABEL, + TextKey::ProcessNameFilterLabel, + ); } IDD_NETPAGE => { set_dialog_item_text(hwnd, IDC_NOADAPTERS, TextKey::NoActiveNetworkAdaptersFound); diff --git a/src/ui/menus.rs b/src/ui/menus.rs index c4b3185..791fc77 100644 --- a/src/ui/menus.rs +++ b/src/ui/menus.rs @@ -195,6 +195,7 @@ fn build_process_main_menu() -> MenuResult { append_item(&mut view, IDM_REFRESH, TextKey::RefreshNow)?; view.append_submenu(text(TextKey::UpdateSpeed), build_update_speed_menu()?)?; append_separator(&mut view)?; + append_item(&mut view, IDM_PROC_FILTER, TextKey::ProcessNameFilterMenu)?; append_item(&mut view, IDM_PROCCOLS, TextKey::SelectColumnsMenu)?; append_common_main_menus( &mut bar, diff --git a/src/ui/resource_ids.rs b/src/ui/resource_ids.rs index 656f6c4..bb47616 100644 --- a/src/ui/resource_ids.rs +++ b/src/ui/resource_ids.rs @@ -247,6 +247,8 @@ pub const IDC_DIAGNOSTIC_OPEN_FOLDER: i32 = 5411; pub const IDC_DIAGNOSTIC_EXPORT: i32 = 5412; pub const IDC_DIAGNOSTIC_EXPORT_STATUS: i32 = 5413; pub const IDC_SERVICELIST: i32 = 4303; +pub const IDC_PROCESS_FILTER: i32 = 4310; +pub const IDC_PROCESS_FILTER_LABEL: i32 = 4311; pub const IDC_MESSAGE_TITLE: i32 = 251; pub const IDC_MESSAGE_MESSAGE: i32 = 252; pub const IDC_CPU0: i32 = 2000; @@ -366,6 +368,7 @@ pub const IDM_TASK_FINDPROCESS: u16 = 40047; pub const IDM_SERVICE_START: u16 = 40120; pub const IDM_SERVICE_STOP: u16 = 40121; pub const IDM_SERVICE_GOTOPROCESS: u16 = 40122; +pub const IDM_PROC_FILTER: u16 = 40123; pub const IDC_NEXTTAB: u16 = 40100; pub const IDC_PREVTAB: u16 = 40101;