Refactor ui structure; Implement scrolling and proper element highlighting

This commit is contained in:
Andreas Tsouchlos 2025-12-29 20:48:10 +02:00
parent 5e65f09748
commit 912eae8daf
6 changed files with 553 additions and 450 deletions

194
src/app.rs Normal file
View File

@ -0,0 +1,194 @@
use ratatui::{crossterm::event::KeyCode, widgets::ListState};
use serde::{Deserialize, Serialize};
use crate::snowballing::Publication;
#[derive(Serialize, Deserialize, Default, PartialEq)]
pub enum ActivePane {
IncludedPublications,
#[default]
PendingPublications,
}
#[derive(Serialize, Deserialize, Default)]
pub enum SnowballingStep {
#[default]
Forward,
Backward,
}
impl ToString for SnowballingStep {
fn to_string(&self) -> String {
match self {
SnowballingStep::Forward => String::from("forward"),
SnowballingStep::Backward => String::from("backward"),
}
}
}
#[derive(Serialize, Deserialize)]
struct SerializableListState {
pub offset: usize,
pub selected: Option<usize>,
}
pub mod liststate_serde {
use serde::{Deserializer, Serializer};
use super::*;
pub fn serialize<S>(
state: &ListState,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let surrogate = SerializableListState {
offset: state.offset(),
selected: state.selected(),
};
surrogate.serialize(serializer)
}
pub fn deserialize<'de, D>(deserializer: D) -> Result<ListState, D::Error>
where
D: Deserializer<'de>,
{
let s = SerializableListState::deserialize(deserializer)?;
Ok(ListState::default()
.with_offset(s.offset)
.with_selected(s.selected))
}
}
#[derive(Serialize, Deserialize, Default)]
pub struct App {
/// List of Publications that have been conclusively included
pub included_publications: Vec<Publication>,
/// List of Publications pending screening
pub pending_publications: Vec<Publication>,
/// List of Publications that have been conclusively excluded
pub excluded_publications: Vec<Publication>,
/// UI state: included publications list
#[serde(with = "liststate_serde")]
pub included_list_state: ListState,
/// UI state: pending publications list
#[serde(with = "liststate_serde")]
pub pending_list_state: ListState,
/// UI state: active pane
#[serde(skip)]
pub active_pane: ActivePane,
pub snowballing_iteration: usize,
pub snowballing_step: SnowballingStep,
#[serde(skip)]
pub should_quit: bool,
}
// TODO: Implement exclusion and inclusion of papers (e.g., X and Y chars)
// TODO: Implement moving through steps and iterations (populating pending papers)
// TODO: Implement input of seed papers using IDs
// TODO: Implement possibility of pushing excluded papers back into pending
// TODO: Implement export of included papers as csv for keywording with a spreadsheet
// TODO: Implement export of included papers into zotero (Use RIS format somehow)
impl App {
pub fn handle_key(&mut self, key: KeyCode) {
match key {
KeyCode::Char('q') => {
self.should_quit = true;
}
KeyCode::Enter => match self.active_pane {
ActivePane::IncludedPublications => {
if let Some(idx) = self.included_list_state.selected() {
open::that(&self.included_publications[idx].id)
.unwrap();
}
}
ActivePane::PendingPublications => {
if let Some(idx) = self.pending_list_state.selected() {
open::that(&self.pending_publications[idx].id).unwrap();
}
}
},
KeyCode::Char('j') => match self.active_pane {
ActivePane::IncludedPublications => {
let i = match self.included_list_state.selected() {
Some(i) => {
if i >= self.included_publications.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.included_list_state.select(Some(i));
}
ActivePane::PendingPublications => {
let i = match self.pending_list_state.selected() {
Some(i) => {
if i >= self.pending_publications.len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.pending_list_state.select(Some(i));
}
},
KeyCode::Char('k') => match self.active_pane {
ActivePane::IncludedPublications => {
let i = match self.included_list_state.selected() {
Some(i) => {
if i == 0 {
self.included_publications.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.included_list_state.select(Some(i));
}
ActivePane::PendingPublications => {
let i = match self.pending_list_state.selected() {
Some(i) => {
if i == 0 {
self.pending_publications.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.pending_list_state.select(Some(i));
}
},
KeyCode::Char('h') => {
self.active_pane = ActivePane::IncludedPublications;
if let None = self.included_list_state.selected() {
self.included_list_state.select(Some(0));
}
}
KeyCode::Char('l') => {
self.active_pane = ActivePane::PendingPublications;
if let None = self.pending_list_state.selected() {
self.pending_list_state.select(Some(0));
}
}
_ => {}
}
}
}

85
src/crossterm.rs Normal file
View File

@ -0,0 +1,85 @@
use std::{error::Error, io, time::Duration};
use ratatui::{
Terminal,
backend::{Backend, CrosstermBackend},
crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event},
execute,
terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
enable_raw_mode,
},
},
};
use crate::{app::App, ui};
pub fn run(app: App) -> Result<(), Box<dyn Error>> {
// setup terminal
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// create app and run it
let app_result = run_app(&mut terminal, app);
// restore terminal
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
if let Err(err) = app_result {
println!("{err:?}");
}
Ok(())
}
// TODO: Implement save on quit
fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut app: App,
) -> io::Result<()>
where
io::Error: From<B::Error>,
{
loop {
terminal.draw(|frame| ui::draw(frame, &mut app))?;
if event::poll(Duration::from_millis(10))? {
if let Event::Key(key) = event::read()? {
app.handle_key(key.code);
}
}
if app.should_quit {
return Ok(());
}
}
}
// pub fn run_app<B: Backend>(
// terminal: &mut Terminal<B>,
// mut app: App,
// ) -> io::Result<App>
// where
// io::Error: From<B::Error>,
// {
// loop {
// terminal.draw(|f| ui(f, &app))?;
//
// if let Event::Key(key) = event::read()? {
// app.handle_key(key.code);
// if app.should_quit {
// return Ok(app);
// }
// }
// }
// }

View File

@ -1,330 +0,0 @@
use ratatui::{
crossterm::event::{self, Event, KeyCode},
prelude::*,
widgets::{Block, Borders, List, ListItem},
};
use serde::{Deserialize, Serialize};
use std::io;
use crate::snowballing::{Publication, reconstruct_abstract};
#[derive(Serialize, Deserialize, Default)]
enum ActivePane {
#[default]
IncludedPublications,
PendingPublications,
}
#[derive(Serialize, Deserialize, Default)]
enum SnowballingStep {
#[default]
Forward,
Backward,
}
impl ToString for SnowballingStep {
fn to_string(&self) -> String {
match self {
SnowballingStep::Forward => String::from("forward"),
SnowballingStep::Backward => String::from("backward"),
}
}
}
#[derive(Serialize, Deserialize, Default)]
pub struct App {
/// List of Publications that have been conclusively included
pub included_publications: Vec<Publication>,
/// List of Publications pending screening
pub pending_publications: Vec<Publication>,
/// List of Publications that have been conclusively excluded
pub excluded_publications: Vec<Publication>,
/// Index of the selected item in the included publications list (UI)
pub included_selected_idx: usize,
/// Index of the selected item in the pending publications list (UI)
pub pending_selected_idx: usize,
pub active_pane: ActivePane,
pub snowballing_iteration: usize,
pub snowballing_step: SnowballingStep,
pub should_quit: bool,
}
// TODO: The whole architecture of the app may be a bit unusual. Compare with
// example on https://ratatui.rs/examples/widgets/scrollbar/
// TODO: Implement exclusion and inclusion of papers (e.g., X and Y chars)
// TODO: Implement moving through steps and iterations (populating pending papers)
// TODO: Implement input of seed papers using IDs
// TODO: Implement possibility of pushing excluded papers back into pending
// TODO: Implement export of included papers as csv for keywording with a spreadsheet
// TODO: Implement export of included papers into zotero (Use RIS format somehow)
impl App {
fn handle_key(&mut self, key: KeyCode) {
match key {
KeyCode::Char('q') => {
self.should_quit = true;
}
KeyCode::Enter => match self.active_pane {
ActivePane::IncludedPublications => {
open::that(
&self.included_publications[self.included_selected_idx]
.id,
)
.unwrap();
}
ActivePane::PendingPublications => {
open::that(
&self.pending_publications[self.pending_selected_idx]
.id,
)
.unwrap();
}
},
KeyCode::Char('h') => {
self.active_pane = ActivePane::IncludedPublications;
}
KeyCode::Char('l') => {
self.active_pane = ActivePane::PendingPublications;
}
KeyCode::Char('k') => match self.active_pane {
ActivePane::IncludedPublications => {
if self.included_selected_idx > 0 {
self.included_selected_idx -= 1;
}
}
ActivePane::PendingPublications => {
if self.pending_selected_idx > 0 {
self.pending_selected_idx -= 1;
}
}
},
KeyCode::Char('j') => match self.active_pane {
ActivePane::IncludedPublications => {
if self.included_selected_idx
< self.included_publications.len().saturating_sub(1)
{
self.included_selected_idx += 1;
}
}
ActivePane::PendingPublications => {
if self.pending_selected_idx
< self.pending_publications.len().saturating_sub(1)
{
self.pending_selected_idx += 1;
}
}
},
_ => {}
}
}
}
pub fn run_app<B: Backend>(
terminal: &mut Terminal<B>,
mut app: App,
) -> io::Result<App>
where
io::Error: From<B::Error>,
{
loop {
terminal.draw(|f| ui(f, &app))?;
if let Event::Key(key) = event::read()? {
app.handle_key(key.code);
if app.should_quit {
return Ok(app);
}
}
}
}
// TODO: Implement scrolling. See
//https://ratatui.rs/examples/widgets/scrollbar/
fn ui(f: &mut Frame, app: &App) {
// Root layout
let panes = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(f.area());
// Left pane
let left_pane = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(2)])
.split(panes[0]);
let included_pubs: Vec<ListItem> = app
.included_publications
.iter()
.enumerate()
.map(|(idx, item)| {
let style = if idx == app.included_selected_idx
&& matches!(app.active_pane, ActivePane::IncludedPublications)
{
Style::default().bg(Color::Rgb(50, 50, 50))
} else {
Style::default()
};
let mut author_str = item
.authorships
.first()
.map(|authorship| authorship.raw_author_name.clone())
.expect(
"Papers are required to always have at least one author",
);
if item.authorships.len() > 1 {
author_str.push_str(" et al.");
}
ListItem::new(vec![
Line::from(vec![
Span::raw(
item.display_name.clone().unwrap_or("".to_string()),
),
Span::styled(
format!(
" ({})",
item.publication_year
.map_or("".to_string(), |val| val.to_string())
),
Style::default().fg(Color::Yellow),
),
]),
Line::from(Span::styled(
author_str,
Style::default().fg(Color::Blue),
)),
])
.style(style)
})
.collect();
let num_included_pubs = included_pubs.len();
let included_pub_list = List::new(included_pubs)
.block(
Block::default()
.title_top(Line::from("Included Publications").centered())
.title_top(
Line::from(Span::styled(
format!("{} entries", num_included_pubs),
Style::default().fg(Color::Yellow),
))
.right_aligned(),
)
.borders(Borders::ALL),
)
.style(Style::default());
f.render_widget(included_pub_list, left_pane[0]);
let status = vec![
Line::from(vec![
Span::raw("Iteration: "),
Span::styled(
app.snowballing_iteration.to_string(),
Style::default().fg(Color::Cyan),
),
]),
Line::from(vec![
Span::raw("Step: "),
Span::styled(
app.snowballing_step.to_string(),
Style::default().fg(Color::Yellow),
),
]),
];
let status_widget =
ratatui::widgets::Paragraph::new(status).style(Style::default());
f.render_widget(status_widget, left_pane[1]);
// Right pane
let pending_pubs: Vec<ListItem> = app
.pending_publications
.iter()
.enumerate()
.map(|(idx, item)| {
let style = if idx == app.pending_selected_idx
&& matches!(app.active_pane, ActivePane::PendingPublications)
{
Style::default().bg(Color::Rgb(50, 50, 50))
} else {
Style::default()
};
let mut author_str = item
.authorships
.first()
.map(|authorship| authorship.raw_author_name.clone())
.expect(
"Papers are required to always have at least one author",
);
if item.authorships.len() > 1 {
author_str.push_str(" et al.");
}
let mut lines = vec![
Line::from(vec![
Span::raw(
item.display_name.clone().unwrap_or("".to_string()),
),
Span::styled(
format!(
" ({})",
item.publication_year
.map_or("".to_string(), |val| val.to_string())
),
Style::default().fg(Color::Yellow),
),
]),
Line::from(Span::styled(
author_str,
Style::default().fg(Color::Blue),
)),
];
let available_width = panes[1].width.saturating_sub(4) as usize; // subtract for borders/padding
let abstract_text = item
.abstract_inverted_index
.clone()
.map_or("[No abstact available]".to_string(), |content| {
reconstruct_abstract(&content)
});
let wrapped = textwrap::fill(
&abstract_text,
available_width.saturating_sub(2),
);
for wrapped_line in wrapped.lines() {
lines.push(Line::from(format!(" {}", wrapped_line)));
}
ListItem::new(lines).style(style)
})
.collect();
let num_pending_pubs = pending_pubs.len();
let pengind_pub_list = List::new(pending_pubs)
.block(
Block::default()
.title_top(
Line::from("Publications Pending Screening").centered(),
)
.title_top(
Line::from(Span::styled(
format!("{} entries", num_pending_pubs),
Style::default().fg(Color::Yellow),
))
.right_aligned(),
)
.borders(Borders::ALL),
)
.style(Style::default());
f.render_widget(pengind_pub_list, panes[1]);
}

View File

@ -1,30 +1,27 @@
use std::io;
use ratatui::Terminal;
use ratatui::crossterm::{
execute,
terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode,
enable_raw_mode,
},
};
use ratatui::prelude::CrosstermBackend;
use serde_json; use serde_json;
mod gui; mod app;
use crate::gui::{App, run_app}; mod ui;
use crate::snowballing::get_citing_papers; use crate::app::App;
mod snowballing; mod snowballing;
use clap::Parser; // use crate::snowballing::get_citing_papers;
// #[tokio::main]
#[derive(Parser)] // async fn main() -> Result<(), reqwest::Error> {
#[command(name = "Brittling")] // let publications = get_citing_papers("w2963127785", "an.tsouchlos@gmail.com").await?;
#[command(about = "A tool to perform snowballing for literature studies", long_about = None)] //
struct Args { // let app = App {
#[arg(short, long)] // pending_publications: publications,
savefile: String, // ..Default::default()
} // };
//
// if let Ok(serialized) = serde_json::to_string_pretty(&app) {
// std::fs::write("temp.json", serialized)
// .expect("We can't really deal with io errors ourselves");
// }
//
// Ok(())
// }
fn load_savefile(filename: &String) -> Result<App, serde_json::Error> { fn load_savefile(filename: &String) -> Result<App, serde_json::Error> {
match std::fs::read_to_string(filename) { match std::fs::read_to_string(filename) {
@ -43,84 +40,23 @@ fn load_savefile(filename: &String) -> Result<App, serde_json::Error> {
} }
} }
#[derive(Debug)] use clap::Parser;
enum Error { mod crossterm;
IOError(std::io::Error), use std::error::Error;
JsonError(serde_json::Error),
#[derive(Parser)]
#[command(name = "Brittling")]
#[command(about = "A tool to perform snowballing for literature studies", long_about = None)]
struct Args {
#[arg(short, long)]
savefile: String,
} }
impl From<std::io::Error> for Error { fn main() -> Result<(), Box<dyn Error>> {
fn from(value: std::io::Error) -> Self {
Error::IOError(value)
}
}
impl From<serde_json::Error> for Error {
fn from(value: serde_json::Error) -> Self {
Error::JsonError(value)
}
}
// TODO: The whole initialization and deinitialization and use of crossterm
// might be unnecessary. Compare with example on
// https://ratatui.rs/examples/widgets/scrollbar/
fn main() -> io::Result<()> {
let args = Args::parse(); let args = Args::parse();
let app = load_savefile(&args.savefile)?;
// <Init terminal> crate::crossterm::run(app)?;
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
// </Init terminal>
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let mut err: Option<Error> = None;
let app = load_savefile(&args.savefile);
if let Ok(app) = app {
let result = run_app(&mut terminal, app);
match result {
Ok(app) => match serde_json::to_string_pretty(&app) {
Ok(serialized) => std::fs::write(args.savefile, serialized)?,
Err(ser_err) => err = Some(ser_err.into()),
},
Err(app_err) => {
err = Some(app_err.into());
}
}
} else {
err = Some(app.err().unwrap().into());
}
// <Deinit terminal>
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
// </Deinit terminal>
if let Some(err) = err {
eprintln!("{:?}", err);
}
Ok(()) Ok(())
} }
// #[tokio::main]
// async fn main() -> Result<(), reqwest::Error> {
// let publications = get_citing_papers("w2963127785", "an.tsouchlos@gmail.com").await?;
//
// let app = App {
// pending_publications: publications,
// ..Default::default()
// };
//
// if let Ok(serialized) = serde_json::to_string_pretty(&app) {
// std::fs::write("temp.json", serialized)
// .expect("We can't really deal with io errors ourselves");
// }
//
// Ok(())
// }

View File

@ -1,7 +1,7 @@
use ammonia::Builder; use ammonia::Builder;
use reqwest::Error; use reqwest::Error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::{collections::HashMap};
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct Authorship { pub struct Authorship {
@ -20,33 +20,59 @@ pub struct Publication {
pub referenced_works: Vec<String>, pub referenced_works: Vec<String>,
} }
impl Publication {
pub fn get_title(&self) -> Option<String> {
self.display_name.clone()
}
pub fn get_year(&self) -> Option<u32> {
self.publication_year
}
pub fn get_author_text(&self) -> String {
let mut author_str = self
.authorships
.first()
.map(|authorship| authorship.raw_author_name.clone())
.expect("Papers are required to always have at least one author");
if self.authorships.len() > 1 {
author_str.push_str(" et al.");
}
author_str
}
pub fn get_abstract(&self) -> Option<String> {
self.abstract_inverted_index.clone().map(|content| {
let mut words_with_pos: Vec<(u32, &String)> = Vec::new();
for (word, positions) in &content {
for pos in positions {
words_with_pos.push((*pos, word));
}
}
words_with_pos.sort_by_key(|k| k.0);
let unsanitized = words_with_pos
.into_iter()
.map(|(_, word)| word.as_str())
.collect::<Vec<_>>()
.join(" ");
let cleaner = Builder::empty();
let sanitized = cleaner.clean(&unsanitized).to_string();
sanitized.replace("\u{a0}", " ").trim().to_string()
})
}
}
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub struct OpenAlexResponse { pub struct OpenAlexResponse {
pub results: Vec<Publication>, pub results: Vec<Publication>,
} }
pub fn reconstruct_abstract(inverted_index: &HashMap<String, Vec<u32>>) -> String {
let mut words_with_pos: Vec<(u32, &String)> = Vec::new();
for (word, positions) in inverted_index {
for &pos in positions {
words_with_pos.push((pos, word));
}
}
words_with_pos.sort_by_key(|k| k.0);
let unsanitized = words_with_pos
.into_iter()
.map(|(_, word)| word.as_str())
.collect::<Vec<_>>()
.join(" ");
let cleaner = Builder::empty();
let sanitized = cleaner.clean(&unsanitized).to_string();
sanitized.replace("\u{a0}", " ").trim().to_string()
}
// TODO: Get all papers, not just the first page // TODO: Get all papers, not just the first page
pub async fn get_citing_papers( pub async fn get_citing_papers(
target_id: &str, target_id: &str,

192
src/ui.rs Normal file
View File

@ -0,0 +1,192 @@
use std::fmt::format;
use crate::{
app::{ActivePane, App},
snowballing::Publication,
};
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{
Color, Modifier, Style, Stylize, palette::material::AccentedPalette,
},
text::{Line, Span},
widgets::{
Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation,
ScrollbarState,
},
};
pub fn draw(f: &mut Frame, app: &mut App) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(f.area());
draw_left_pane(f, app, chunks[0]);
draw_right_pane(f, app, chunks[1]);
}
fn create_publication_item_list(
publications: &Vec<Publication>,
selected_idx: Option<usize>,
available_width: usize,
show_abstract: bool,
) -> Vec<ListItem<'_>> {
publications
.iter()
.enumerate()
.map(|(idx, publ)| {
let is_selected = Some(idx) == selected_idx;
let (border_char, border_style) = if is_selected {
(
"",
Style::default()
.fg(Color::Gray)
.add_modifier(Modifier::BOLD),
)
} else {
(" ", Style::default())
};
let mut lines = vec![
Line::from(vec![
Span::styled(border_char, border_style),
Span::raw(format!("[{}] ", idx + 1)),
Span::raw(
publ.get_title()
.unwrap_or("[No title available]".to_string()),
),
Span::styled(
format!(
" ({})",
publ.publication_year
.map_or("".to_string(), |val| val.to_string())
),
Style::default().fg(Color::Yellow),
),
]),
Line::from(vec![
Span::styled(border_char, border_style),
Span::styled(
publ.get_author_text(),
Style::default().fg(Color::Blue),
),
]),
];
if show_abstract {
let wrapped = textwrap::fill(
&publ
.get_abstract()
.unwrap_or("[No abstract available]".to_string()),
available_width.saturating_sub(2),
);
for wrapped_line in wrapped.lines() {
lines.push(Line::from(vec![
Span::styled(border_char, border_style),
Span::raw(format!(" {}", wrapped_line)),
]));
}
}
if is_selected {
lines = lines
.iter()
.map(|line| line.clone().add_modifier(Modifier::BOLD))
.collect();
}
ListItem::new(lines)
})
.collect()
}
fn draw_left_pane(frame: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(10), Constraint::Length(2)])
.split(area);
// Included Publication list
let items = create_publication_item_list(
&app.included_publications,
if app.active_pane == ActivePane::IncludedPublications {
app.included_list_state.selected()
} else {
None
},
chunks[0].width.saturating_sub(4) as usize,
false,
);
let list = List::new(items).block(
Block::default()
.title_top(Line::from("Included Publications").centered())
.title_top(
Line::from(Span::styled(
format!("{} entries", app.included_publications.len()),
Style::default().fg(Color::Yellow),
))
.right_aligned(),
)
.borders(Borders::ALL),
);
frame.render_stateful_widget(list, chunks[0], &mut app.included_list_state);
// Snowballing status
let status = vec![
Line::from(vec![
Span::raw("Iteration: "),
Span::styled(
app.snowballing_iteration.to_string(),
Style::default().fg(Color::Cyan),
),
]),
Line::from(vec![
Span::raw("Step: "),
Span::styled(
app.snowballing_step.to_string(),
Style::default().fg(Color::Yellow),
),
]),
];
let status_widget =
ratatui::widgets::Paragraph::new(status).style(Style::default());
frame.render_widget(status_widget, chunks[1]);
}
fn draw_right_pane(frame: &mut Frame, app: &mut App, area: Rect) {
// Included Publication list
let items = create_publication_item_list(
&app.included_publications,
if app.active_pane == ActivePane::PendingPublications {
app.pending_list_state.selected()
} else {
None
},
area.width.saturating_sub(4) as usize,
true,
);
let list = List::new(items).block(
Block::default()
.title_top(Line::from("Publications Pending Screening").centered())
.title_top(
Line::from(Span::styled(
format!("{} entries", app.pending_publications.len()),
Style::default().fg(Color::Yellow),
))
.right_aligned(),
)
.borders(Borders::ALL),
);
frame.render_stateful_widget(list, area, &mut app.pending_list_state);
}