From 84e22b169f5ad0df9480e90c6ffccea6bfffc6f5 Mon Sep 17 00:00:00 2001 From: debpalash <4178343+debpalash@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:04:23 +0530 Subject: [PATCH] Consolidate browsing navigation and search into one compact row --- src/services/search.zig | 163 ++++++--- src/ui/shell.zig | 537 ++++++++++++----------------- tests/features/test_drama.py | 2 +- tests/features/test_iptv.py | 2 +- tests/features/test_novels.py | 2 +- tests/features/test_page_shell2.py | 12 +- tests/features/test_page_shell3.py | 6 +- tests/features/test_search_v2.py | 62 +++- tests/features/test_vndb.py | 2 +- 9 files changed, 410 insertions(+), 378 deletions(-) diff --git a/src/services/search.zig b/src/services/search.zig index 1d6d92b..4671386 100644 --- a/src/services/search.zig +++ b/src/services/search.zig @@ -847,27 +847,55 @@ fn queryEztvApi(query: []const u8, allocator: std.mem.Allocator, my_gen: u64) vo /// query into search_buf, switches to universal (all-source) mode, and kicks off /// the resolver fan-out. Mirrors the in-page universal submit at renderSearchContent. pub fn submitQuery(query_text: []const u8) void { - if (query_text.len == 0) return; - // Local taste engine: log the intent (buffered, flushed off-thread). - @import("activity.zig").record(.search, query_text, .{}); - const resolver = @import("resolver.zig"); - const n = @min(query_text.len, search_buf.len - 1); - @memset(&search_buf, 0); - @memcpy(search_buf[0..n], query_text[0..n]); - state.app.universal_search = true; - resolver.resolve(search_buf[0..n], "auto"); + var owned: [1024]u8 = undefined; + const n = @min(query_text.len, owned.len - 1); + if (n == 0) return; + // Callers may pass search_buf, the shell buffer, or resolver query storage. + // Own the bytes before cancelling work or clearing either visible buffer. + @memcpy(owned[0..n], query_text[0..n]); + cancelPendingMemorySearch(); + @import("activity.zig").record(.search, owned[0..n], .{}); + setUniversalQuery(owned[0..n]); + @import("resolver.zig").resolve(owned[0..n], "auto"); } -/// Show `query_text` in the universal Search view WITHOUT re-resolving — -/// for flows that already ran resolver.resolve and just want the picker to -/// display the live results (smart episode play's fallback). +/// Display an already-resolved query through the same editable shell field. pub fn setUniversalQuery(query_text: []const u8) void { - const n = @min(query_text.len, search_buf.len - 1); + var owned: [1024]u8 = undefined; + const n = @min(query_text.len, owned.len - 1); + @memcpy(owned[0..n], query_text[0..n]); @memset(&search_buf, 0); - @memcpy(search_buf[0..n], query_text[0..n]); + @memcpy(search_buf[0..n], owned[0..n]); + if (state.app.page_shell_enabled) { + @memset(&state.app.magnet_buf, 0); + @memcpy(state.app.magnet_buf[0..n], owned[0..n]); + } state.app.universal_search = true; } +pub fn cancelPendingMemorySearch() void { + _ = memory_generation.fetchAdd(1, .acq_rel); + memory_publish_lock.lock(); + memory_publish_ready.store(false, .release); + memory_publish_lock.unlock(); +} + +/// Unified Clear supersedes both searches and pending memory publication. +pub fn clearShellSearch() void { + cancelPendingMemorySearch(); + search_abort.store(true, .release); + _ = search_generation.fetchAdd(1, .acq_rel); + if (search_thread) |thread| @import("../core/workers.zig").release(thread); + search_thread = null; + is_searching.store(false, .release); + @memset(&search_buf, 0); + @memset(&state.app.magnet_buf, 0); + clearResults(); + @import("resolver.zig").clearResults(); + search_page = 0; + view_dirty = true; +} + /// Omnibox memory-mode flag. When set, the shell's submit path routes the /// raw phrase through memorySearch() (conversational "?"-search) instead of the /// plain unified search. R4 sets this; R3 honors it via the submit entry. @@ -1076,7 +1104,7 @@ fn submitSearchInput(raw: []const u8) void { pub fn renderSearchContent() void { state.app.universal_search = true; // compatibility with saved sessions/API - { + if (!state.app.page_shell_enabled) { var bar = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .background = true, @@ -1116,17 +1144,11 @@ pub fn renderSearchContent() void { .padding = dvui.Rect.all(theme.spacing.xs), }); if ((len > 0 or view_cache.query_len > 0 or view_cache.loaded > 0) and searchButton(91001, "Clear", false)) { - @memset(&search_buf, 0); - clearResults(); - @import("resolver.zig").clearResults(); - view_dirty = true; + clearShellSearch(); } input_row.deinit(); if (enter or clicked) submitSearchInput(submitted[0..len]); - var label_buf: [48]u8 = undefined; - const count = search_view.activeCount(view_filters); - const label = if (count > 0) std.fmt.bufPrint(&label_buf, "Filters ({d})", .{count}) catch "Filters" else "Filters"; - if (searchButton(91002, label, filters_open)) filters_open = !filters_open; + renderShellSearchControls(false); } refreshSearchView(); if (filters_open) renderSearchFilters(); @@ -1134,6 +1156,55 @@ pub fn renderSearchContent() void { renderUniversalResults(); } +/// Search contributes controls to the existing single shell row, never a second +/// query field. Dense mode uses bounded icons; popup choices remain named. +pub fn renderShellSearchControls(dense: bool) void { + refreshSearchView(); + var label_buf: [48]u8 = undefined; + const count = search_view.activeCount(view_filters); + const label = if (count > 0) std.fmt.bufPrint(&label_buf, "Filters ({d})", .{count}) catch "Filters" else "Filters"; + var tip_buf: [64]u8 = undefined; + const filters_tip = std.fmt.bufPrint(&tip_buf, "Filters ({d} active)", .{count}) catch "Filters"; + const filters_clicked = if (dense) searchIconButton(91002, icons.tvg.lucide.@"sliders-horizontal", filters_tip, filters_open or count > 0) else searchButton(91002, label, filters_open); + if (filters_clicked) filters_open = !filters_open; + if (searchSelectImpl(91300, &SORT_LABELS, @intFromEnum(view_sort), dense)) |choice| { + view_sort = @enumFromInt(choice); + view_dirty = true; + } + if (view_cache.loading) { + const cancel_clicked = if (dense) searchIconButton(91303, icons.tvg.lucide.x, "Cancel search", false) else searchButton(91303, "Cancel", false); + if (cancel_clicked) { + cancelPendingMemorySearch(); + @import("resolver.zig").cancel(); + } + } else if (view_cache.query_len > 0 and view_cache.loaded == 0) { + const retry_clicked = if (dense) searchIconButton(91304, icons.tvg.lucide.@"rotate-ccw", "Retry search", false) else searchButton(91304, "Retry", false); + if (retry_clicked) submitQuery(view_cache.query[0..view_cache.query_len]); + } +} + +/// A 26-point target keeps three dense controls within 78 points before shell gaps. +fn searchIconButton(id: usize, icon: []const u8, tooltip: []const u8, active: bool) bool { + var data: dvui.WidgetData = undefined; + const clicked = dvui.buttonIcon(@src(), tooltip, icon, .{}, .{}, .{ + .id_extra = id, + .data_out = &data, + .color_fill = if (active) theme.colors.bg_elevated else theme.transparent, + .color_fill_hover = theme.colors.bg_hover, + .color_fill_press = theme.colors.bg_elevated, + .color_text = if (active) theme.colors.accent else theme.colors.text_secondary, + .border = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), + .corner_radius = theme.dims.rad_sm, + .min_size_content = .{ .w = 16, .h = 16 }, + .max_size_content = .{ .w = 16, .h = 16 }, + .padding = dvui.Rect.all(5), + .gravity_y = 0.5, + }); + components.tip(@src(), data, tooltip); + return clicked; +} + fn searchButton(id: usize, label: []const u8, active: bool) bool { return dvui.button(@src(), label, .{}, .{ .id_extra = id, @@ -1142,6 +1213,7 @@ fn searchButton(id: usize, label: []const u8, active: bool) bool { .color_fill_press = theme.colors.bg_elevated, .color_text = if (active) theme.colors.accent else theme.colors.text_secondary, .border = dvui.Rect.all(0), + .margin = dvui.Rect.all(0), .corner_radius = theme.dims.rad_sm, .padding = .{ .x = theme.spacing.sm, .y = 6, .w = theme.spacing.sm, .h = 6 }, .gravity_y = 0.5, @@ -1150,9 +1222,26 @@ fn searchButton(id: usize, label: []const u8, active: bool) bool { /// Explicitly themed popup, avoiding the default dropdown menu palette. fn searchSelect(id: usize, labels: []const []const u8, selected: usize) ?usize { + return searchSelectImpl(id, labels, selected, false); +} + +fn searchSelectImpl(id: usize, labels: []const []const u8, selected: usize, icon_only: bool) ?usize { var menu = dvui.menu(@src(), .horizontal, .{ .id_extra = id, .color_fill = theme.transparent, .gravity_y = 0.5 }); defer menu.deinit(); - if (dvui.menuItemLabel(@src(), labels[@min(selected, labels.len - 1)], .{ .submenu = true }, .{ + const current_label = labels[@min(selected, labels.len - 1)]; + var data: dvui.WidgetData = undefined; + const trigger = if (icon_only) dvui.menuItemIcon(@src(), current_label, icons.tvg.lucide.@"arrow-down-wide-narrow", .{ .submenu = true }, .{ + .id_extra = id, + .data_out = &data, + .color_fill = theme.transparent, + .color_fill_hover = theme.colors.bg_hover, + .color_fill_press = theme.colors.bg_elevated, + .color_text = theme.colors.text_secondary, + .corner_radius = theme.dims.rad_sm, + .min_size_content = .{ .w = 16, .h = 16 }, + .max_size_content = .{ .w = 16, .h = 16 }, + .padding = dvui.Rect.all(5), + }) else dvui.menuItemLabel(@src(), current_label, .{ .submenu = true }, .{ .id_extra = id, .background = true, .color_fill = theme.colors.bg_elevated, @@ -1161,13 +1250,18 @@ fn searchSelect(id: usize, labels: []const []const u8, selected: usize) ?usize { .color_text = theme.colors.text_primary, .corner_radius = theme.dims.rad_sm, .padding = .{ .x = theme.spacing.sm, .y = 6, .w = theme.spacing.sm, .h = 6 }, - })) |rect| { + }); + if (icon_only) components.tip(@src(), data, current_label); + if (trigger) |rect| { var popup = dvui.floatingMenu(@src(), .{ .from = rect }, .{ .id_extra = id, .color_fill = theme.colors.bg_surface, .color_border = theme.colors.border_subtle }); defer popup.deinit(); var choices = dvui.menu(@src(), .vertical, .{ .id_extra = id, .background = true, .color_fill = theme.colors.bg_surface, .color_border = theme.colors.border_subtle, .border = dvui.Rect.all(1) }); defer choices.deinit(); for (labels, 0..) |label, i| { - if (dvui.menuItemLabel(@src(), label, .{}, .{ .id_extra = i, .expand = .horizontal, .color_text = if (i == selected) theme.colors.accent else theme.colors.text_primary, .color_fill_hover = theme.colors.bg_hover })) |_| return i; + if (dvui.menuItemLabel(@src(), label, .{}, .{ .id_extra = i, .expand = .horizontal, .color_text = if (i == selected) theme.colors.accent else theme.colors.text_primary, .color_fill_hover = theme.colors.bg_hover })) |_| { + popup.close(); + return i; + } } } return null; @@ -1515,23 +1609,10 @@ fn renderUniversalResults() void { const resolver = @import("resolver.zig"); // Facet changes from this frame update counts and rows together. if (view_dirty) refreshSearchView(); - { - var header = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, .padding = dvui.Rect.all(theme.spacing.sm) }); - defer header.deinit(); + if (view_cache.query_len > 0 or view_cache.loaded > 0) { var count_buf: [96]u8 = undefined; const count = std.fmt.bufPrint(&count_buf, "{d} shown of {d} loaded{s}", .{ view_cache.count, view_cache.loaded, if (view_cache.loading) " · searching…" else "" }) catch "Results"; - _ = dvui.label(@src(), "{s}", .{count}, .{ .color_text = theme.colors.text_secondary, .gravity_y = 0.5 }); - var spacer = dvui.box(@src(), .{}, .{ .expand = .horizontal }); - spacer.deinit(); - if (searchSelect(91300, &SORT_LABELS, @intFromEnum(view_sort))) |choice| { - view_sort = @enumFromInt(choice); - view_dirty = true; - } - if (view_cache.loading) { - if (searchButton(91303, "Cancel", false)) resolver.cancel(); - } else if (view_cache.query_len > 0 and view_cache.loaded == 0) { - if (searchButton(91304, "Retry", false)) submitQuery(view_cache.query[0..view_cache.query_len]); - } + _ = dvui.label(@src(), "{s}", .{count}, .{ .color_text = theme.colors.text_secondary, .padding = dvui.Rect.all(theme.spacing.sm) }); } if (view_cache.rows == null) { components.emptyState(icons.tvg.lucide.@"search-x", "Search could not allocate its result view", "Try again after closing unused players."); diff --git a/src/ui/shell.zig b/src/ui/shell.zig index 73076aa..60e3cc7 100644 --- a/src/ui/shell.zig +++ b/src/ui/shell.zig @@ -1,6 +1,6 @@ //! Page-shell — the website-like navigation root (redesign P0–P4). //! -//! Persistent top nav (brand · back/fwd · nav links · omnibox · actions) over +//! One browsing row (destination · omnibox · filters · More) over //! a content region that swaps full pages by route, plus a docked mini-player //! so playback continues while browsing. Page bodies reuse the exact drawer //! content renderers via `drawer.renderTabContent`. Driven by `state.app.router`. @@ -71,19 +71,17 @@ pub fn render() !void { // after resize and push More off-screen until a later repaint. // The OS-point compact tier also activates if a large user scale leaves // too few layout units for the desktop nav's minimum child widths. - // compact: bottom tabs and More replace desktop links; - // narrow: icon-only top links and a tighter omnibox; - // tiny/short: the densest complete shell. + // compact: icon-only destination and search tools; + // narrow: a tighter omnibox; + // tiny: the densest complete shell. // This shell renders inside dvui.scale(ui_scale), so convert OS window // dimensions before comparing either point or layout-unit thresholds. const scale_pure = @import("../core/scale_pure.zig"); const window_rect = dvui.windowRect(); const w = scale_pure.layoutUnits(window_rect.w, state.app.ui_scale); - const h = scale_pure.layoutUnits(window_rect.h, state.app.ui_scale); const compact = scale_pure.needsCompactNav(w, state.app.ui_scale); const narrow = scale_pure.isNarrow(w, state.app.ui_scale) or w < 1200; const tiny = scale_pure.isTiny(w, state.app.ui_scale); - const short = scale_pure.isShort(h, state.app.ui_scale); // A breakpoint swap changes the navbar's child set and therefore its // measured minimum size. Give dvui one explicit convergence frame so a @@ -112,12 +110,13 @@ pub fn render() !void { const ap = state.app.players.items[state.app.active_player_idx]; playing_video = ap.texture != null and !ap.cached_paused; } - const text_len = std.mem.indexOfScalar(u8, &state.app.magnet_buf, 0) orelse state.app.magnet_buf.len; const now_ms = @import("../core/io_global.zig").milliTimestamp(); idle_ms = now_ms - state.app.last_mouse_move_ms; hide_eligible = autohide.shouldHideChrome(.{ .playing_video = playing_video, - .typing = text_len > 0, + // Player has no query field; a retained browsing query must not + // prevent playback chrome from hiding. + .typing = false, .idle_ms = idle_ms, .threshold_ms = autohide.DEFAULT_THRESHOLD_MS, }); @@ -196,20 +195,9 @@ pub fn render() !void { } const prev_alpha = dvui.alpha(nav_alpha); renderTopNav(compact, narrow); - if (compact) { - var search_row = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .padding = dvui.Rect.all(theme.spacing.xs), - }); - omnibox(true); - search_row.deinit(); - } dvui.alphaSet(prev_alpha); } - // Reserve bottom navigation before the page takes the remaining height. - if (compact and !immersive) renderBottomTabs(tiny or short); - { // The Player route owns its full bleed (video grid); every other page // gets a consistent gutter so content never sits flush to the window edge. @@ -276,127 +264,54 @@ fn anyHasMedia() bool { // ── Top navigation ── +/// The browsing chrome has one row at every width. Menus absorb secondary +/// navigation; the expanding query field never moves into a second toolbar. fn renderTopNav(compact: bool, narrow: bool) void { - // Transparent title bar — the nav floats over the app background (no solid - // fill) for a lighter, content-focused feel; a hairline bottom border keeps - // it separated from the page. var bar = dvui.box(@src(), .{ .dir = .horizontal }, .{ .expand = .horizontal, - .min_size_content = .{ .w = 0, .h = 30 }, + .min_size_content = .{ .w = 0, .h = 36 }, .background = true, - .color_fill = transparent, - .color_border = if (state.app.router.current == .player) transparent else theme.colors.border_subtle, - .border = if (state.app.router.current == .player) dvui.Rect.all(0) else .{ .x = 0, .y = 0, .w = 0, .h = 1 }, - .padding = .{ .x = if (compact) theme.spacing.xs else theme.spacing.md, .y = 1, .w = if (compact) theme.spacing.xs else theme.spacing.md, .h = 1 }, + .color_fill = theme.colors.bg_app, + .color_border = theme.colors.border_subtle, + .border = .{ .x = 0, .y = 0, .w = 0, .h = 1 }, + .padding = .{ .x = theme.spacing.xs, .y = 3, .w = theme.spacing.xs, .h = 3 }, }); defer bar.deinit(); - // Brand — clickable: always returns to the Home overview (even out of - // the chat transcript, which otherwise owns the Home route while a - // conversation exists). Suppressed when the custom title bar is active - // (Windows) — it already shows the Opal gem + wordmark, so a second copy in - // the nav row would be a duplicate. - if (!@import("titlebar.zig").active()) { - var brand = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .background = true, - .color_fill = transparent, - .color_fill_hover = theme.colors.bg_hover, - .corner_radius = theme.dims.rad_sm, - .padding = .{ .x = theme.spacing.xs, .y = 2, .w = theme.spacing.xs, .h = 2 }, - .gravity_y = 0.5, - }); - defer brand.deinit(); - var hovered = false; - if (dvui.clicked(brand.data(), .{ .hovered = &hovered })) { - @import("home.zig").showOverview(); - state.app.router.navigate(.home); - } - if (hovered) brand.data().options.color_fill = theme.colors.bg_hover; - brand.drawBackground(); - // Brand mark — the real Opal gem (assets/logo.svg rendered to PNG at - // build time via src/ui/opal_logo_64.png), not a generic zap glyph. - _ = dvui.image(@src(), .{ - .source = .{ .imageFile = .{ .bytes = @embedFile("opal_logo_64.png"), .name = "opal-brand" } }, - }, .{ - .min_size_content = theme.iconSize(.md), - .max_size_content = .{ .w = 20, .h = 20 }, - .gravity_y = 0.5, - }); - if (!compact) { - _ = dvui.label(@src(), "Opal", .{}, .{ - .color_text = theme.colors.text_primary, - .gravity_y = 0.5, - .margin = .{ .x = theme.spacing.xs, .y = 0, .w = theme.spacing.xs, .h = 0 }, - }); - } + browseSourcePicker(compact); + if (!compact and state.app.router.canGoBack()) { + if (chromeIconButton(@src(), icons.tvg.lucide.@"chevron-left", "Back", false, true)) state.app.router.goBack(); } - - browseSourcePicker(); - - // Back / forward — disabled (dimmed, inert) when there's no history in - // that direction. Previously canGoBack() was passed as `active`, which - // painted Back as a toggled-on accent chip whenever ANY history existed — - // the same visual language the route buttons use for "current page". - if (chromeIconButton(@src(), icons.tvg.lucide.@"chevron-left", "Back", false, state.app.router.canGoBack())) { - state.app.router.goBack(); + omnibox(narrow); + if (state.app.router.current == .search) search_mod.renderShellSearchControls(compact); + if (!compact and anyHasMedia()) { + if (chromeIconButton(@src(), icons.tvg.lucide.play, "Now playing", false, true)) state.app.router.navigate(.player); } - // Primary nav links — compact uses bottom tabs and More instead. - if (!compact) { - // Home is icon-only; Search and Watching gain labels when space allows. - // Browse remains in the source selector, while Search always opens the - // global results page regardless of the omnibox's current source. - navLink(.home, "Home", icons.tvg.lucide.house, 1, true); - navLink(.search, "Search", icons.tvg.lucide.search, 8, narrow); - navLink(.watching, "Watching", icons.tvg.lucide.tv, 7, narrow); - } - - // Compact widths render the same input in a dedicated row below the nav. - if (!compact) omnibox(narrow); - - // The search field takes available width; compact keeps actions right-aligned. - if (compact) { - var sp = dvui.box(@src(), .{}, .{ .expand = .horizontal }); - sp.deinit(); - } - - // Right-side actions stay icon-only; assistant chat is reached through the - // omnibox ('>' or trailing '?'), while AI & Voice configuration is in Settings. - if (chromeIconButton(@src(), icons.tvg.lucide.play, "Now playing", state.app.router.current == .player, true)) { - state.app.router.navigate(.player); - } - // Settings stays in the desktop row; compact exposes it in More. - if (!compact and chromeIconButton(@src(), icons.tvg.lucide.settings, "Settings", state.app.router.current == .settings, true)) { - state.app.router.navigate(.settings); - } - - // Overflow (⋯) keeps secondary destinations short and groups advanced - // commands into two small submenus that fit laptop screens. - { - var m = dvui.menu(@src(), .horizontal, .{ .gravity_y = 0.5 }); - defer m.deinit(); - if (dvui.menuItemIcon(@src(), "More", icons.tvg.lucide.@"ellipsis-vertical", .{ .submenu = true }, .{ - .color_text = theme.colors.text_secondary, - .color_fill = transparent, - .corner_radius = dvui.Rect.all(theme.radius.sm), - .min_size_content = theme.iconSize(.sm), - .padding = dvui.Rect.all(6), - })) |r| { - var fw = dvui.floatingMenu(@src(), .{ .from = r }, .{ - .color_fill = theme.colors.bg_surface, - .color_border = theme.colors.border_subtle, - }); - defer fw.deinit(); - var col = dvui.menu(@src(), .vertical, .{ - .background = true, - .color_fill = theme.colors.bg_surface, - .border = dvui.Rect.all(1), - .color_border = theme.colors.border_subtle, - .corner_radius = dvui.Rect.all(theme.radius.md), - }); - defer col.deinit(); - renderSecondaryDestinations(compact); - } + var m = dvui.menu(@src(), .horizontal, .{ .gravity_y = 0.5 }); + defer m.deinit(); + if (dvui.menuItemIcon(@src(), "More", icons.tvg.lucide.@"ellipsis-vertical", .{ .submenu = true }, .{ + .color_text = theme.colors.text_secondary, + .color_fill = transparent, + .color_fill_hover = theme.colors.bg_hover, + .corner_radius = theme.dims.rad_sm, + .min_size_content = theme.iconSize(.sm), + .padding = dvui.Rect.all(6), + })) |r| { + var fw = dvui.floatingMenu(@src(), .{ .from = r }, .{ + .color_fill = theme.colors.bg_surface, + .color_border = theme.colors.border_subtle, + }); + defer fw.deinit(); + var col = dvui.menu(@src(), .vertical, .{ + .background = true, + .color_fill = theme.colors.bg_surface, + .border = dvui.Rect.all(1), + .color_border = theme.colors.border_subtle, + .corner_radius = theme.dims.rad_md, + }); + defer col.deinit(); + renderSecondaryDestinations(compact); } } @@ -473,7 +388,28 @@ fn chromeIconButton(src: std.builtin.SourceLocation, icon: []const u8, tooltip: if (state.app.router.current == .player) { return components.iconButtonOverlay(src, icon, tooltip, active, enabled); } - return components.iconButtonEx(src, icon, tooltip, active, enabled); + var data: dvui.WidgetData = undefined; + const opts = dvui.Options{ + .data_out = &data, + .color_fill = if (active) theme.colors.bg_elevated else transparent, + .color_fill_hover = theme.colors.bg_hover, + .color_fill_press = theme.colors.bg_elevated, + .color_text = if (!enabled) theme.colors.text_tertiary else if (active) theme.colors.accent else theme.colors.text_secondary, + .border = dvui.Rect.all(0), + .corner_radius = theme.dims.rad_sm, + .min_size_content = .{ .w = 16, .h = 16 }, + .max_size_content = .{ .w = 16, .h = 16 }, + .margin = dvui.Rect.all(0), + .padding = dvui.Rect.all(5), + .gravity_y = 0.5, + }; + if (!enabled) { + dvui.icon(src, tooltip, icon, .{}, opts); + return false; + } + const clicked = dvui.buttonIcon(src, tooltip, icon, .{}, .{}, opts); + components.tip(src, data, tooltip); + return clicked; } /// Nav-bar Plugins menu: a puzzle-icon dropdown listing every section of the @@ -524,24 +460,32 @@ fn pluginsMenu() void { } } -/// Compact bottom tabs already expose Home, Watching, History and Downloads. -/// Keep More short; lengthy advanced controls belong in two small submenus. +/// Navigation lives in the destination picker; More groups actions and tools. fn renderSecondaryDestinations(compact: bool) void { const item_opts = dvui.Options{ .expand = .horizontal, .color_text = theme.colors.text_primary }; - if (compact) { - if (dvui.menuItemLabel(@src(), "Search all sources", .{}, item_opts) != null) { - closeOverflowMenu(); - state.app.router.navigate(.search); - } - } else { - if (dvui.menuItemLabel(@src(), "Downloads", .{}, item_opts) != null) { - closeOverflowMenu(); - state.app.router.navigate(.downloads); - } - if (dvui.menuItemLabel(@src(), "History", .{}, item_opts) != null) { - closeOverflowMenu(); - state.app.router.navigate(.history); - } + if (compact and state.app.router.canGoBack() and dvui.menuItemLabel(@src(), "Back", .{}, item_opts) != null) { + closeOverflowMenu(); + state.app.router.goBack(); + } + if (state.app.router.canGoForward() and dvui.menuItemLabel(@src(), "Forward", .{}, item_opts) != null) { + closeOverflowMenu(); + state.app.router.goForward(); + } + if (dvui.menuItemLabel(@src(), "Now playing", .{}, item_opts) != null) { + closeOverflowMenu(); + state.app.router.navigate(.player); + } + if (dvui.menuItemLabel(@src(), "Downloads", .{}, item_opts) != null) { + closeOverflowMenu(); + state.app.router.navigate(.downloads); + } + if (dvui.menuItemLabel(@src(), "History", .{}, item_opts) != null) { + closeOverflowMenu(); + state.app.router.navigate(.history); + } + if (dvui.menuItemLabel(@src(), "Paste link", .{}, item_opts) != null) { + closeOverflowMenu(); + header.handleClipboardPaste(); } // Open the longest submenu near the top so its last action remains visible // on 768px-high displays without depending on mouse-wheel scrolling. @@ -563,7 +507,7 @@ fn renderSecondaryDestinations(compact: bool) void { closeOverflowMenu(); state.app.router.navigate(.plugins); } - if (compact and dvui.menuItemLabel(@src(), "Settings", .{}, item_opts) != null) { + if (dvui.menuItemLabel(@src(), "Settings", .{}, item_opts) != null) { closeOverflowMenu(); state.app.router.navigate(.settings); } @@ -655,144 +599,81 @@ fn closeOverflowMenu() void { if (dvui.FloatingMenuWidget.currentGet()) |popup| popup.close(); } -/// A top-nav link: whole-row click target, icon + label, accent when active. -/// Hover lifts the fill; the row takes a tab stop (Enter/Space activates) and -/// draws dvui's focus ring when keyboard-focused. -/// `icon_only` drops the text label. The label is still passed (and still names -/// the icon), so it becomes a hover tooltip — an unlabelled glyph with no tooltip -/// is a guessing game. -fn navLink(r: Route, label: []const u8, icon: []const u8, id_extra: usize, icon_only: bool) void { - const active = state.app.router.current == r; - - var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .id_extra = id_extra, - .min_size_content = .{ .w = 0, .h = 24 }, +/// One query entry, with identical routing for Enter and the Search button. +/// Browse queries stay contextual; All content queries use the shared resolver. +fn omnibox(narrow: bool) void { + const placeholder: []const u8 = if (state.app.router.current == .browse) + "Search this source or paste a link…" + else if (narrow) + "Search, ask, paste…" + else + "Search everything, ask, or paste a link…"; + var field = dvui.box(@src(), .{ .dir = .horizontal }, .{ + .expand = .horizontal, + .min_size_content = .{ .w = 80, .h = 30 }, .background = true, - .color_fill = if (active) theme.colors.bg_elevated else transparent, - .corner_radius = dvui.Rect.all(theme.radius.md), - .padding = .{ .x = theme.spacing.sm, .y = 2, .w = theme.spacing.sm, .h = 2 }, - .margin = .{ .x = 2, .y = 0, .w = 2, .h = 0 }, - .gravity_y = 0.5, - }); - defer row.deinit(); - - if (navRowInteract(row)) { - state.app.router.navigate(r); - } - - const fg = if (active) theme.colors.accent else theme.colors.text_secondary; - dvui.icon(@src(), label, icon, .{}, .{ - .id_extra = id_extra, - .color_text = fg, - .min_size_content = theme.iconSize(.sm), - .gravity_y = 0.5, - // No label to separate from — the trailing gap would just off-center the - // glyph inside its pill. - .margin = if (icon_only) - dvui.Rect.all(0) - else - .{ .x = 0, .y = 0, .w = theme.spacing.xs, .h = 0 }, - }); - - if (icon_only) { - // Every navLink shares this @src(), so the tooltip needs an explicit - // id_extra or all of them collide on one widget id. - components.tipId(@src(), row.data().*, label, id_extra); - return; - } - - _ = dvui.label(@src(), "{s}", .{label}, .{ - .id_extra = id_extra, - .color_text = fg, + .color_fill = theme.colors.bg_elevated, + .color_border = theme.colors.border_subtle, + .border = dvui.Rect.all(1), + .corner_radius = theme.dims.rad_md, + .margin = .{ .x = theme.spacing.xs, .y = 0, .w = theme.spacing.xs, .h = 0 }, .gravity_y = 0.5, }); -} - -/// Live omnibox — the universal entry point. On Enter it classifies the text: -/// • media (magnet/url/path) → load into player, go to Player -/// • leading '>' or trailing '?' → AI assistant (chat) -/// • anything else → UNIFIED search across all sources -fn omnibox(narrow: bool) void { - // This field always routes plain text to universal search, even on Browse. - // Do not label it as a second Movies & TV search box. - const placeholder: []const u8 = if (narrow) - "Search all sources, ask, paste…" - else - "Search all sources, ask, or paste a link…"; + defer field.deinit(); var te = dvui.textEntry(@src(), .{ .text = .{ .buffer = &state.app.magnet_buf }, .placeholder = placeholder, }, .{ - // Consume only the width left after navigation/actions. On compact - // windows this is the full second row, never a hidden search control. .expand = .horizontal, - .min_size_content = .{ .w = 80, .h = 26 }, - .margin = .{ .x = theme.spacing.xs, .y = 0, .w = 4, .h = 0 }, - .color_fill = if (state.app.router.current == .player) theme.playerGlass(40) else theme.colors.bg_elevated, - .color_border = if (state.app.router.current == .player) theme.playerGlass(76) else theme.colors.border_subtle, - .border = dvui.Rect.all(1), - .corner_radius = dvui.Rect.all(theme.radius.md), + .min_size_content = .{ .w = 48, .h = 18 }, + .margin = dvui.Rect.all(0), + .padding = .{ .x = 8, .y = 4, .w = 0, .h = 4 }, + .color_fill = transparent, + .color_border = transparent, + .color_text = theme.colors.text_primary, + .border = dvui.Rect.all(0), + .corner_radius = theme.dims.rad_sm, .gravity_y = 0.5, }); const entered = te.enter_pressed; te.deinit(); const len = std.mem.indexOfScalar(u8, &state.app.magnet_buf, 0) orelse state.app.magnet_buf.len; - - // Inline affordances next to the box: clear-✕ while text is present - // (mouse users had no way to empty it), paste when empty, and the voice - // conversation toggle (was legacy-header-only). - if (len > 0) { - if (components.iconButton(@src(), icons.tvg.lucide.x, "Clear", false)) { - @memset(&state.app.magnet_buf, 0); - return; - } - } else { - if (components.iconButton(@src(), icons.tvg.lucide.@"clipboard-paste", "Paste", false)) { - header.handleClipboardPaste(); - return; - } - } - { - const voice = @import("../services/ai_voice.zig"); - const voice_icon = if (voice.conv_phase == .speaking) - icons.tvg.lucide.@"volume-2" - else if (voice.conv_phase == .listening or voice.is_recording.load(.acquire)) - icons.tvg.lucide.mic - else - icons.tvg.lucide.headphones; - if (chromeIconButton(@src(), voice_icon, "Voice / conversation mode", voice.conversation_active.load(.acquire), true)) { - voice.toggleConversation(); - } - } - { - var gap = dvui.box(@src(), .{}, .{ .min_size_content = .{ .w = theme.spacing.sm, .h = 0 } }); - gap.deinit(); + if (len > 0 and chromeIconButton(@src(), icons.tvg.lucide.x, "Clear search", false, true)) { + if (state.app.router.current == .search) search_mod.clearShellSearch() else @memset(&state.app.magnet_buf, 0); + return; } - - if (!entered) return; - if (len == 0) return; - const text = std.mem.trim(u8, state.app.magnet_buf[0..len], " \t\r\n"); + const submitted = chromeIconButton(@src(), icons.tvg.lucide.search, "Search", false, len > 0); + if ((!entered and !submitted) or len == 0) return; + + // Services may clear or mirror the field; never route a slice into that + // mutable buffer after handing it to a service. + var query_buf: [state.app.magnet_buf.len]u8 = undefined; + @memcpy(query_buf[0..len], state.app.magnet_buf[0..len]); + const text = std.mem.trim(u8, query_buf[0..len], " \t\r\n"); + if (text.len == 0) return; + // A newer assistant/link/source query also supersedes a pending memory + // lookup; its late publication must not replace this field or search. + search_mod.cancelPendingMemorySearch(); switch (browser_pure.classifyOmnibox(text)) { .empty => {}, .memory => { search_mod.memorySearch(std.mem.trim(u8, text[1..], " \t")); - @memset(&state.app.magnet_buf, 0); state.app.router.navigate(.search); }, .assistant => { + // An explicit assistant request must not inherit the legacy + // header's sticky memory toggle. + const legacy_memory_mode = search_mod.memory_mode; + search_mod.memory_mode = false; + defer search_mod.memory_mode = legacy_memory_mode; header.submitInput(); - // The conversation renders on HOME (home.zig chat mode); the - // .assistant route hosts AI SETTINGS, not the chat. state.app.router.navigate(.home); }, .open => openOmniboxTarget(text), .search => { - if (browser.searchCurrentBrowse(text)) { - @memset(&state.app.magnet_buf, 0); - } else { + if (!browser.searchCurrentBrowse(text)) { search_mod.submitQuery(text); - @memset(&state.app.magnet_buf, 0); state.app.router.navigate(.search); } }, @@ -818,8 +699,8 @@ fn openOmniboxTarget(text: []const u8) void { browser.loadContent(target); } -/// Shared interaction for the box-based nav rows (top-nav links, sub-tabs, -/// bottom tabs): click + hover lift + tab stop + Enter/Space activation + +/// Shared interaction for box-based sub-tabs: click + hover lift + tab stop + +/// Enter/Space activation + /// focus ring. Plain boxes get NONE of this from dvui (color_fill_hover is /// only consulted by button widgets, and boxes never register a tab index), /// which left the app's primary navigation mouse-only with zero feedback. @@ -928,8 +809,8 @@ const CONNECTED_SOURCES = [_]state.DrawerTab{ .Web, .Jellyfin, .Plex }; /// Browse is one task with a source filter, not seventeen permanent navigation /// tabs. Connectors remain reachable from the command palette even when their /// setup is incomplete; Plugins/Settings owns configuration. -fn browseSourcePicker() void { - if (browseSourceSelect()) |picked| { +fn browseSourcePicker(dense: bool) void { + if (browseSourceSelect(dense)) |picked| { state.app.browse_source = picked; state.app.drawer_tab = state.app.browse_source; state.app.router.navigate(.browse); @@ -940,7 +821,7 @@ fn browseSourcePicker() void { /// integrations stay discoverable so their sign-in/recovery UI remains usable. /// Keep it single-level: cascading hover submenus were fragile at the window /// edges and could lose their anchor before a source click reached them. -fn browseSourceSelect() ?state.DrawerTab { +fn browseSourceSelect(dense: bool) ?state.DrawerTab { var picked: ?state.DrawerTab = null; var menu = dvui.menu(@src(), .horizontal, .{ .color_fill = transparent, @@ -949,7 +830,7 @@ fn browseSourceSelect() ?state.DrawerTab { defer menu.deinit(); const selected = state.app.browse_source; - if (browseSourceMenuItem(selected, 8000, true, true)) |anchor| { + if (navigationMenuItem(selected, dense)) |anchor| { var popup = dvui.floatingMenu(@src(), .{ .from = anchor }, .{ .background = true, .color_fill = theme.colors.bg_surface, @@ -965,6 +846,21 @@ fn browseSourceSelect() ?state.DrawerTab { .corner_radius = theme.dims.rad_lg, }); defer choices.deinit(); + const routes = [_]Route{ .home, .search, .watching }; + const labels = [_][]const u8{ "Home", "All content", "Watching" }; + for (routes, labels, 0..) |route, label, i| { + if (dvui.menuItemLabel(@src(), label, .{}, .{ + .id_extra = 8050 + i, + .expand = .horizontal, + .color_text = if (state.app.router.current == route) theme.colors.accent else theme.colors.text_primary, + .color_fill_hover = theme.colors.bg_hover, + .padding = dvui.Rect.all(theme.spacing.sm), + }) != null) { + popup.close(); + if (route == .home) @import("home.zig").showOverview(); + state.app.router.navigate(route); + } + } browseSourceSection("WATCH", &WATCH_SOURCES, 8100, &picked); browseSourceSection("LISTEN", &LISTEN_SOURCES, 8200, &picked); browseSourceSection("READ", &READ_SOURCES, 8300, &picked); @@ -976,6 +872,61 @@ fn browseSourceSelect() ?state.DrawerTab { return picked; } +/// Names the current destination, rather than showing Movies & TV on Search. +/// Compact widths retain the same menu behind one labelled icon and chevron. +fn navigationMenuItem(selected: state.DrawerTab, dense: bool) ?dvui.Rect.Natural { + const route = state.app.router.current; + const label = if (route == .browse) tabLabel(selected) else switch (route) { + .home => "Home", + .search => "All content", + .watching => "Watching", + .downloads => "Downloads", + .queue => "Queue", + .history => "History", + .player => "Now playing", + .assistant => "Assistant", + .settings => "Settings", + .plugins => "Plugins", + .system => "Logs", + .browse => unreachable, + }; + const icon = if (route == .browse) iconForTab(selected) else switch (route) { + .home => icons.tvg.lucide.house, + .watching => icons.tvg.lucide.tv, + else => icons.tvg.lucide.globe, + }; + var item = dvui.menuItem(@src(), .{ .submenu = true }, .{ + .id_extra = 8000, + .background = true, + .color_fill = transparent, + .color_fill_hover = theme.colors.bg_hover, + .color_text = theme.colors.text_secondary, + .corner_radius = theme.dims.rad_sm, + .padding = dvui.Rect.all(6), + .gravity_y = 0.5, + }); + defer item.deinit(); + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{}); + defer row.deinit(); + const child = item.data().options.strip().override(item.style()); + dvui.icon(@src(), label, icon, .{}, child.override(.{ + .min_size_content = theme.iconSize(.sm), + .max_size_content = .{ .w = theme.iconSize(.sm).w, .h = theme.iconSize(.sm).h }, + .gravity_y = 0.5, + })); + if (!dense) dvui.labelNoFmt(@src(), label, .{}, child.override(.{ + .gravity_y = 0.5, + .margin = .{ .x = theme.spacing.xs, .y = 0, .w = theme.spacing.xs, .h = 0 }, + })); + dvui.icon(@src(), "Choose destination or source", icons.tvg.lucide.@"chevron-down", .{}, child.override(.{ + .min_size_content = .{ .w = 12, .h = 12 }, + .max_size_content = .{ .w = 12, .h = 12 }, + .gravity_y = 0.5, + })); + if (dense) components.tipId(@src(), item.data().*, label, 8000); + return item.activeRect(); +} + fn browseSourceSection(label: []const u8, sources: []const state.DrawerTab, id: usize, picked: *?state.DrawerTab) void { const selected = state.app.browse_source; @@ -987,7 +938,7 @@ fn browseSourceSection(label: []const u8, sources: []const state.DrawerTab, id: .padding = .{ .x = theme.spacing.sm, .y = 5, .w = theme.spacing.sm, .h = 2 }, }); for (sources, 0..) |source, i| { - if (browseSourceMenuItem(source, id + i + 1, false, source == selected) != null) picked.* = source; + if (browseSourceMenuItem(source, id + i + 1, false, state.app.router.current == .browse and source == selected) != null) picked.* = source; } } @@ -1185,59 +1136,3 @@ fn pluginTabIcon(t: router.PluginTab) []const u8 { .content => icons.tvg.lucide.puzzle, }; } - -// ── Compact bottom tab bar (mobile) ── - -fn renderBottomTabs(dense: bool) void { - var bar = dvui.box(@src(), .{ .dir = .horizontal }, .{ - .expand = .horizontal, - .gravity_y = 1, - .min_size_content = .{ .w = 0, .h = if (dense) 40 else 52 }, - .background = true, - .color_fill = theme.colors.bg_surface, - .color_border = theme.colors.border_subtle, - .border = .{ .x = 0, .y = 1, .w = 0, .h = 0 }, - .padding = .{ .x = if (dense) 2 else theme.spacing.sm, .y = if (dense) 2 else theme.spacing.xs, .w = if (dense) 2 else theme.spacing.sm, .h = if (dense) 2 else theme.spacing.xs }, - }); - defer bar.deinit(); - - bottomTab(.home, "Home", icons.tvg.lucide.house, 401, dense); - bottomTab(.watching, "Watching", icons.tvg.lucide.tv, 402, dense); - bottomTab(.history, "History", icons.tvg.lucide.history, 403, dense); - bottomTab(.downloads, "Downloads", icons.tvg.lucide.download, 404, dense); - bottomTab(.player, "Player", icons.tvg.lucide.play, 405, dense); -} - -fn bottomTab(r: Route, label: []const u8, icon: []const u8, id_extra: usize, dense: bool) void { - const active = state.app.router.current == r; - var col = dvui.box(@src(), .{ .dir = .vertical }, .{ - .id_extra = id_extra, - .expand = .horizontal, - .background = true, - .color_fill = if (active) theme.colors.bg_elevated else transparent, - .corner_radius = dvui.Rect.all(theme.radius.sm), - .padding = .{ .x = 2, .y = if (dense) 2 else theme.spacing.xs, .w = 2, .h = if (dense) 2 else theme.spacing.xs }, - }); - defer col.deinit(); - if (navRowInteract(col)) state.app.router.navigate(r); - - const fg = if (active) theme.colors.accent else theme.colors.text_secondary; - dvui.icon(@src(), label, icon, .{}, .{ - .id_extra = id_extra, - .color_text = fg, - .min_size_content = theme.iconSize(.md), - .gravity_x = 0.5, - }); - if (!dense) { - var f = dvui.themeGet().font_body; - f.size = theme.font_size.micro; - _ = dvui.label(@src(), "{s}", .{label}, .{ - .id_extra = id_extra, - .color_text = fg, - .font = f, - .gravity_x = 0.5, - }); - } else { - components.tipId(@src(), col.data().*, label, id_extra); - } -} diff --git a/tests/features/test_drama.py b/tests/features/test_drama.py index 2a3dc97..ebf5203 100644 --- a/tests/features/test_drama.py +++ b/tests/features/test_drama.py @@ -52,7 +52,7 @@ def test_drama_module(): "rail nav entry": "renderRailTab(.Drama" in drawer, "shell label": '.Drama => "Asian Drama"' in shell, "shell icon (exists in pack)": ".Drama => icons.tvg.lucide." in shell and "clapperboard" in shell, - "browse source picker": ".Drama" in shell and "browseSourcePicker()" in shell, + "browse source picker": ".Drama" in shell and "browseSourcePicker(compact)" in shell, # ── Tokusatsu lane fully removed (drama-only tab) ── "no tokusatsu residue": no_toku, # ── Play routes through load_file + gotoPlayer (guarded) ── diff --git a/tests/features/test_iptv.py b/tests/features/test_iptv.py index 536f397..5e03bf2 100644 --- a/tests/features/test_iptv.py +++ b/tests/features/test_iptv.py @@ -145,7 +145,7 @@ def test_iptv_tab(): "rail nav entry": "renderRailTab(.Iptv" in drawer, "shell label": '.Iptv => "Live TV"' in shell, "shell icon (exists in pack)": '.Iptv => icons.tvg.lucide.@"monitor-play"' in shell, - "browse source picker": ".Iptv" in shell and "browseSourcePicker()" in shell, + "browse source picker": ".Iptv" in shell and "browseSourcePicker(compact)" in shell, # ── Pure module registered in the `zig build test` step ── "test registered": 'b.path("src/services/iptv_pure.zig")' in build, diff --git a/tests/features/test_novels.py b/tests/features/test_novels.py index bfcafa4..16e83ec 100644 --- a/tests/features/test_novels.py +++ b/tests/features/test_novels.py @@ -58,7 +58,7 @@ def test_novels_reader(): "render dispatch": '.Novels => @import("../services/novels.zig").renderContent()' in drawer, "rail nav entry": "renderRailTab(.Novels" in drawer, "shell label+icon": '.Novels => "Novels"' in shell and "book-marked" in shell, - "browse source picker": ".Novels" in shell and "browseSourcePicker()" in shell, + "browse source picker": ".Novels" in shell and "browseSourcePicker(compact)" in shell, # ── Service: async workers, chapter nav, resume ── "search worker": "pub fn searchNovels" in svc and "fn searchWorker" in svc, "chapter-list worker": "pub fn openNovel" in svc and "fn chaptersWorker" in svc, diff --git a/tests/features/test_page_shell2.py b/tests/features/test_page_shell2.py index 2c4ce1a..3da96ff 100644 --- a/tests/features/test_page_shell2.py +++ b/tests/features/test_page_shell2.py @@ -77,7 +77,7 @@ def test_motion_transitions(): @test("Page Shell Immersive Hides Nav", "Page Shell") def test_shell_immersive_navbar(): - # On the Player route, the page-shell top nav (and compact bottom tabs) must + # On the Player route, the single page-shell top nav must # auto-hide during immersive playback (fullscreen or idle-while-watching), so # the video gets the whole window. Decision reuses the unit-tested pure # chrome_autohide.shouldHideChrome; this checks the shell wiring. @@ -89,11 +89,11 @@ def test_shell_immersive_navbar(): and "nav_alpha" in sh # phase 4: nav fades instead of popping and "router.current == .player" in sh # scoped so browsing keeps the nav and "fullscreen_player_idx != null" in sh - and "and !immersive) renderBottomTabs" in sh # compact bottom tabs hide too + and "renderBottomTabs(" not in sh # destinations stay in the one-row chooser ) if not ok: return "fail", "shell top nav not gated on immersive playback" - return "pass", "top nav + bottom tabs auto-hide on immersive Player route" + return "pass", "single top nav auto-hides on immersive Player route" @test("Interaction States Render (Hover/Focus/Confirm)", "Page Shell") @@ -174,13 +174,15 @@ def test_responsive_shell_tiers(): checks = { "tiny width tier": "TINY_PT" in sc and "isTiny(" in sc, "short height tier": "SHORT_PT" in sc and "isShort(" in sc, - "compact header sheds inline search": "if (!compact) omnibox(narrow)" in sh, + "one omnibox in every width tier": "omnibox(narrow);" in sh and "if (!compact) omnibox" not in sh and "omnibox(true);" not in sh, "resize reads live window": "const window_rect = dvui.windowRect()" in sh and "root.data().rect.w" not in sh, "breakpoint swap converges": "last_tier" in sh and "dvui.refresh(null" in sh, "compact destinations remain reachable": all(label in sh for label in ('"Watching"', '"Queue"', '"History"', '"Plugins"', '"Logs"', '"Settings"')), - "dense bottom navigation": "renderBottomTabs(tiny or short)" in sh and "if (!dense)" in sh, + "single row destinations remain keyboard menus": "renderBottomTabs(" not in sh and "browseSourcePicker(compact);" in sh + and 'const labels = [_][]const u8{ "Home", "All content", "Watching" }' in sh + and "dvui.menuItemLabel(" in sh, "lyrics stack below narrow player": "lyrics_below" in sh and ".bottom else .side" in sh, "lyrics panel adapts both axes": "LyricsPanelPlacement" in ly and "panel_h" in ly and "panel_w" in ly, "dialogs fit the live window": "pub fn fitWindowSize(" in _src("src/ui/theme.zig") diff --git a/tests/features/test_page_shell3.py b/tests/features/test_page_shell3.py index 648827e..6943438 100644 --- a/tests/features/test_page_shell3.py +++ b/tests/features/test_page_shell3.py @@ -362,9 +362,9 @@ def test_nav_donate_button(): and 'openExternal(DONATE_URL)' in hdr), # A donate chip that spawns its own child process = duplicated launcher. "no second launcher": "Child.init(" not in hdr, - # The input flexes into available space and moves to its own compact - # row; the optional donate chip is hidden before space gets tight. - "omnibox responsive": 'if (!compact) omnibox(narrow);' in shell and 'omnibox(true);' in shell, + # The single input flexes within the same row at every width tier. + "omnibox responsive": "omnibox(narrow);" in shell and "omnibox(true);" not in shell + and "var search_row" not in shell and "if (!compact) omnibox" not in shell, } missing = [k for k, v in checks.items() if not v] if missing: diff --git a/tests/features/test_search_v2.py b/tests/features/test_search_v2.py index 3800cea..f8b43ca 100644 --- a/tests/features/test_search_v2.py +++ b/tests/features/test_search_v2.py @@ -55,28 +55,45 @@ def test_search_v2_resolver_projection(): @test("Search v2 native view is compact virtualized and snapshot-owned", "Search") def test_search_v2_native_view(): search = _src("src/services/search.zig") - header = _between(search, "pub fn renderSearchContent()", "fn searchButton(") + shell = _src("src/ui/shell.zig") + header = _between(search, "pub fn renderSearchContent()", "pub fn renderShellSearchControls(") + controls = _between(search, "pub fn renderShellSearchControls(", "fn searchButton(") + omnibox = _between(shell, "fn omnibox(", "fn openOmniboxTarget(") + top = _between(shell, "fn renderTopNav(", "fn renderPlayerTopNav()") refresh = _between(search, "fn refreshSearchView()", "fn renderProviderFacet()") renderer = _between(search, "fn renderUniversalResults()", "fn showResult(") row = _between(search, "fn renderCompactRow(", "pub fn ") sources = _between(search, "fn renderSearchSources()", "fn renderUniversalResults()") filters = _between(search, "fn renderSearchFilters()", "fn filterChip(") return _checked({ - "one search header": "submitSearchInput(" in header and "const modes" not in header and '"Universal"' not in header, + "one shell search field": omnibox.count("dvui.textEntry(") == 1 and top.count("omnibox(narrow);") == 1 + and "var search_row" not in shell and "navLink(.search" not in shell, + "legacy input only outside page shell": "if (!state.app.page_shell_enabled)" in header + and "submitSearchInput(" in header and "const modes" not in header, + "one copied Enter and Search dispatch": "const entered = te.enter_pressed" in omnibox + and 'chromeIconButton(@src(), icons.tvg.lucide.search, "Search"' in omnibox + and "(!entered and !submitted)" in omnibox + and omnibox.index("@memcpy(query_buf") < omnibox.index("switch (browser_pure.classifyOmnibox(text))"), + "explicit assistant bypasses legacy memory mode": "search_mod.memory_mode = false" in omnibox + and "defer search_mod.memory_mode = legacy_memory_mode" in omnibox, + "shell owns Search controls": "search_mod.renderShellSearchControls(compact)" in top + and "state.app.router.current == .search" in top, "facet panel separate from provider settings": "renderSearchFilters()" in header and "renderActiveSearchFilters()" in header and "view_filters: search_view.Filters" in search, "heap immutable snapshot": "allocator.alloc(resolver.ResolvedItem, resolver.MAX_RESULTS)" in refresh and "resolver.copySearchSnapshot(" in refresh and "resolver.results_mutex.lock()" not in renderer, "one match/sort policy": "search_view.matches(" in refresh and "search_view.lessThan(" in refresh and "resolver.sortResultsBy(" not in renderer, - "compact sort popup": "searchSelect(91300, &SORT_LABELS" in renderer and "components.segment(" not in renderer, + "sort popup closes on selection": "popup.close();" in _between(search, "fn searchSelectImpl(", "const CONTENT_LABELS"), + "compact sort popup": "searchSelectImpl(91300, &SORT_LABELS, @intFromEnum(view_sort), dense)" in controls and "&SORT_LABELS" in controls + and "components.segment(" not in controls and "searchSelect(91300" not in renderer, "filtered totals": "shown of {d} loaded" in renderer and "view_cache.count" in renderer and "view_cache.loaded" in renderer, "visible range only": "search_view.visibleRange(" in renderer and "for (range.start..range.end)" in renderer, "keyboard traversal retained": "keyboardLayoutMode(" in renderer and ".tab" in renderer, "snapshot-owned action": "view_cache.rows.?[action.idx]" in renderer and "resolver.playResolvedItem(item)" in renderer, "safe torrent queue": "torrent_risk_pure.zig" in renderer and "risk.risk == .block" in renderer and "addToQueue(" in renderer, "stable result widget identities": "resolver.actionKey(item)" in row and ".id_extra = row_key" in row, - "progress cancellation and retry": "resolver.cancel()" in renderer and '"Retry search"' in sources + "progress cancellation and retry": '@import("resolver.zig").cancel()' in controls and '"Retry search"' in sources and "resolver.resolve(view_cache.query" in sources, "real torrent row metadata": "meta_pure.metaLine(" in row and all(field in row for field in (".quality = item.quality", ".size_bytes = item.size_bytes", ".seeds = item.seeds", ".leech = item.leech")), "reader/details actions retained": all(label in row for label in ('"read"', '"details"', '"open audio"', '"play"')), @@ -104,3 +121,40 @@ def test_search_v2_web_projection(): "web applies independent facets": "matchesSearchFilters(" in catalog and "unifiedResultRows(" in catalog, "web actions use opaque identity": "apiMutation('/unified_search/' + action" in catalog and "encodeURIComponent(r.key)" in catalog and "?generation=" in catalog, }, "Additive shared search metadata retains generation-scoped opaque actions") + + +@test("Search shell dense controls and mirrored query ownership", "Search") +def test_search_shell_dense_and_query_ownership(): + search = _src("src/services/search.zig") + shell = _src("src/ui/shell.zig") + submit = _between(search, "pub fn submitQuery(", "pub fn setUniversalQuery(") + mirror = _between(search, "pub fn setUniversalQuery(", "fn cancelPendingMemorySearch()") + clear = _between(search, "pub fn clearShellSearch()", "pub var memory_mode") + controls = _between(search, "pub fn renderShellSearchControls(", "fn searchButton(") + icons = _between(search, "fn searchIconButton(", "fn searchButton(") + select = _between(search, "fn searchSelectImpl(", "const CONTENT_LABELS") + omnibox = _between(shell, "fn omnibox(", "fn openOmniboxTarget(") + return _checked({ + "dense named icon controls": all(token in controls for token in ( + "if (dense) searchIconButton", "filters_tip", '"Cancel search"', '"Retry search"')), + "bounded icon target with tooltip": ".max_size_content = .{ .w = 16, .h = 16 }" in icons + and ".padding = dvui.Rect.all(5)" in icons and "components.tip(" in icons, + "dense Sort keeps full named choices": "if (icon_only) dvui.menuItemIcon" in select + and "dvui.menuItemLabel(@src(), label" in select + and "components.tip(@src(), data, current_label)" in select, + "query owned before cancellation and mirrored writes": submit.index("@memcpy(owned") < submit.index("cancelPendingMemorySearch()") + and "setUniversalQuery(owned[0..n])" in submit + and mirror.index("@memcpy(owned") < mirror.index("@memset(&search_buf"), + "both query fields mirrored": "@memcpy(search_buf[0..n], owned[0..n])" in mirror + and "@memcpy(state.app.magnet_buf[0..n], owned[0..n])" in mirror, + "Clear invalidates both worker pipelines and visible fields": all(token in clear for token in ( + "cancelPendingMemorySearch()", "search_abort.store(true", "search_generation.fetchAdd", + "@memset(&search_buf", "@memset(&state.app.magnet_buf", 'resolver.zig").clearResults()', "view_dirty = true")), + "every new submission cancels stale memory publication": omnibox.index("search_mod.cancelPendingMemorySearch()") + < omnibox.index("switch (browser_pure.classifyOmnibox(text))"), + "shell Clear reaches coherent service": "search_mod.clearShellSearch()" in omnibox, + "retained query does not block player autohide": ".typing = false" in shell + and ".typing = text_len > 0" not in shell, + "compact input avoids inherited button and field margins": ".margin = dvui.Rect.all(0)" in icons + and ".margin = dvui.Rect.all(0)" in omnibox, + }, "Dense icons keep named menus; query aliases are owned and Clear cancels both pipelines") diff --git a/tests/features/test_vndb.py b/tests/features/test_vndb.py index 7397852..469308f 100644 --- a/tests/features/test_vndb.py +++ b/tests/features/test_vndb.py @@ -73,7 +73,7 @@ def test_vndb_catalog(): "rail nav entry": "renderRailTab(.Vndb" in drawer, "shell label": '.Vndb => "Visual Novels"' in shell, "shell icon (exists in pack)": '.Vndb => icons.tvg.lucide.@"gamepad-2"' in shell, - "browse source picker": ".Vndb" in shell and "browseSourcePicker()" in shell, + "browse source picker": ".Vndb" in shell and "browseSourcePicker(compact)" in shell, # ── Pure module registered in the `zig build test` step ── "test registered": 'b.path("src/services/vndb_pure.zig")' in build,