feat: add direct view shortcuts and category search
This commit is contained in:
@@ -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
@@ -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",
|
||||||
|
|||||||
+435
-93
File diff suppressed because one or more lines are too long
+44
-10
@@ -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
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user