feat: add direct view shortcuts and category search

This commit is contained in:
2026-10-12 01:41:19 +02:00
parent b15fa23b8a
commit 28c94eb70b
4 changed files with 498 additions and 108 deletions
+13 -3
View File
@@ -60,7 +60,9 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| `j` / `k`, ↓ / ↑ | Select the next / previous video | | `j` / `k`, ↓ / ↑ | Select the next / previous video |
| Enter | Open the selected video in Terebi | | Enter | Open the selected video in Terebi |
| `g` / Home, `G` / End | Select the first / last loaded video | | `g` / Home, `G` / End | Select the first / last loaded video |
| `i` | Edit the search query | | `i` | Edit the current category/combined feed's search, or general Search from other views |
| `/` | Go directly to general YouTube Search |
| `F` (Shift-F) | Go directly to the subscription Feed |
| `d` | Open video info; press again for the full description, again to return to info | | `d` | Open video info; press again for the full description, again to return to info |
| Esc | Close the preview; otherwise return to the previous view | | Esc | Close the preview; otherwise return to the previous view |
| `-` | Return to the previous view | | `-` | Return to the previous view |
@@ -117,11 +119,19 @@ In terminals supporting bracketed paste, clipboard text is inserted in one opera
- **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses the chosen categories; Esc returns to normal mode without clearing the filter. Space is ordinary input here. - **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses the chosen categories; Esc returns to normal mode without clearing the filter. Space is ordinary input here.
- Checkboxes select categories for browsing; the dimmed `p` suffix marks pins independently. Hidden checks survive filtering. Enter still browses checked categories when there are no visible matches; without checks, an empty result makes browsing a no-op. Pinning, renaming, and toggling require a visible selected row. - Checkboxes select categories for browsing; the dimmed `p` suffix marks pins independently. Hidden checks survive filtering. Enter still browses checked categories when there are no visible matches; without checks, an empty result makes browsing a no-op. Pinning, renaming, and toggling require a visible selected row.
### Category search
Press `i` in a category or combined-category feed to search only its channels' histories—not just already loaded videos. Enter submits a changed query and returns to normal mode; an unchanged query keeps cached results and pagination. Clear the field and submit to restore the ordinary upload feed. Esc leaves the draft unsubmitted.
Each category and combined feed retains its own draft, submitted query, results, and pagination independently of general Search. Subsequent pages use the same scoped query and shared 60-video budget. Empty categories and failed scoped requests never fall back to global search. Search input in other views still opens general YouTube Search.
The direct Search and Feed jumps use the configurable `view.search` and `view.feed` actions. They preserve cached views and history, and do not run inside text inputs or selectors.
### Combined category feeds ### Combined category feeds
Check multiple categories in `c`, then press Enter to open their combined feed. One checked category opens its ordinary view; no checks retain the usual single-category shortcut. Esc discards the selector's staged choices. Reopening `c` from a combined feed prechecks its member categories. Check multiple categories in `c`, then press Enter to open their combined feed. One checked category opens its ordinary view; no checks retain the usual single-category shortcut. Esc discards the selector's staged choices. Reopening `c` from a combined feed prechecks its member categories.
The combined feed uses the shared temporary tab, replacing the prior unpinned category/channel/collection view. Channels and video IDs are deduplicated, and videos use the same newest-first ordering and infinite scrolling as other feeds. Back/forward navigation restores cached rows, selection, and pagination. The combined feed uses the shared temporary tab, replacing the prior unpinned category/channel/collection view. Channels and video IDs are deduplicated, and videos use the same newest-first ordering and infinite scrolling as other feeds. Back/forward navigation restores its query, cached rows, selection, and pagination.
Press `P` to save the viewed union of channels under a new category name. Enter commits; Esc cancels; duplicate or invalid names leave the prompt open without overwriting anything. The new flat category becomes active without automatic pinning or re-fetching matching cached pages. Its memberships are an independent copy, not links to the original categories. Press `P` to save the viewed union of channels under a new category name. Enter commits; Esc cancels; duplicate or invalid names leave the prompt open without overwriting anything. The new flat category becomes active without automatic pinning or re-fetching matching cached pages. Its memberships are an independent copy, not links to the original categories.
@@ -286,7 +296,7 @@ Using the same binding for overview and description preserves the default cycle.
## Loading and previews ## Loading and previews
Search loads up to 60 results per page. Feed and category views share a **60-upload total request budget per load**, distributed fairly across active channels—not 60 per channel. Each channel retains its own offset; exhausted channels stop receiving requests, and feeds with more than 60 channels rotate through them across loads. Matching ranges are batched across at most four concurrent yt-dlp processes, with further batches queued. Individual channel views can use the full 60-row budget. General Search loads up to 60 results per page. Feed and category views, including scoped category searches, share a **60-video total request budget per load**, distributed fairly across active channels—not 60 per channel. Each channel retains its own offset; exhausted channels stop receiving requests, and feeds with more than 60 channels rotate through them across loads. Matching ranges are batched across at most four concurrent yt-dlp processes, with further batches queued. Individual channel views can use the full 60-row budget.
In normal mode, forward video navigation authorizes prefetch when 20 loaded rows remain. Each successful page clears that intent, so short, duplicate-only, or unavailable batches do not immediately chain additional loads at startup. Only one page request runs at a time; insert mode and open popups pause automatic prefetch. A feed page is accepted only after every batch succeeds. Switching views cancels running and queued batches; returning resumes the same unaccepted per-channel ranges. Deep channel archives can get slower because yt-dlp offsets traverse earlier pages again. In normal mode, forward video navigation authorizes prefetch when 20 loaded rows remain. Each successful page clears that intent, so short, duplicate-only, or unavailable batches do not immediately chain additional loads at startup. Only one page request runs at a time; insert mode and open popups pause automatic prefetch. A feed page is accepted only after every batch succeeds. Switching views cancels running and queued batches; returning resumes the same unaccepted per-channel ranges. Deep channel archives can get slower because yt-dlp offsets traverse earlier pages again.
+6 -2
View File
@@ -110,7 +110,9 @@ actions! {
DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"]; DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"];
DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"]; DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"];
DeleteLine, "input.delete.line", "Clear the input line", TEXT, ["super+backspace", "ctrl+u", "alt+super+backspace"]; DeleteLine, "input.delete.line", "Clear the input line", TEXT, ["super+backspace", "ctrl+u", "alt+super+backspace"];
SearchEdit, "search.edit", "Edit the video search", VIEW, ["i"]; SearchEdit, "search.edit", "Edit general or current-category search", VIEW, ["i"];
ViewSearch, "view.search", "Go to general YouTube Search", VIEW, ["/"];
ViewFeed, "view.feed", "Go to the combined subscription Feed", VIEW, ["F"];
ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-", "escape"]; ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-", "escape"];
ViewNext, "view.history.next", "Go forward in view history", VIEW, ["+"]; ViewNext, "view.history.next", "Go forward in view history", VIEW, ["+"];
ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"]; ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"];
@@ -474,7 +476,9 @@ impl Bindings {
&[ &[
(&[VideoNext, VideoPrevious], "Select next / previous video"), (&[VideoNext, VideoPrevious], "Select next / previous video"),
(&[VideoPlay], "Play the selected video in Terebi"), (&[VideoPlay], "Play the selected video in Terebi"),
(&[SearchEdit], "Edit the search query"), (&[SearchEdit], "Edit general or current-category search"),
(&[ViewSearch], "Go to general YouTube Search"),
(&[ViewFeed], "Go to the combined subscription Feed"),
( (
&[InfoOverview, InfoDescription], &[InfoOverview, InfoDescription],
"Info → full description → info", "Info → full description → info",
+423 -81
View File
File diff suppressed because one or more lines are too long
+44 -10
View File
@@ -379,8 +379,34 @@ struct Extraction {
completed: Option<String>, completed: Option<String>,
} }
fn feed_commands(channels: &[String], requests: &[FeedRequest]) -> io::Result<Vec<Command>> { fn encode_query(query: &str) -> String {
const HEX: &[u8; 16] = b"0123456789ABCDEF";
let unreserved =
|byte: u8| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~');
let capacity = query
.bytes()
.map(|byte| if unreserved(byte) { 1 } else { 3 })
.sum();
let mut encoded = String::with_capacity(capacity);
for byte in query.bytes() {
if unreserved(byte) {
encoded.push(char::from(byte));
} else {
encoded.push('%');
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
encoded.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
}
encoded
}
fn feed_commands(
channels: &[String],
requests: &[FeedRequest],
query: &str,
) -> io::Result<Vec<Command>> {
validate_feed_requests(requests)?; validate_feed_requests(requests)?;
let query = (!query.is_empty()).then(|| encode_query(query));
let mut commands = Vec::new(); let mut commands = Vec::new();
let mut bounds = None; let mut bounds = None;
let batch_size = requests.len().div_ceil(MAX_EXTRACTORS); let batch_size = requests.len().div_ceil(MAX_EXTRACTORS);
@@ -417,10 +443,10 @@ fn feed_commands(channels: &[String], requests: &[FeedRequest]) -> io::Result<Ve
bounds = Some((request.start, request.count)); bounds = Some((request.start, request.count));
batch_len = 0; batch_len = 0;
} }
commands commands.last_mut().unwrap().arg(match &query {
.last_mut() Some(query) => format!("{channel}/search?query={query}"),
.unwrap() None => format!("{channel}/videos"),
.arg(format!("{channel}/videos")); });
batch_len += 1; batch_len += 1;
} }
Ok(commands) Ok(commands)
@@ -442,8 +468,8 @@ impl Job {
])) ]))
} }
pub fn feed(channels: &[String], requests: &[FeedRequest]) -> io::Result<Self> { pub fn feed(channels: &[String], requests: &[FeedRequest], query: &str) -> io::Result<Self> {
Self::spawn_all(feed_commands(channels, requests)?) Self::spawn_all(feed_commands(channels, requests, query)?)
} }
pub fn channel(id: &str) -> io::Result<Self> { pub fn channel(id: &str) -> io::Result<Self> {
@@ -1583,6 +1609,14 @@ mod tests {
assert_eq!(exhausted.feed_progress[0].count, 0); assert_eq!(exhausted.feed_progress[0].count, 0);
} }
#[test]
fn channel_search_query_preserves_utf8_and_isolates_url_parameters() {
assert_eq!(
encode_query("café 🦀 &next=1#?/+%20\t\n\0 AZaz09-._~"),
"caf%C3%A9%20%F0%9F%A6%80%20%26next%3D1%23%3F%2F%2B%2520%09%0A%00%20AZaz09-._~"
);
}
#[test] #[test]
fn page_offsets_reject_zero_and_overflow_before_spawning() { fn page_offsets_reject_zero_and_overflow_before_spawning() {
assert_eq!(page_end(1).unwrap(), PAGE_SIZE); assert_eq!(page_end(1).unwrap(), PAGE_SIZE);
@@ -1595,12 +1629,12 @@ mod tests {
Err(error) if error.kind() == io::ErrorKind::InvalidInput Err(error) if error.kind() == io::ErrorKind::InvalidInput
)); ));
assert!(matches!( assert!(matches!(
Job::feed(&channels, &[FeedRequest { start, ..ONE_CHANNEL[0] }]), Job::feed(&channels, &[FeedRequest { start, ..ONE_CHANNEL[0] }], ""),
Err(error) if error.kind() == io::ErrorKind::InvalidInput Err(error) if error.kind() == io::ErrorKind::InvalidInput
)); ));
} }
assert!(matches!( assert!(matches!(
Job::feed(&[], &ONE_CHANNEL), Job::feed(&[], &ONE_CHANNEL, ""),
Err(error) if error.kind() == io::ErrorKind::InvalidInput Err(error) if error.kind() == io::ErrorKind::InvalidInput
)); ));
} }
@@ -1660,7 +1694,7 @@ mod tests {
], ],
] { ] {
assert!(matches!( assert!(matches!(
Job::feed(&channels, &requests), Job::feed(&channels, &requests, ""),
Err(error) if error.kind() == io::ErrorKind::InvalidInput Err(error) if error.kind() == io::ErrorKind::InvalidInput
)); ));
} }