From f3e5d3eaf384a761ff04477ef22c7619797ab796 Mon Sep 17 00:00:00 2001 From: electricmessiah Date: Sat, 11 Jul 2026 10:05:00 -0400 Subject: [PATCH 1/4] Feat: custom toolbar row with File/Edit/View + New/Save As quick-access icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the native menu bar with a custom-drawn toolbar row so quick-access icon buttons can share the same line as File/Edit/View. File/Edit/View are now flat buttons that open the same native submenus via TrackPopupMenu (all existing menu content, IDM_* command routing, and keyboard behavior within each dropdown is unchanged); New and Save As are icon buttons using Segoe Fluent Icons glyphs, with hover tooltips. - Custom BS_OWNERDRAW buttons throughout: standard BS_PUSHBUTTON always self-draws its 3D face using system colors and ignores WM_CTLCOLORBTN regardless of theme, so dark-themed buttons need full owner-draw (draw_toolbar_row_button). - The app's HMENU is now built once and kept in AppState (main_menu) instead of attached via SetMenu/CreateWindowExW; every menu-manipulation helper (checkmarks, enable/disable, Recent Files rebuild) was updated to read it from state directly. This also fixes a related bug: those helpers previously read the menu via GetWindowLongPtrW(hwnd, GWLP_USERDATA), which isn't set until after create_children() returns — so initial checkmark sync at startup was silently a no-op until the user toggled the setting manually once. --- src/platform/win32.rs | 547 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 474 insertions(+), 73 deletions(-) diff --git a/src/platform/win32.rs b/src/platform/win32.rs index db63363..3bffd06 100644 --- a/src/platform/win32.rs +++ b/src/platform/win32.rs @@ -1,6 +1,7 @@ use std::io::BufRead; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -10,9 +11,10 @@ use windows::Win32::Foundation::{ POINT, RECT, WPARAM, }; use windows::Win32::Graphics::Gdi::{ - BeginPaint, CreatePen, CreateSolidBrush, DT_CENTER, DT_END_ELLIPSIS, DT_HIDEPREFIX, - DT_NOPREFIX, DT_SINGLELINE, DT_VCENTER, DeleteObject, DrawTextW, EndPaint, FillRect, HBRUSH, - HDC, HGDIOBJ, InvalidateRect, LineTo, MONITOR_DEFAULTTONULL, MonitorFromRect, MoveToEx, + BeginPaint, COLOR_BTNFACE, CreateFontW, CreatePen, CreateSolidBrush, DEFAULT_GUI_FONT, + DT_CENTER, DT_END_ELLIPSIS, DT_HIDEPREFIX, DT_NOPREFIX, DT_SINGLELINE, DT_VCENTER, + DeleteObject, DrawTextW, EndPaint, FillRect, GetStockObject, GetSysColorBrush, HBRUSH, HDC, + HFONT, HGDIOBJ, InvalidateRect, LineTo, MONITOR_DEFAULTTONULL, MonitorFromRect, MoveToEx, PAINTSTRUCT, PS_SOLID, ScreenToClient, SelectObject, SetBkMode, SetTextColor, TRANSPARENT, }; use windows::Win32::System::Com::CoTaskMemFree; @@ -24,18 +26,19 @@ use windows::Win32::UI::Controls::Dialogs::{ }; use windows::Win32::UI::Controls::{ CDDS_ITEMPOSTPAINT, CDDS_ITEMPREPAINT, CDDS_PREPAINT, CDRF_DODEFAULT, CDRF_NEWFONT, - CDRF_NOTIFYITEMDRAW, CDRF_NOTIFYPOSTPAINT, ICC_LISTVIEW_CLASSES, ICC_WIN95_CLASSES, - INITCOMMONCONTROLSEX, InitCommonControlsEx, LIST_VIEW_ITEM_STATE_FLAGS, LVCF_WIDTH, LVCOLUMNW, - LVHITTESTINFO, LVIF_PARAM, LVIF_TEXT, LVIS_FOCUSED, LVIS_SELECTED, LVITEMW, LVM_DELETEALLITEMS, - LVM_GETITEMRECT, LVM_HITTEST, LVM_INSERTCOLUMNW, LVM_INSERTITEMW, LVM_SETBKCOLOR, - LVM_SETCOLUMNWIDTH, LVM_SETEXTENDEDLISTVIEWSTYLE, LVM_SETITEMSTATE, LVM_SETTEXTBKCOLOR, - LVM_SETTEXTCOLOR, LVN_ITEMCHANGED, LVS_EX_DOUBLEBUFFER, LVS_EX_FULLROWSELECT, - LVS_NOCOLUMNHEADER, LVS_REPORT, LVS_SHOWSELALWAYS, LVS_SINGLESEL, NM_CUSTOMDRAW, NM_RCLICK, - NMHDR, NMLISTVIEW, NMLVCUSTOMDRAW, ODS_HOTLIGHT, ODS_INACTIVE, ODS_NOACCEL, ODS_SELECTED, - SB_GETPARTS, SB_GETRECT, SB_GETTEXTLENGTHW, SB_GETTEXTW, SB_SETPARTS, SB_SETTEXTW, - STATUSCLASSNAMEW, TCHITTESTINFO, TCIF_TEXT, TCITEMW, TCM_DELETEITEM, TCM_GETCURSEL, - TCM_GETITEMCOUNT, TCM_GETITEMRECT, TCM_HITTEST, TCM_INSERTITEMW, TCM_SETCURSEL, TCM_SETITEMW, - TCM_SETMINTABWIDTH, TCM_SETPADDING, TCN_SELCHANGE, WC_LISTVIEWW, WC_TABCONTROLW, + CDRF_NOTIFYITEMDRAW, CDRF_NOTIFYPOSTPAINT, DRAWITEMSTRUCT, ICC_LISTVIEW_CLASSES, + ICC_WIN95_CLASSES, INITCOMMONCONTROLSEX, InitCommonControlsEx, LIST_VIEW_ITEM_STATE_FLAGS, + LVCF_WIDTH, LVCOLUMNW, LVHITTESTINFO, LVIF_PARAM, LVIF_TEXT, LVIS_FOCUSED, LVIS_SELECTED, + LVITEMW, LVM_DELETEALLITEMS, LVM_GETITEMRECT, LVM_HITTEST, LVM_INSERTCOLUMNW, LVM_INSERTITEMW, + LVM_SETBKCOLOR, LVM_SETCOLUMNWIDTH, LVM_SETEXTENDEDLISTVIEWSTYLE, LVM_SETITEMSTATE, + LVM_SETTEXTBKCOLOR, LVM_SETTEXTCOLOR, LVN_ITEMCHANGED, LVS_EX_DOUBLEBUFFER, + LVS_EX_FULLROWSELECT, LVS_NOCOLUMNHEADER, LVS_REPORT, LVS_SHOWSELALWAYS, LVS_SINGLESEL, + NM_CUSTOMDRAW, NM_RCLICK, NMHDR, NMLISTVIEW, NMLVCUSTOMDRAW, ODS_HOTLIGHT, ODS_INACTIVE, + ODS_NOACCEL, ODS_SELECTED, SB_GETPARTS, SB_GETRECT, SB_GETTEXTLENGTHW, SB_GETTEXTW, + SB_SETPARTS, SB_SETTEXTW, STATUSCLASSNAMEW, TCHITTESTINFO, TCIF_TEXT, TCITEMW, TCM_DELETEITEM, + TCM_GETCURSEL, TCM_GETITEMCOUNT, TCM_GETITEMRECT, TCM_HITTEST, TCM_INSERTITEMW, TCM_SETCURSEL, + TCM_SETITEMW, TCM_SETMINTABWIDTH, TCM_SETPADDING, TCN_SELCHANGE, TOOLTIPS_CLASSW, TTF_IDISHWND, + TTF_SUBCLASS, TTM_ADDTOOLW, TTS_ALWAYSTIP, TTTOOLINFOW, WC_LISTVIEWW, WC_TABCONTROLW, }; use windows::Win32::UI::HiDpi::{ DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2, GetDpiForWindow, SetProcessDpiAwarenessContext, @@ -49,33 +52,35 @@ use windows::Win32::UI::Shell::{ SHGetPathFromIDListW, SetWindowSubclass, ShellExecuteW, }; use windows::Win32::UI::WindowsAndMessaging::{ - ACCEL, AppendMenuW, BM_GETCHECK, BM_SETCHECK, BS_AUTOCHECKBOX, BS_DEFPUSHBUTTON, BS_PUSHBUTTON, - CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CheckMenuItem, CreateAcceleratorTableW, CreateMenu, - CreatePopupMenu, CreateWindowExW, DefWindowProcW, DeleteMenu, DestroyAcceleratorTable, - DestroyMenu, DestroyWindow, DispatchMessageW, ES_AUTOHSCROLL, ES_NUMBER, EnableMenuItem, FALT, - FCONTROL, FSHIFT, FVIRTKEY, GCLP_HICON, GCLP_HICONSM, GWLP_USERDATA, GetClientRect, - GetCursorPos, GetMenu, GetMenuBarInfo, GetMenuItemCount, GetMenuItemInfoW, GetMessageW, - GetParent, GetSubMenu, GetSystemMetrics, GetWindowLongPtrW, GetWindowPlacement, GetWindowRect, - GetWindowTextLengthW, GetWindowTextW, HACCEL, HICON, HMENU, HWND_NOTOPMOST, HWND_TOPMOST, - ICON_BIG, ICON_SMALL, ICON_SMALL2, IDC_ARROW, IDC_SIZEWE, IDI_APPLICATION, IDNO, IDYES, - IMAGE_ICON, IsIconic, KillTimer, LB_ADDSTRING, LB_GETCURSEL, LB_RESETCONTENT, LBN_DBLCLK, - LBS_NOINTEGRALHEIGHT, LBS_NOTIFY, LR_DEFAULTCOLOR, LR_SHARED, LoadCursorW, LoadIconW, - LoadImageW, MB_ICONERROR, MB_ICONINFORMATION, MB_ICONWARNING, MB_OK, MB_YESNO, MB_YESNOCANCEL, - MENUBARINFO, MENUITEMINFOW, MF_BYCOMMAND, MF_BYPOSITION, MF_CHECKED, MF_ENABLED, MF_GRAYED, - MF_POPUP, MF_SEPARATOR, MF_STRING, MF_UNCHECKED, MIIM_STRING, MSG, MessageBoxW, OBJID_MENU, - PostQuitMessage, RegisterClassExW, SM_CXICON, SM_CXSMICON, SM_CYICON, SM_CYSMICON, SW_HIDE, - SW_RESTORE, SW_SHOW, SW_SHOWMAXIMIZED, SW_SHOWMINIMIZED, SW_SHOWNORMAL, SWP_FRAMECHANGED, - SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, SWP_NOZORDER, SYSTEM_METRICS_INDEX, SendMessageW, - SetClassLongPtrW, SetCursor, SetTimer, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, - SetWindowTextW, ShowWindow, TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON, TrackPopupMenu, - TranslateAcceleratorW, TranslateMessage, WINDOW_STYLE, WINDOWPLACEMENT, WINDOWPLACEMENT_FLAGS, - WM_ACTIVATEAPP, WM_CAPTURECHANGED, WM_CHAR, WM_CLOSE, WM_COMMAND, WM_CONTEXTMENU, WM_COPYDATA, - WM_CREATE, WM_CTLCOLORBTN, WM_CTLCOLORDLG, WM_CTLCOLOREDIT, WM_CTLCOLORLISTBOX, - WM_CTLCOLORSTATIC, WM_DESTROY, WM_DROPFILES, WM_ERASEBKGND, WM_GETFONT, WM_GETICON, - WM_INITMENUPOPUP, WM_KEYDOWN, WM_LBUTTONDOWN, WM_LBUTTONUP, WM_MBUTTONUP, WM_MOUSEMOVE, - WM_NCDESTROY, WM_NOTIFY, WM_PAINT, WM_SETCURSOR, WM_SETICON, WM_SIZE, WM_TIMER, WNDCLASSEXW, - WPF_RESTORETOMAXIMIZED, WS_BORDER, WS_CAPTION, WS_CHILD, WS_CLIPSIBLINGS, WS_OVERLAPPEDWINDOW, - WS_SYSMENU, WS_TABSTOP, WS_VISIBLE, WS_VSCROLL, + ACCEL, AppendMenuW, BM_GETCHECK, BM_SETCHECK, BS_AUTOCHECKBOX, BS_DEFPUSHBUTTON, BS_OWNERDRAW, + BS_PUSHBUTTON, CS_HREDRAW, CS_VREDRAW, CW_USEDEFAULT, CheckMenuItem, CreateAcceleratorTableW, + CreateMenu, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DeleteMenu, + DestroyAcceleratorTable, DestroyMenu, DestroyWindow, DispatchMessageW, ES_AUTOHSCROLL, + ES_NUMBER, EnableMenuItem, FALT, FCONTROL, FSHIFT, FVIRTKEY, GCLP_HICON, GCLP_HICONSM, + GWLP_USERDATA, GetClientRect, GetCursorPos, GetDlgItem, GetMenuBarInfo, GetMenuItemCount, + GetMenuItemInfoW, GetMessageW, GetParent, GetSubMenu, GetSystemMetrics, GetWindowLongPtrW, + GetWindowPlacement, GetWindowRect, GetWindowTextLengthW, GetWindowTextW, HACCEL, HICON, HMENU, + HWND_NOTOPMOST, HWND_TOPMOST, ICON_BIG, ICON_SMALL, ICON_SMALL2, IDC_ARROW, IDC_SIZEWE, + IDI_APPLICATION, IDNO, IDYES, IMAGE_ICON, IsIconic, KillTimer, LB_ADDSTRING, LB_GETCURSEL, + LB_RESETCONTENT, LBN_DBLCLK, LBS_NOINTEGRALHEIGHT, LBS_NOTIFY, LR_DEFAULTCOLOR, LR_SHARED, + LoadCursorW, LoadIconW, LoadImageW, MB_ICONERROR, MB_ICONINFORMATION, MB_ICONWARNING, MB_OK, + MB_YESNO, MB_YESNOCANCEL, MENUBARINFO, MENUITEMINFOW, MF_BYCOMMAND, MF_BYPOSITION, MF_CHECKED, + MF_ENABLED, MF_GRAYED, MF_POPUP, MF_SEPARATOR, MF_STRING, MF_UNCHECKED, MIIM_STRING, MSG, + MessageBoxW, OBJID_MENU, PostMessageW, PostQuitMessage, RegisterClassExW, SM_CXICON, + SM_CXSMICON, SM_CYICON, SM_CYSMICON, SW_HIDE, SW_RESTORE, SW_SHOW, SW_SHOWMAXIMIZED, + SW_SHOWMINIMIZED, SW_SHOWNORMAL, SWP_FRAMECHANGED, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, + SWP_NOZORDER, SYSTEM_METRICS_INDEX, SendMessageW, SetClassLongPtrW, SetCursor, + SetForegroundWindow, SetTimer, SetWindowLongPtrW, SetWindowPlacement, SetWindowPos, + SetWindowTextW, ShowWindow, TPM_LEFTALIGN, TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON, + TPM_TOPALIGN, TrackPopupMenu, TranslateAcceleratorW, TranslateMessage, WINDOW_STYLE, + WINDOWPLACEMENT, WINDOWPLACEMENT_FLAGS, WM_ACTIVATEAPP, WM_CAPTURECHANGED, WM_CHAR, WM_CLOSE, + WM_COMMAND, WM_CONTEXTMENU, WM_COPYDATA, WM_CREATE, WM_CTLCOLORBTN, WM_CTLCOLORDLG, + WM_CTLCOLOREDIT, WM_CTLCOLORLISTBOX, WM_CTLCOLORSTATIC, WM_DESTROY, WM_DRAWITEM, WM_DROPFILES, + WM_ERASEBKGND, WM_GETFONT, WM_GETICON, WM_INITMENUPOPUP, WM_KEYDOWN, WM_LBUTTONDOWN, + WM_LBUTTONUP, WM_MBUTTONUP, WM_MOUSEMOVE, WM_NCDESTROY, WM_NOTIFY, WM_NULL, WM_PAINT, + WM_SETCURSOR, WM_SETFONT, WM_SETICON, WM_SIZE, WM_TIMER, WNDCLASSEXW, WPF_RESTORETOMAXIMIZED, + WS_BORDER, WS_CAPTION, WS_CHILD, WS_CLIPSIBLINGS, WS_OVERLAPPEDWINDOW, WS_POPUP, WS_SYSMENU, + WS_TABSTOP, WS_VISIBLE, WS_VSCROLL, }; use windows::core::PWSTR; use windows::core::{HSTRING, PCWSTR, w}; @@ -259,6 +264,14 @@ const FIND_CLASS: PCWSTR = w!("rivet_find_dialog"); const FIND_FILES_CLASS: PCWSTR = w!("rivet_find_in_files"); const GOTO_LINE_CLASS: PCWSTR = w!("rivet_goto_line_dialog"); const SPLITTER_CLASS: PCWSTR = w!("rivet_tab_splitter"); +const TOOLBAR_ROW_CLASS: PCWSTR = w!("rivet_toolbar_row"); + +const IDC_TOOLBAR_FILE: usize = 5400; +const IDC_TOOLBAR_EDIT: usize = 5401; +const IDC_TOOLBAR_VIEW: usize = 5402; +const IDC_TOOLBAR_NEW: usize = 5403; +const IDC_TOOLBAR_SAVE_AS: usize = 5404; +const TOOLBAR_ROW_HEIGHT: i32 = 34; const SCFIND_MATCHCASE: usize = 0x4; const SCFIND_WHOLEWORD: usize = 0x2; @@ -440,6 +453,8 @@ struct AppState { go_to_line_dialog: Option, find_in_files: Option, status_message: Option, + main_menu: HMENU, + toolbar_row: HWND, } pub fn run() -> Result<()> { @@ -498,7 +513,6 @@ pub fn run() -> Result<()> { register_aux_classes(instance)?; - let menu = create_menu().map_err(|err| AppError::new(format!("create_menu: {err}")))?; let hwnd = unsafe { CreateWindowExW( Default::default(), @@ -510,7 +524,7 @@ pub fn run() -> Result<()> { CW_USEDEFAULT, CW_USEDEFAULT, HWND(0), - menu, + HMENU(0), instance, None, ) @@ -982,6 +996,7 @@ fn register_aux_classes(instance: HINSTANCE) -> Result<()> { register_window_class(instance, FIND_FILES_CLASS, Some(find_in_files_wndproc))?; register_window_class(instance, GOTO_LINE_CLASS, Some(goto_line_wndproc))?; register_window_class(instance, SPLITTER_CLASS, Some(splitter_wndproc))?; + register_window_class(instance, TOOLBAR_ROW_CLASS, Some(toolbar_row_wndproc))?; Ok(()) } @@ -1010,6 +1025,338 @@ fn register_window_class( Ok(()) } +/// Segoe Fluent Icons / Segoe MDL2 Assets glyph font, used for the toolbar +/// row's icon buttons. Created once and leaked for the process lifetime +/// (matches the existing `cached_solid_brush` pattern for GDI objects that +/// live as long as the app). +fn toolbar_icon_font(hwnd: HWND) -> HFONT { + static CACHE: OnceLock = OnceLock::new(); + let handle = *CACHE.get_or_init(|| { + let height = -scale_for_dpi(hwnd, 16); + let font = unsafe { + CreateFontW( + height, + 0, + 0, + 0, + 400, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + w!("Segoe Fluent Icons"), + ) + }; + font.0 + }); + HFONT(handle) +} + +/// Creates the File/Edit/View label buttons and New/Save As/Print icon +/// buttons inside the toolbar row. Icon buttons use icon-font glyphs +/// (Segoe Fluent Icons) rather than hand-drawn GDI glyphs. +fn create_toolbar_row_buttons(toolbar_row: HWND, instance: HINSTANCE) -> Result<()> { + // BS_OWNERDRAW: standard BS_PUSHBUTTON always self-draws its 3D face + // using system colors and ignores WM_CTLCOLORBTN's background brush + // regardless of theme, so a themed dark button needs full owner-draw. + let label_style = window_style(WS_CHILD.0 | WS_VISIBLE.0 | BS_OWNERDRAW as u32 | WS_TABSTOP.0); + let label_height = scale_for_dpi(toolbar_row, 26); + let label_width = scale_for_dpi(toolbar_row, 56); + let icon_width = scale_for_dpi(toolbar_row, 32); + let y = (TOOLBAR_ROW_HEIGHT - 26) / 2; + let y = scale_for_dpi(toolbar_row, y.max(0)); + + let labels: [(usize, &str, i32); 3] = [ + (IDC_TOOLBAR_FILE, "File", 0), + (IDC_TOOLBAR_EDIT, "Edit", 1), + (IDC_TOOLBAR_VIEW, "View", 2), + ]; + for (id, text, slot) in labels { + let x = scale_for_dpi(toolbar_row, 4) + slot * label_width; + let hwnd_btn = unsafe { + CreateWindowExW( + Default::default(), + w!("Button"), + &HSTRING::from(text), + label_style, + x, + y, + label_width, + label_height, + toolbar_row, + menu_id(id), + instance, + None, + ) + }; + dark_mode::disable_visual_styles(hwnd_btn); + } + + // Icon buttons are positioned right-aligned by toolbar_row_wndproc's + // WM_SIZE handler; the x here is just a harmless initial placement. + let icons: [(usize, &str, PCWSTR); 2] = [ + (IDC_TOOLBAR_NEW, "\u{E7C3}", w!("New File (Ctrl+N)")), + (IDC_TOOLBAR_SAVE_AS, "\u{E792}", w!("Save As...")), + ]; + let font = toolbar_icon_font(toolbar_row); + let tooltip = create_toolbar_tooltip(toolbar_row, instance); + for (id, glyph, tip) in icons { + let hwnd_btn = unsafe { + CreateWindowExW( + Default::default(), + w!("Button"), + &HSTRING::from(glyph), + label_style, + 0, + y, + icon_width, + label_height, + toolbar_row, + menu_id(id), + instance, + None, + ) + }; + dark_mode::disable_visual_styles(hwnd_btn); + unsafe { + SendMessageW(hwnd_btn, WM_SETFONT, WPARAM(font.0 as usize), LPARAM(1)); + } + add_tooltip(tooltip, toolbar_row, hwnd_btn, tip); + } + Ok(()) +} + +/// Creates the shared tooltip control that all toolbar-row icon buttons +/// register with. Returns `HWND(0)` on failure (tooltips are cosmetic, so +/// callers just skip registering on failure rather than treating it as +/// fatal). +fn create_toolbar_tooltip(toolbar_row: HWND, instance: HINSTANCE) -> HWND { + unsafe { + CreateWindowExW( + Default::default(), + TOOLTIPS_CLASSW, + PCWSTR::null(), + WINDOW_STYLE(WS_POPUP.0 | TTS_ALWAYSTIP), + CW_USEDEFAULT, + CW_USEDEFAULT, + CW_USEDEFAULT, + CW_USEDEFAULT, + toolbar_row, + HMENU(0), + instance, + None, + ) + } +} + +/// Registers `target` as a tool on `tooltip`, showing `text` on hover. +/// `text` must be static (the tooltip control keeps the pointer, not a +/// copy, and re-reads it on every display). +fn add_tooltip(tooltip: HWND, owner: HWND, target: HWND, text: PCWSTR) { + if tooltip.0 == 0 { + return; + } + let mut info = TTTOOLINFOW { + cbSize: std::mem::size_of::() as u32, + uFlags: TTF_IDISHWND | TTF_SUBCLASS, + hwnd: owner, + uId: target.0 as usize, + lpszText: PWSTR(text.as_ptr() as *mut u16), + ..Default::default() + }; + unsafe { + SendMessageW( + tooltip, + TTM_ADDTOOLW, + WPARAM(0), + LPARAM(&mut info as *mut TTTOOLINFOW as isize), + ); + } +} + +/// Paints one `BS_OWNERDRAW` toolbar-row button: background (dark theme +/// color or `COLOR_BTNFACE`), a slightly different shade while pressed, and +/// centered text/glyph. Standard `BS_PUSHBUTTON` ignores `WM_CTLCOLORBTN`'s +/// brush for its face regardless of theme, so this is the only way to get a +/// genuinely dark-themed button here. +fn draw_toolbar_row_button(toolbar_row: HWND, lparam: LPARAM) { + let dis = unsafe { &*(lparam.0 as *const DRAWITEMSTRUCT) }; + let pressed = (dis.itemState.0 & ODS_SELECTED.0) != 0; + let theme = dialog_dark_theme(toolbar_row); + + let bg = match &theme { + Some(theme) if pressed => dark_mode::cached_solid_brush(theme.hover_bg), + Some(theme) => dark_mode::cached_solid_brush(theme.bg), + None => unsafe { GetSysColorBrush(COLOR_BTNFACE) }, + }; + unsafe { + let _ = FillRect(dis.hDC, &dis.rcItem, bg); + } + + let is_icon = matches!(dis.CtlID as usize, IDC_TOOLBAR_NEW | IDC_TOOLBAR_SAVE_AS); + let font = if is_icon { + toolbar_icon_font(toolbar_row) + } else { + HFONT(unsafe { GetStockObject(DEFAULT_GUI_FONT) }.0) + }; + let old_font = unsafe { SelectObject(dis.hDC, HGDIOBJ(font.0)) }; + + let text_color = theme.map(|theme| theme.fg).unwrap_or(COLORREF(0x00000000)); + unsafe { + SetTextColor(dis.hDC, text_color); + SetBkMode(dis.hDC, TRANSPARENT); + } + + let text = { + let len = unsafe { GetWindowTextLengthW(dis.hwndItem) } as usize; + let mut buffer = vec![0u16; len + 1]; + unsafe { + let _ = GetWindowTextW(dis.hwndItem, buffer.as_mut_slice()); + } + wide_to_string(&buffer).unwrap_or_default() + }; + let mut text_wide: Vec = text.encode_utf16().collect(); + let mut rect = dis.rcItem; + unsafe { + DrawTextW( + dis.hDC, + &mut text_wide, + &mut rect, + DT_CENTER | DT_VCENTER | DT_SINGLELINE, + ); + SelectObject(dis.hDC, old_font); + } +} + +/// Repositions the New/Save As/Print icon buttons to hug the right edge of +/// the toolbar row, called from `WM_SIZE`. +fn layout_toolbar_row_icons(toolbar_row: HWND) { + let mut rect = windows::Win32::Foundation::RECT::default(); + unsafe { + let _ = GetClientRect(toolbar_row, &mut rect); + } + let width = rect.right - rect.left; + let icon_width = scale_for_dpi(toolbar_row, 32); + let label_height = scale_for_dpi(toolbar_row, 26); + let y = (TOOLBAR_ROW_HEIGHT - 26) / 2; + let y = scale_for_dpi(toolbar_row, y.max(0)); + let margin = scale_for_dpi(toolbar_row, 4); + let ids = [IDC_TOOLBAR_SAVE_AS, IDC_TOOLBAR_NEW]; + let mut x = width - margin - icon_width; + for id in ids { + let child = unsafe { GetDlgItem(toolbar_row, id as i32) }; + if child.0 != 0 { + unsafe { + let _ = SetWindowPos( + child, + HWND(0), + x, + y, + icon_width, + label_height, + SWP_NOZORDER | SWP_NOACTIVATE, + ); + } + } + x -= icon_width; + } +} + +unsafe extern "system" fn toolbar_row_wndproc( + hwnd: HWND, + msg: u32, + wparam: WPARAM, + lparam: LPARAM, +) -> LRESULT { + match msg { + WM_ERASEBKGND => { + let mut rect = windows::Win32::Foundation::RECT::default(); + unsafe { + let _ = GetClientRect(hwnd, &mut rect); + } + let brush = match dialog_dark_theme(hwnd) { + Some(theme) => dark_mode::cached_solid_brush(theme.bg), + None => unsafe { GetSysColorBrush(COLOR_BTNFACE) }, + }; + unsafe { + let _ = FillRect(HDC(wparam.0 as isize), &rect, brush); + } + LRESULT(1) + } + WM_CTLCOLORBTN | WM_CTLCOLORDLG | WM_CTLCOLORSTATIC => { + if let Some(r) = dialog_ctl_color(hwnd, HDC(wparam.0 as isize), false) { + return r; + } + unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) } + } + WM_DRAWITEM => { + draw_toolbar_row_button(hwnd, lparam); + LRESULT(1) + } + WM_SIZE => { + layout_toolbar_row_icons(hwnd); + LRESULT(0) + } + WM_COMMAND => { + let main_hwnd = unsafe { HWND(GetWindowLongPtrW(hwnd, GWLP_USERDATA)) }; + let id = loword(wparam.0) as usize; + let submenu_index = match id { + IDC_TOOLBAR_FILE => Some(0), + IDC_TOOLBAR_EDIT => Some(1), + IDC_TOOLBAR_VIEW => Some(2), + _ => None, + }; + if let Some(index) = submenu_index { + if let Some(state) = get_state(main_hwnd) { + let submenu = unsafe { GetSubMenu(state.main_menu, index) }; + let button = unsafe { GetDlgItem(hwnd, id as i32) }; + let mut rect = windows::Win32::Foundation::RECT::default(); + unsafe { + let _ = GetWindowRect(button, &mut rect); + let _ = SetForegroundWindow(main_hwnd); + TrackPopupMenu( + submenu, + TPM_LEFTALIGN | TPM_TOPALIGN, + rect.left, + rect.bottom, + 0, + main_hwnd, + None, + ); + let _ = PostMessageW(main_hwnd, WM_NULL, WPARAM(0), LPARAM(0)); + } + } + return LRESULT(0); + } + match id { + IDC_TOOLBAR_NEW => unsafe { + let _ = PostMessageW( + main_hwnd, + WM_COMMAND, + WPARAM(IDM_FILE_NEW as usize), + LPARAM(0), + ); + }, + IDC_TOOLBAR_SAVE_AS => unsafe { + let _ = PostMessageW( + main_hwnd, + WM_COMMAND, + WPARAM(IDM_FILE_SAVE_AS as usize), + LPARAM(0), + ); + }, + _ => {} + } + LRESULT(0) + } + _ => unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }, + } +} + #[allow(clippy::manual_dangling_ptr)] fn load_main_icons(instance: HINSTANCE) -> (HICON, HICON) { let resource = PCWSTR(1 as *const u16); @@ -2244,6 +2591,33 @@ fn create_children(hwnd: HWND, instance: HINSTANCE) -> Result { dark_mode::apply_to_window(hwnd, ui_settings.editor_dark); let (icon_big, icon_small) = load_main_icons(instance); + + let main_menu = create_menu().map_err(|err| AppError::new(format!("create_menu: {err}")))?; + + let toolbar_row = unsafe { + CreateWindowExW( + Default::default(), + TOOLBAR_ROW_CLASS, + PCWSTR::null(), + window_style(WS_CHILD.0 | WS_VISIBLE.0 | WS_CLIPSIBLINGS.0), + 0, + 0, + 0, + 0, + hwnd, + HMENU(0), + instance, + None, + ) + }; + if toolbar_row.0 == 0 { + return Err(AppError::win32("CreateWindowExW(ToolbarRow)")); + } + unsafe { + SetWindowLongPtrW(toolbar_row, GWLP_USERDATA, hwnd.0); + } + create_toolbar_row_buttons(toolbar_row, instance)?; + let top_tabs = unsafe { CreateWindowExW( Default::default(), @@ -2445,6 +2819,8 @@ fn create_children(hwnd: HWND, instance: HINSTANCE) -> Result { go_to_line_dialog: None, find_in_files: None, status_message: None, + main_menu, + toolbar_row, }; let mut state = restore_session(hwnd, state)?; @@ -2471,9 +2847,9 @@ fn create_children(hwnd: HWND, instance: HINSTANCE) -> Result { layout_children(hwnd, &mut state); apply_always_on_top(hwnd, state.always_on_top); update_status(&state); - update_tab_layout_menu(hwnd, state.tab_host.placement); + update_tab_layout_menu(state.main_menu, state.tab_host.placement); update_wrap_menu(hwnd, &state); - update_editor_dark_menu(hwnd, state.editor_dark); + update_editor_dark_menu(state.main_menu, state.editor_dark); update_always_on_top_menu(hwnd, &state); rebuild_recent_files_menu(hwnd, &state); @@ -2505,6 +2881,7 @@ fn layout_children(hwnd: HWND, state: &mut AppState) { let width = rect.right - rect.left; let height = rect.bottom - rect.top; let status_height = status_rect.bottom - status_rect.top; + let toolbar_height = scale_for_dpi(hwnd, TOOLBAR_ROW_HEIGHT); let tab_height = match state.tab_host.placement { TabPlacement::Top => tab_bar_height(state).max(0), TabPlacement::Left | TabPlacement::Right => 0, @@ -2517,7 +2894,7 @@ fn layout_children(hwnd: HWND, state: &mut AppState) { adjusted } }; - let editor_height = (height - status_height - tab_height).max(0); + let editor_height = (height - toolbar_height - status_height - tab_height).max(0); let editor_width = match state.tab_host.placement { TabPlacement::Top => width.max(0), TabPlacement::Left | TabPlacement::Right => { @@ -2537,11 +2914,20 @@ fn layout_children(hwnd: HWND, state: &mut AppState) { unsafe { let _ = SetWindowPos( - state.tab_host.top_tabs, + state.toolbar_row, HWND(0), 0, 0, width, + toolbar_height, + SWP_NOZORDER | SWP_NOACTIVATE, + ); + let _ = SetWindowPos( + state.tab_host.top_tabs, + HWND(0), + 0, + toolbar_height, + width, tab_height, SWP_NOZORDER | SWP_NOACTIVATE, ); @@ -2549,18 +2935,18 @@ fn layout_children(hwnd: HWND, state: &mut AppState) { state.tab_host.vertical_tabs, HWND(0), list_left, - 0, + toolbar_height, list_width, - height - status_height, + height - toolbar_height - status_height, SWP_NOZORDER | SWP_NOACTIVATE, ); let _ = SetWindowPos( state.tab_host.splitter, HWND(0), splitter_left, - 0, + toolbar_height, TAB_SPLITTER_WIDTH, - height - status_height, + height - toolbar_height - status_height, SWP_NOZORDER | SWP_NOACTIVATE, ); let _ = SetWindowPos( @@ -2578,7 +2964,7 @@ fn layout_children(hwnd: HWND, state: &mut AppState) { doc.editor, HWND(0), editor_left, - tab_height, + toolbar_height + tab_height, editor_width, editor_height, SWP_NOZORDER | SWP_NOACTIVATE, @@ -6353,8 +6739,7 @@ fn scale_for_dpi(hwnd: HWND, value: i32) -> i32 { } /// Locate the (initially empty) "Recent Files" popup inside the File menu. -fn recent_files_submenu(hwnd: HWND) -> Option { - let bar = unsafe { GetMenu(hwnd) }; +fn recent_files_submenu(bar: HMENU) -> Option { if bar.0 == 0 { return None; } @@ -6368,8 +6753,8 @@ fn recent_files_submenu(hwnd: HWND) -> Option { } /// Rewrite the Recent Files submenu from `state.ui_settings.recent_files`. -fn rebuild_recent_files_menu(hwnd: HWND, state: &AppState) { - let Some(menu) = recent_files_submenu(hwnd) else { +fn rebuild_recent_files_menu(_hwnd: HWND, state: &AppState) { + let Some(menu) = recent_files_submenu(state.main_menu) else { return; }; unsafe { @@ -6751,7 +7136,7 @@ fn handle_vertical_tab_custom_draw(state: &AppState, lparam: LPARAM) -> LRESULT fn set_editor_dark_mode(hwnd: HWND, state: &mut AppState, enabled: bool) { state.editor_dark = enabled; - update_editor_dark_menu(hwnd, enabled); + update_editor_dark_menu(state.main_menu, enabled); for doc_tab in &state.docs { apply_syntax_for_doc(doc_tab, enabled); } @@ -6772,12 +7157,30 @@ fn set_editor_dark_mode(hwnd: HWND, state: &mut AppState, enabled: bool) { SWP_FRAMECHANGED | SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE, ); } + // The toolbar row (and its buttons) only reads the live dark-mode state + // at repaint time; nothing repaints it on its own when the setting + // changes, so force one here. + unsafe { + let _ = InvalidateRect(state.toolbar_row, None, true); + for id in [ + IDC_TOOLBAR_FILE, + IDC_TOOLBAR_EDIT, + IDC_TOOLBAR_VIEW, + IDC_TOOLBAR_NEW, + IDC_TOOLBAR_SAVE_AS, + ] { + let child = GetDlgItem(state.toolbar_row, id as i32); + if child.0 != 0 { + let _ = InvalidateRect(child, None, true); + } + } + } persist_ui_settings(state); } fn set_tab_layout(hwnd: HWND, state: &mut AppState, layout: TabPlacement) { state.tab_host.placement = layout; - update_tab_layout_menu(hwnd, layout); + update_tab_layout_menu(state.main_menu, layout); if layout == TabPlacement::Top && state.tab_host.resizing { state.tab_host.resizing = false; unsafe { @@ -6908,8 +7311,7 @@ fn apply_always_on_top(hwnd: HWND, enabled: bool) { } } -fn update_editor_dark_menu(hwnd: HWND, enabled: bool) { - let menu = unsafe { GetMenu(hwnd) }; +fn update_editor_dark_menu(menu: HMENU, enabled: bool) { if menu.0 == 0 { return; } @@ -6972,8 +7374,7 @@ fn destroy_tab_host_brush(state: &mut AppState) { } } -fn update_tab_layout_menu(hwnd: HWND, layout: TabPlacement) { - let menu = unsafe { GetMenu(hwnd) }; +fn update_tab_layout_menu(menu: HMENU, layout: TabPlacement) { if menu.0 == 0 { return; } @@ -6987,16 +7388,16 @@ fn update_tab_layout_menu(hwnd: HWND, layout: TabPlacement) { set_menu_check(menu, IDM_VIEW_TABS_VERTICAL_RIGHT, right); } -fn update_wrap_menu(hwnd: HWND, state: &AppState) { - let menu = unsafe { GetMenu(hwnd) }; +fn update_wrap_menu(_hwnd: HWND, state: &AppState) { + let menu = state.main_menu; if menu.0 == 0 { return; } set_menu_check(menu, IDM_VIEW_WORD_WRAP, state.word_wrap_enabled); } -fn update_file_menu(hwnd: HWND, state: &AppState) { - let menu = unsafe { GetMenu(hwnd) }; +fn update_file_menu(_hwnd: HWND, state: &AppState) { + let menu = state.main_menu; if menu.0 == 0 { return; } @@ -7010,8 +7411,8 @@ fn update_file_menu(hwnd: HWND, state: &AppState) { } } -fn update_copy_path_menu(hwnd: HWND, state: &AppState) { - let menu = unsafe { GetMenu(hwnd) }; +fn update_copy_path_menu(_hwnd: HWND, state: &AppState) { + let menu = state.main_menu; if menu.0 == 0 { return; } @@ -7042,8 +7443,8 @@ fn update_copy_path_menu(hwnd: HWND, state: &AppState) { } } -fn update_always_on_top_menu(hwnd: HWND, state: &AppState) { - let menu = unsafe { GetMenu(hwnd) }; +fn update_always_on_top_menu(_hwnd: HWND, state: &AppState) { + let menu = state.main_menu; if menu.0 == 0 { return; } @@ -7052,8 +7453,8 @@ fn update_always_on_top_menu(hwnd: HWND, state: &AppState) { /// Checks the Language-menu item matching the active tab's lexer: "Auto" when no /// override is set, otherwise the forced language. -fn update_language_menu(hwnd: HWND, state: &AppState) { - let menu = unsafe { GetMenu(hwnd) }; +fn update_language_menu(_hwnd: HWND, state: &AppState) { + let menu = state.main_menu; if menu.0 == 0 { return; } From 2b81d2e3ed987ff5a5aef7b6b3b584af443c55ca Mon Sep 17 00:00:00 2001 From: electricmessiah Date: Sat, 11 Jul 2026 11:20:39 -0400 Subject: [PATCH 2/4] Fix: nudge toolbar label text down to align with icon buttons --- src/platform/win32.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/platform/win32.rs b/src/platform/win32.rs index 3bffd06..570d0c7 100644 --- a/src/platform/win32.rs +++ b/src/platform/win32.rs @@ -1221,6 +1221,16 @@ fn draw_toolbar_row_button(toolbar_row: HWND, lparam: LPARAM) { }; let mut text_wide: Vec = text.encode_utf16().collect(); let mut rect = dis.rcItem; + if !is_icon { + // DT_VCENTER centers on each font's own metrics, and the Segoe + // Fluent Icons glyphs sit lower in their em-box than regular UI + // text does in DEFAULT_GUI_FONT — so at the same rect, label text + // visually sits higher than the icons beside it. Nudge it down a + // couple pixels to match. + let nudge = scale_for_dpi(toolbar_row, 2); + rect.top += nudge; + rect.bottom += nudge; + } unsafe { DrawTextW( dis.hDC, From be52f9e6c6ade3db645ad1fec35f1f6c9fc328f0 Mon Sep 17 00:00:00 2001 From: electricmessiah Date: Sat, 11 Jul 2026 12:34:19 -0400 Subject: [PATCH 3/4] Fix: cache toolbar icon font per-DPI instead of once-ever Font was baked in on first use, often during early WM_CREATE/WM_SIZE before Windows reports the window's final per-monitor DPI. Button boxes already re-layout dynamically and pick up the corrected DPI, but the glyph font stayed frozen at the wrong (usually 96 DPI) size, so icons rendered undersized relative to their buttons on interactive launches that hit the transient DPI window. --- src/platform/win32.rs | 66 +++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 28 deletions(-) diff --git a/src/platform/win32.rs b/src/platform/win32.rs index 570d0c7..517fdfc 100644 --- a/src/platform/win32.rs +++ b/src/platform/win32.rs @@ -1,7 +1,7 @@ use std::io::BufRead; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::sync::OnceLock; +use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -1026,34 +1026,44 @@ fn register_window_class( } /// Segoe Fluent Icons / Segoe MDL2 Assets glyph font, used for the toolbar -/// row's icon buttons. Created once and leaked for the process lifetime -/// (matches the existing `cached_solid_brush` pattern for GDI objects that -/// live as long as the app). +/// row's icon buttons. Cached per-DPI (and leaked, matching the existing +/// `cached_solid_brush` pattern for GDI objects that live as long as the +/// app) since the window's DPI can settle after the font is first needed +/// (e.g. during early WM_CREATE/WM_SIZE before Windows reports the final +/// per-monitor DPI), and a stale font would leave icons the wrong size. fn toolbar_icon_font(hwnd: HWND) -> HFONT { - static CACHE: OnceLock = OnceLock::new(); - let handle = *CACHE.get_or_init(|| { - let height = -scale_for_dpi(hwnd, 16); - let font = unsafe { - CreateFontW( - height, - 0, - 0, - 0, - 400, - 0, - 0, - 0, - 1, - 0, - 0, - 0, - 0, - w!("Segoe Fluent Icons"), - ) - }; - font.0 - }); - HFONT(handle) + static CACHE: Mutex> = Mutex::new(None); + let dpi = unsafe { GetDpiForWindow(hwnd) }; + let mut cache = match CACHE.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + if let Some((cached_dpi, handle)) = *cache + && cached_dpi == dpi + { + return HFONT(handle); + } + let height = -scale_for_dpi(hwnd, 16); + let font = unsafe { + CreateFontW( + height, + 0, + 0, + 0, + 400, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + w!("Segoe Fluent Icons"), + ) + }; + *cache = Some((dpi, font.0)); + font } /// Creates the File/Edit/View label buttons and New/Save As/Print icon From 0cd2d995b2dbe484d174ed43f6ee24afed435bf0 Mon Sep 17 00:00:00 2001 From: electricmessiah Date: Sat, 11 Jul 2026 13:28:49 -0400 Subject: [PATCH 4/4] Fix: add bottom border to toolbar row in dark mode Dark theme colors the toolbar row the same background as the editor and tabs below it, so without a seam the row visually blends into the content area. Draws a DPI-scaled 1px line in a brighter dedicated shade along the bottom edge to keep the separation visible. --- src/platform/win32.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/platform/win32.rs b/src/platform/win32.rs index 517fdfc..1946ade 100644 --- a/src/platform/win32.rs +++ b/src/platform/win32.rs @@ -1298,13 +1298,29 @@ unsafe extern "system" fn toolbar_row_wndproc( unsafe { let _ = GetClientRect(hwnd, &mut rect); } - let brush = match dialog_dark_theme(hwnd) { + let theme = dialog_dark_theme(hwnd); + let brush = match theme { Some(theme) => dark_mode::cached_solid_brush(theme.bg), None => unsafe { GetSysColorBrush(COLOR_BTNFACE) }, }; unsafe { let _ = FillRect(HDC(wparam.0 as isize), &rect, brush); } + // Dark theme colors the toolbar row the same background as the + // editor/tabs below it, so without a seam the row visually + // blends into the content area. theme.border (used for the tab + // strip elsewhere) is too close to the dark bg to read as a + // seam here, so use a brighter dedicated shade and scale the + // line thickness with DPI so it stays visible at high scaling. + if theme.is_some() { + let thickness = scale_for_dpi(hwnd, 1).max(1); + let mut border_rect = rect; + border_rect.top = border_rect.bottom - thickness; + let border_brush = dark_mode::cached_solid_brush(color_ref(90, 90, 90)); + unsafe { + let _ = FillRect(HDC(wparam.0 as isize), &border_rect, border_brush); + } + } LRESULT(1) } WM_CTLCOLORBTN | WM_CTLCOLORDLG | WM_CTLCOLORSTATIC => {