Refactor ui structure; Implement scrolling and proper element highlighting
This commit is contained in:
parent
5e65f09748
commit
912eae8daf
194
src/app.rs
Normal file
194
src/app.rs
Normal 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
85
src/crossterm.rs
Normal 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);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
330
src/gui.rs
330
src/gui.rs
@ -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]);
|
|
||||||
}
|
|
||||||
130
src/main.rs
130
src/main.rs
@ -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(())
|
|
||||||
// }
|
|
||||||
|
|||||||
@ -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,17 +20,36 @@ pub struct Publication {
|
|||||||
pub referenced_works: Vec<String>,
|
pub referenced_works: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
impl Publication {
|
||||||
pub struct OpenAlexResponse {
|
pub fn get_title(&self) -> Option<String> {
|
||||||
pub results: Vec<Publication>,
|
self.display_name.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reconstruct_abstract(inverted_index: &HashMap<String, Vec<u32>>) -> String {
|
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();
|
let mut words_with_pos: Vec<(u32, &String)> = Vec::new();
|
||||||
|
|
||||||
for (word, positions) in inverted_index {
|
for (word, positions) in &content {
|
||||||
for &pos in positions {
|
for pos in positions {
|
||||||
words_with_pos.push((pos, word));
|
words_with_pos.push((*pos, word));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,6 +64,13 @@ pub fn reconstruct_abstract(inverted_index: &HashMap<String, Vec<u32>>) -> Strin
|
|||||||
let cleaner = Builder::empty();
|
let cleaner = Builder::empty();
|
||||||
let sanitized = cleaner.clean(&unsanitized).to_string();
|
let sanitized = cleaner.clean(&unsanitized).to_string();
|
||||||
sanitized.replace("\u{a0}", " ").trim().to_string()
|
sanitized.replace("\u{a0}", " ").trim().to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct OpenAlexResponse {
|
||||||
|
pub results: Vec<Publication>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Get all papers, not just the first page
|
// TODO: Get all papers, not just the first page
|
||||||
|
|||||||
192
src/ui.rs
Normal file
192
src/ui.rs
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user