loads all shows
This commit is contained in:
@@ -6,6 +6,7 @@ use super::config::Config;
|
|||||||
pub struct JellyfinClient {
|
pub struct JellyfinClient {
|
||||||
base_url: String,
|
base_url: String,
|
||||||
client: reqwest::blocking::Client,
|
client: reqwest::blocking::Client,
|
||||||
|
token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct LoginResult {
|
pub struct LoginResult {
|
||||||
@@ -13,70 +14,186 @@ pub struct LoginResult {
|
|||||||
pub user_id: Option<String>,
|
pub user_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JellyfinClient {
|
|
||||||
pub fn from_config(cfg: &Config) -> Self {
|
|
||||||
Self {
|
|
||||||
base_url: cfg.base_url.trim_end_matches('/').to_string(),
|
|
||||||
client: reqwest::blocking::Client::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn login_by_name(&self, username: &str, password: &str) -> Result<LoginResult, String> {
|
|
||||||
let url = format!("{}/Users/AuthenticateByName", self.base_url);
|
|
||||||
|
|
||||||
// Jellyfin expects this format. :contentReference[oaicite:0]{index=0}
|
|
||||||
let body = AuthenticateUserByName {
|
|
||||||
Username: username,
|
|
||||||
Pw: password,
|
|
||||||
};
|
|
||||||
|
|
||||||
// This header identifies the client/device. :contentReference[oaicite:1]{index=1}
|
|
||||||
let auth_header = r#"MediaBrowser Client="cj-jftui", Device="kitty", DeviceId="cj-jftui", Version="0.1.0""#;
|
|
||||||
|
|
||||||
let resp = self
|
|
||||||
.client
|
|
||||||
.post(url)
|
|
||||||
.header("X-Emby-Authorization", auth_header)
|
|
||||||
.json(&body)
|
|
||||||
.send()
|
|
||||||
.map_err(|e| format!("request failed: {e}"))?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
let status = resp.status();
|
|
||||||
let text = resp.text().unwrap_or_default();
|
|
||||||
return Err(format!("login failed: {status} {text}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: AuthenticationResult = resp
|
|
||||||
.json()
|
|
||||||
.map_err(|e| format!("bad response json: {e}"))?;
|
|
||||||
|
|
||||||
let token = parsed
|
|
||||||
.AccessToken
|
|
||||||
.ok_or_else(|| "missing AccessToken in response".to_string())?;
|
|
||||||
|
|
||||||
let user_id = parsed.User.and_then(|u| u.Id);
|
|
||||||
|
|
||||||
Ok(LoginResult {
|
|
||||||
access_token: token,
|
|
||||||
user_id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct AuthenticateUserByName<'a> {
|
struct AuthenticateUserByName<'a> {
|
||||||
Username: &'a str,
|
#[serde(rename = "Username")]
|
||||||
Pw: &'a str,
|
username: &'a str,
|
||||||
|
#[serde(rename = "Pw")]
|
||||||
|
pw: &'a str,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AuthenticationResult {
|
struct AuthenticationResult {
|
||||||
AccessToken: Option<String>,
|
#[serde(rename = "AccessToken")]
|
||||||
User: Option<UserDto>,
|
access_token: Option<String>,
|
||||||
|
#[serde(rename = "User")]
|
||||||
|
user: Option<UserDto>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct UserDto {
|
struct UserDto {
|
||||||
Id: Option<String>,
|
#[serde(rename = "Id")]
|
||||||
|
id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
impl JellyfinClient {
|
||||||
|
pub fn from_config(cfg: &Config) -> Result<Self, String> {
|
||||||
|
let token = cfg
|
||||||
|
.access_token
|
||||||
|
.clone()
|
||||||
|
.ok_or_else(|| "missing access_token (need login)".to_string())?;
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
base_url: cfg.base_url.trim_end_matches('/').to_string(),
|
||||||
|
client: reqwest::blocking::Client::new(),
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn unauthenticated(base_url: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: base_url.trim_end_matches('/').to_string(),
|
||||||
|
client: reqwest::blocking::Client::new(),
|
||||||
|
token: String::new(), // unused for login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_by_name(&self, username: &str, password: &str) -> Result<LoginResult, String> {
|
||||||
|
let url = format!("{}/Users/AuthenticateByName", self.base_url);
|
||||||
|
|
||||||
|
let auth_header = format!(
|
||||||
|
"MediaBrowser Client=\"cj-jftui\", Device=\"kitty\", DeviceId=\"cj-jftui\", Version=\"{}\"",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let body = AuthenticateUserByName { username, pw: password };
|
||||||
|
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.post(url)
|
||||||
|
.header("X-Emby-Authorization", auth_header)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.map_err(|e| format!("request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().unwrap_or_default();
|
||||||
|
return Err(format!("login failed: {status} {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: AuthenticationResult = resp
|
||||||
|
.json()
|
||||||
|
.map_err(|e| format!("bad response json: {e}"))?;
|
||||||
|
|
||||||
|
let token = parsed
|
||||||
|
.access_token
|
||||||
|
.ok_or_else(|| "missing AccessToken in response".to_string())?;
|
||||||
|
|
||||||
|
let user_id = parsed.user.and_then(|u| u.id);
|
||||||
|
|
||||||
|
Ok(LoginResult {
|
||||||
|
access_token: token,
|
||||||
|
user_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn auth_header_value(&self) -> String {
|
||||||
|
// Identify as a client and include the token.
|
||||||
|
format!(
|
||||||
|
"MediaBrowser Client=\"cj-jftui\", Device=\"kitty\", DeviceId=\"cj-jftui\", Version=\"{}\", Token=\"{}\"",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
self.token
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_items(
|
||||||
|
&self,
|
||||||
|
user_id: &str,
|
||||||
|
query: LibraryQuery,
|
||||||
|
start_index: usize,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<PagedItems, String> {
|
||||||
|
let url = format!("{}/Users/{}/Items", self.base_url, user_id);
|
||||||
|
|
||||||
|
let mut req = self
|
||||||
|
.client
|
||||||
|
.get(url)
|
||||||
|
.header("X-Emby-Authorization", self.auth_header_value())
|
||||||
|
.query(&[
|
||||||
|
("Recursive", "true"),
|
||||||
|
("StartIndex", &start_index.to_string()),
|
||||||
|
("Limit", &limit.to_string()),
|
||||||
|
("Fields", "PrimaryImageAspectRatio,SortName,ProductionYear"),
|
||||||
|
("SortBy", "SortName"),
|
||||||
|
("SortOrder", "Ascending"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// apply query (movie/series/etc)
|
||||||
|
if let Some(types) = query.include_item_types.as_deref() {
|
||||||
|
req = req.query(&[("IncludeItemTypes", types)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let resp = req.send().map_err(|e| format!("request failed: {e}"))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let text = resp.text().unwrap_or_default();
|
||||||
|
return Err(format!("list_items failed: {status} {text}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: ItemsResponse = resp
|
||||||
|
.json()
|
||||||
|
.map_err(|e| format!("bad response json: {e}"))?;
|
||||||
|
|
||||||
|
Ok(PagedItems {
|
||||||
|
items: parsed.items,
|
||||||
|
total: parsed.total_record_count.unwrap_or(0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What kind of library list we want.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LibraryQuery {
|
||||||
|
pub include_item_types: Option<String>, // e.g. Some("Movie".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LibraryQuery {
|
||||||
|
pub fn movies() -> Self {
|
||||||
|
Self { include_item_types: Some("Movie".into()) }
|
||||||
|
}
|
||||||
|
pub fn shows() -> Self {
|
||||||
|
Self { include_item_types: Some("Series".into()) }
|
||||||
|
}
|
||||||
|
pub fn music() -> Self {
|
||||||
|
Self { include_item_types: Some("Audio".into()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PagedItems {
|
||||||
|
pub items: Vec<JfItem>,
|
||||||
|
pub total: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct JfItem {
|
||||||
|
#[serde(rename = "Id")]
|
||||||
|
pub id: String,
|
||||||
|
#[serde(rename = "Name")]
|
||||||
|
pub name: String,
|
||||||
|
#[serde(rename = "Type")]
|
||||||
|
pub item_type: Option<String>,
|
||||||
|
#[serde(rename = "ProductionYear")]
|
||||||
|
pub production_year: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct ItemsResponse {
|
||||||
|
#[serde(rename = "Items")]
|
||||||
|
items: Vec<JfItem>,
|
||||||
|
#[serde(rename = "TotalRecordCount")]
|
||||||
|
total_record_count: Option<usize>,
|
||||||
|
}
|
||||||
259
src/main.rs
259
src/main.rs
@@ -1,5 +1,7 @@
|
|||||||
mod core;
|
mod core;
|
||||||
|
|
||||||
|
const PAGE_SIZE: usize = 50;
|
||||||
|
|
||||||
use std::{
|
use std::{
|
||||||
io,
|
io,
|
||||||
time::{Duration, Instant},
|
time::{Duration, Instant},
|
||||||
@@ -22,7 +24,14 @@ use ratatui::backend::CrosstermBackend;
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum View {
|
enum View {
|
||||||
Categories,
|
Categories,
|
||||||
Placeholder(String), // temporary for testing navigation
|
Library {
|
||||||
|
title: String,
|
||||||
|
query: core::jellyfin::LibraryQuery,
|
||||||
|
items: Vec<core::jellyfin::JfItem>,
|
||||||
|
selected: usize,
|
||||||
|
start_index: usize,
|
||||||
|
total: usize,
|
||||||
|
},
|
||||||
Login,
|
Login,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +189,7 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let client = core::jellyfin::JellyfinClient::from_config(&cfg);
|
let client = core::jellyfin::JellyfinClient::unauthenticated(&cfg.base_url);
|
||||||
match client.login_by_name(&app.login_user, &app.login_pass) {
|
match client.login_by_name(&app.login_user, &app.login_pass) {
|
||||||
Ok(res) => {
|
Ok(res) => {
|
||||||
let mut new_cfg = cfg;
|
let mut new_cfg = cfg;
|
||||||
@@ -224,10 +233,61 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
match app.current_view() {
|
match app.current_view() {
|
||||||
View::Categories => {
|
View::Categories => {
|
||||||
let selected = app.items[app.selected].clone();
|
let cat = app.items[app.selected].clone();
|
||||||
app.view_stack.push(View::Placeholder(selected.clone()));
|
|
||||||
app.status = format!("Entered {}", selected);
|
// Map your categories to Jellyfin queries (expand later)
|
||||||
app.selected = 0;
|
let query = match cat.as_str() {
|
||||||
|
"Movies" => core::jellyfin::LibraryQuery::movies(),
|
||||||
|
"Shows" => core::jellyfin::LibraryQuery::shows(),
|
||||||
|
"Music" => core::jellyfin::LibraryQuery::music(),
|
||||||
|
_ => {
|
||||||
|
app.status = format!("Not wired yet: {}", cat);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let cfg = match &app.config {
|
||||||
|
Some(c) => c.clone(),
|
||||||
|
None => {
|
||||||
|
app.status = "No config loaded".to_string();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_id = match cfg.user_id.as_deref() {
|
||||||
|
Some(u) => u,
|
||||||
|
None => {
|
||||||
|
app.status = "Missing user_id in config (login again)".to_string();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = match core::jellyfin::JellyfinClient::from_config(&cfg) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
app.status = e;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.status = format!("Fetching {}...", cat);
|
||||||
|
|
||||||
|
match client.list_items(user_id, query.clone(), 0, PAGE_SIZE) {
|
||||||
|
Ok(page) => {
|
||||||
|
app.view_stack.push(View::Library {
|
||||||
|
title: cat,
|
||||||
|
query,
|
||||||
|
items: page.items,
|
||||||
|
selected: 0,
|
||||||
|
start_index: 0,
|
||||||
|
total: page.total,
|
||||||
|
});
|
||||||
|
app.status = "Loaded".to_string();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
app.status = format!("Fetch failed: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -242,28 +302,128 @@ fn handle_key(app: &mut App, code: KeyCode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
KeyCode::Up | KeyCode::Char('k') => {
|
KeyCode::Up | KeyCode::Char('k') => {
|
||||||
if app.items.is_empty() {
|
match app.view_stack.last_mut().unwrap() {
|
||||||
return;
|
View::Categories => {
|
||||||
|
if app.items.is_empty() { return; }
|
||||||
|
if app.selected == 0 { app.selected = app.items.len() - 1; }
|
||||||
|
else { app.selected -= 1; }
|
||||||
|
app.status = format!("Selected: {}", app.items[app.selected]);
|
||||||
|
}
|
||||||
|
View::Library { items, selected, .. } => {
|
||||||
|
if items.is_empty() { return; }
|
||||||
|
if *selected == 0 { *selected = items.len() - 1; }
|
||||||
|
else { *selected -= 1; }
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
if app.selected == 0 {
|
|
||||||
app.selected = app.items.len() - 1;
|
|
||||||
} else {
|
|
||||||
app.selected -= 1;
|
|
||||||
}
|
|
||||||
app.status = format!("Selected: {}", app.items[app.selected]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
KeyCode::Down | KeyCode::Char('j') => {
|
KeyCode::Down | KeyCode::Char('j') => {
|
||||||
if app.items.is_empty() {
|
match app.view_stack.last_mut().unwrap() {
|
||||||
return;
|
View::Categories => {
|
||||||
|
if app.items.is_empty() { return; }
|
||||||
|
app.selected = (app.selected + 1) % app.items.len();
|
||||||
|
app.status = format!("Selected: {}", app.items[app.selected]);
|
||||||
|
}
|
||||||
|
View::Library { items, selected, total, .. } => {
|
||||||
|
if items.is_empty() { return; }
|
||||||
|
|
||||||
|
// If we're at the end of what we have loaded…
|
||||||
|
if *selected + 1 >= items.len() {
|
||||||
|
// …and there’s more available on the server, fetch next page.
|
||||||
|
if items.len() < *total {
|
||||||
|
load_next_library_page(app);
|
||||||
|
|
||||||
|
// After loading more, move down if possible.
|
||||||
|
if let Some(View::Library { items, selected, .. }) = app.view_stack.last_mut() {
|
||||||
|
if !items.is_empty() && *selected + 1 < items.len() {
|
||||||
|
*selected += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// wrap or stop—choose behavior:
|
||||||
|
// stop at end:
|
||||||
|
// do nothing
|
||||||
|
// OR wrap:
|
||||||
|
// *selected = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
*selected += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
app.selected = (app.selected + 1) % app.items.len();
|
|
||||||
app.status = format!("Selected: {}", app.items[app.selected]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn load_next_library_page(app: &mut App) {
|
||||||
|
// Only meaningful if the current view is Library
|
||||||
|
let (query, next_start, total) = match app.view_stack.last() {
|
||||||
|
Some(View::Library { query, items, total, start_index, .. }) => {
|
||||||
|
let loaded = items.len();
|
||||||
|
let next_start = start_index + loaded;
|
||||||
|
(query.clone(), next_start, *total)
|
||||||
|
}
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If we already loaded everything, do nothing
|
||||||
|
let loaded = match app.view_stack.last() {
|
||||||
|
Some(View::Library { items, .. }) => items.len(),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
if loaded >= total {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cfg = match &app.config {
|
||||||
|
Some(c) => c.clone(),
|
||||||
|
None => {
|
||||||
|
app.status = "No config loaded".to_string();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_id = match cfg.user_id.as_deref() {
|
||||||
|
Some(u) => u,
|
||||||
|
None => {
|
||||||
|
app.status = "Missing user_id in config (login again)".to_string();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let client = match core::jellyfin::JellyfinClient::from_config(&cfg) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
app.status = e;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
app.status = format!("Loading more... ({}/{})", loaded, total);
|
||||||
|
|
||||||
|
let page = match client.list_items(user_id, query.clone(), next_start, PAGE_SIZE) {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
app.status = format!("Fetch failed: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Append the new items into the current library view
|
||||||
|
if let Some(View::Library { items, total, .. }) = app.view_stack.last_mut() {
|
||||||
|
items.extend(page.items);
|
||||||
|
*total = page.total; // keep total fresh in case server changes
|
||||||
|
}
|
||||||
|
|
||||||
|
app.status = "Loaded more".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fn ui(frame: &mut Frame, app: &App) {
|
fn ui(frame: &mut Frame, app: &App) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
let panes = layout(area);
|
let panes = layout(area);
|
||||||
@@ -277,8 +437,8 @@ fn ui(frame: &mut Frame, app: &App) {
|
|||||||
View::Categories => {
|
View::Categories => {
|
||||||
render_categories(frame, app, panes.left_main, panes.right_main);
|
render_categories(frame, app, panes.left_main, panes.right_main);
|
||||||
}
|
}
|
||||||
View::Placeholder(name) => {
|
View::Library { title, items, selected, start_index, total, .. } => {
|
||||||
render_placeholder(frame, name, panes.left_main, panes.right_main);
|
render_library(frame, title, *start_index, *total, items, *selected, panes.left_main, panes.right_main);
|
||||||
}
|
}
|
||||||
View::Login => {
|
View::Login => {
|
||||||
render_login(frame, app, panes.left_main, panes.right_main);
|
render_login(frame, app, panes.left_main, panes.right_main);
|
||||||
@@ -368,6 +528,63 @@ fn render_placeholder(frame: &mut Frame, name: &str, left: Rect, right: Rect) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn render_library(
|
||||||
|
frame: &mut Frame,
|
||||||
|
title: &str,
|
||||||
|
start_index: usize,
|
||||||
|
total: usize,
|
||||||
|
items: &[core::jellyfin::JfItem],
|
||||||
|
selected: usize,
|
||||||
|
left: Rect,
|
||||||
|
right: Rect,
|
||||||
|
) {
|
||||||
|
let viewport = left.height.saturating_sub(2) as usize;
|
||||||
|
|
||||||
|
let len = items.len();
|
||||||
|
let selected = selected.min(len.saturating_sub(1));
|
||||||
|
|
||||||
|
let (start, end) = compute_window(len, selected, viewport);
|
||||||
|
|
||||||
|
let list_items: Vec<ListItem> = items[start..end]
|
||||||
|
.iter()
|
||||||
|
.map(|it| {
|
||||||
|
let year = it.production_year.map(|y| format!(" ({y})")).unwrap_or_default();
|
||||||
|
ListItem::new(format!("{}{}", it.name, year))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(list_items)
|
||||||
|
.block(
|
||||||
|
Block::bordered()
|
||||||
|
.title(format!("{title} [{}/{}]", items.len(), total))
|
||||||
|
.merge_borders(MergeStrategy::Exact),
|
||||||
|
)
|
||||||
|
.highlight_symbol("> ")
|
||||||
|
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
|
|
||||||
|
let mut state = ListState::default();
|
||||||
|
state.select(if len > 0 { Some(selected - start) } else { None });
|
||||||
|
|
||||||
|
frame.render_stateful_widget(list, left, &mut state);
|
||||||
|
|
||||||
|
let detail = if len > 0 {
|
||||||
|
let it = &items[selected];
|
||||||
|
format!(
|
||||||
|
"Name: {}\nId: {}\nType: {:?}\nYear: {:?}",
|
||||||
|
it.name, it.id, it.item_type, it.production_year
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
"No items returned".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(detail)
|
||||||
|
.block(Block::bordered().title("Details").merge_borders(MergeStrategy::Exact)),
|
||||||
|
right,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
struct Panes {
|
struct Panes {
|
||||||
top: Rect,
|
top: Rect,
|
||||||
left_main: Rect,
|
left_main: Rect,
|
||||||
@@ -388,7 +605,7 @@ fn layout(area: Rect) -> Panes {
|
|||||||
.areas(bottom);
|
.areas(bottom);
|
||||||
|
|
||||||
let [left_main, right_main] =
|
let [left_main, right_main] =
|
||||||
Layout::horizontal([Fill(2), Fill(5)])
|
Layout::horizontal([Fill(3), Fill(5)])
|
||||||
.spacing(Spacing::Overlap(1))
|
.spacing(Spacing::Overlap(1))
|
||||||
.areas(main_area);
|
.areas(main_area);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user