// bibiman - a TUI for managing BibLaTeX databases
// Copyright (C) 2024 lukeflo
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see .
/////
use std::path::PathBuf;
use super::popup::{PopupArea, PopupItem};
use crate::bibiman::bibisetup::CustomField;
use crate::bibiman::entries::EntryTableColumn;
use crate::bibiman::{CurrentArea, FormerArea};
use crate::cliargs::CLIArgs;
use crate::config::BibiConfig;
use crate::tui::popup::PopupKind;
use crate::App;
use itertools::Itertools;
use ratatui::layout::{Direction, Position};
use ratatui::widgets::Clear;
use ratatui::Frame;
use ratatui::{
layout::{Alignment, Constraint, Flex, Layout, Rect},
style::{Color, Modifier, Style, Stylize},
symbols,
text::{Line, Span, Text},
widgets::{
Block, Borders, Cell, HighlightSpacing, List, ListItem, Padding, Paragraph, Row, Scrollbar,
ScrollbarOrientation, Table, Wrap,
},
};
use walkdir::WalkDir;
// Symbols
static SORTED_ENTRIES: &str = "▼";
static SORTED_ENTRIES_REVERSED: &str = "▲";
static SCROLLBAR_UPPER_CORNER: Option<&str> = Some("┓");
static SCROLLBAR_LOWER_CORNER: Option<&str> = Some("┛");
pub fn color_list(
args: &CLIArgs,
list_item: i32,
sel_item: i32,
highlight: u8,
max_diff: i32,
) -> Color {
match args.light_theme {
false => {
if list_item == sel_item {
Color::Indexed(highlight)
} else if (list_item - sel_item) > max_diff
|| (sel_item - list_item) > max_diff
|| -(list_item - sel_item) > max_diff
|| -(sel_item - list_item) > max_diff
{
Color::Indexed(highlight - max_diff as u8)
} else if list_item < sel_item {
Color::Indexed(highlight - (sel_item - list_item) as u8)
} else {
Color::Indexed(highlight - (list_item - sel_item) as u8)
}
}
true => {
if list_item == sel_item {
Color::Indexed(highlight)
} else if (list_item - sel_item) > max_diff
|| (sel_item - list_item) > max_diff
|| -(list_item - sel_item) > max_diff
|| -(sel_item - list_item) > max_diff
{
Color::Indexed(highlight + max_diff as u8)
} else if list_item < sel_item {
Color::Indexed(highlight + (sel_item - list_item) as u8)
} else {
Color::Indexed(highlight + (list_item - sel_item) as u8)
}
}
}
}
fn count_files(files: &[PathBuf]) -> u16 {
let mut count = 0;
for f in files {
if f.is_file() {
count += 1
} else if f.is_dir() {
for e in WalkDir::new(f) {
let f = e.unwrap().into_path();
if f.is_file() && f.extension().unwrap() == "bib" {
count += 1
}
}
}
}
count
}
pub fn render_ui(app: &mut App, cfg: &BibiConfig, frame: &mut Frame) {
let [header_area, main_area, footer_area] = Layout::new(
Direction::Vertical,
[
Constraint::Length(1),
Constraint::Fill(1),
Constraint::Length(if let CurrentArea::SearchArea = app.bibiman.current_area {
1
} else {
0
}),
],
)
.direction(Direction::Vertical)
.areas(frame.area());
let [list_area, item_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Fill(1)]).areas(main_area);
let [entry_area, entry_info_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(2)]).areas(list_area);
let [tag_area, info_area] =
Layout::horizontal([Constraint::Max(25), Constraint::Min(35)]).areas(item_area);
render_header(cfg, frame, header_area);
if let CurrentArea::SearchArea = app.bibiman.current_area {
render_footer(app, cfg, frame, footer_area);
}
render_entrytable(app, cfg, frame, entry_area);
render_selected_item(app, cfg, frame, info_area);
render_taglist(app, cfg, frame, tag_area);
render_file_info(app, cfg, frame, entry_info_area);
if app.bibiman.popup_area.is_popup {
render_popup(app, cfg, frame);
}
}
pub fn render_popup(app: &mut App, cfg: &BibiConfig, frame: &mut Frame) {
match app.bibiman.popup_area.popup_kind {
Some(PopupKind::Help) => {
let block = Block::bordered()
.title_top(" Keybindings ".bold())
.title_bottom(" (j,k|↓,↑) ━ (ESC|ENTER) ".bold())
.title_alignment(Alignment::Center)
.style(
Style::new()
.fg(cfg.colors.main_text_color)
.bg(cfg.colors.popup_bg_color),
)
.border_set(symbols::border::THICK)
.border_style(Style::new().fg(cfg.colors.entry_color));
let text: Text = PopupArea::popup_help(cfg);
// Calculate max scroll position depending on hight of terminal window
// Needed length is number of text lines plus two for borders at bottom and top
// minus half the height of the frame (or the height set for the popup)
let popup_height: u16 = frame.area().height / 2;
let scroll_pos = if app.bibiman.popup_area.popup_scroll_pos
> text.lines.len() as u16 + 2 - popup_height
{
app.bibiman.popup_area.popup_scroll_pos =
text.lines.len() as u16 + 2 - popup_height;
app.bibiman.popup_area.popup_scroll_pos
} else {
app.bibiman.popup_area.popup_scroll_pos
};
let par = Paragraph::new(text).scroll((scroll_pos, 0)).block(block);
let par_width = par.line_width();
let popup_area = popup_area(frame.area(), par_width as u16, popup_height);
frame.render_widget(Clear, popup_area);
frame.render_widget(par, popup_area)
}
Some(PopupKind::AddEntry) => {
let area = frame.area();
let block = Block::bordered()
.title_top(" Add Entry ".bold())
.title_bottom(" (ESC) ━ (ENTER) ".bold())
.title_alignment(Alignment::Center)
.style(
Style::new()
.fg(cfg.colors.main_text_color)
.bg(cfg.colors.popup_bg_color),
)
.border_set(symbols::border::THICK)
.border_style(Style::new().fg(cfg.colors.entry_color));
// Prepare the input fields
let content = vec![Line::from(vec![
Span::styled("DOI: ", Style::new().fg(cfg.colors.entry_color)),
Span::raw(app.input.value().to_string().clone()),
])];
let paragraph = Paragraph::new(content)
.block(block.clone())
.style(Style::new().fg(cfg.colors.main_text_color))
.wrap(Wrap { trim: false });
let doi_lines = paragraph.line_count(area.width / 2);
// Calculate popup size
let popup_width = area.width / 4 * 3;
let popup_height = doi_lines as u16; // Adjust as needed
let popup_area = popup_area(area, popup_width, popup_height);
// Render the popup
frame.render_widget(Clear, popup_area);
render_cursor(app, frame, popup_area, 6, doi_lines as u16 - 1);
frame.render_widget(paragraph, popup_area);
}
Some(PopupKind::MessageConfirm) => {
let area = frame.area();
let block = Block::bordered()
.title_top(" Message ".bold().fg(cfg.colors.confirm_color))
.border_style(Style::new().fg(cfg.colors.confirm_color))
.style(
Style::new()
.fg(cfg.colors.main_text_color)
.bg(cfg.colors.popup_bg_color),
);
let content = Paragraph::new(app.bibiman.popup_area.popup_message.clone())
.block(block)
.style(Style::new().fg(cfg.colors.confirm_color));
// Calculate popup size. Width is number of string chars plus 2 for border
let popup_area = popup_area(
area,
(app.bibiman.popup_area.popup_message.chars().count() + 2) as u16,
3,
);
// Clear area and draw popup
frame.render_widget(Clear, popup_area);
frame.render_widget(&content, popup_area)
}
Some(PopupKind::MessageError) => {
let area = frame.area();
let block = Block::bordered()
.title_top(" Warning ".bold().fg(cfg.colors.warn_color))
.border_style(Style::new().fg(Color::Red))
.style(
Style::new()
.fg(cfg.colors.main_text_color)
.bg(cfg.colors.popup_bg_color),
);
let content = Paragraph::new(app.bibiman.popup_area.popup_message.clone())
.block(block)
.style(Style::new().fg(cfg.colors.warn_color));
// Calculate popup size. Width is number of string chars plus 2 for border
let popup_area = popup_area(
area,
(app.bibiman.popup_area.popup_message.chars().count() + 2) as u16,
3,
);
// Clear area and draw popup
frame.render_widget(Clear, popup_area);
frame.render_widget(&content, popup_area)
}
Some(PopupKind::OpenRes)
| Some(PopupKind::AppendToFile)
| Some(PopupKind::YankItem)
| Some(PopupKind::CreateNote) => {
let list_items: Vec = if let Some(PopupKind::CreateNote) =
app.bibiman.popup_area.popup_kind
{
app.bibiman
.popup_area
.popup_list
.iter()
.map(|(m, o, _i)| {
ListItem::from(Line::from(vec![Span::raw(m), Span::raw("."), Span::raw(o)]))
.fg(cfg.colors.note_color)
})
.collect()
} else {
app.bibiman
.popup_area
.popup_list
.iter()
.map(
|(mes, obj, i)| {
let style: Color = match i {
PopupItem::Bibfile => cfg.colors.entry_color,
PopupItem::Citekey => cfg.colors.entry_color,
PopupItem::Entryfile => cfg.colors.file_color,
PopupItem::Notefile => cfg.colors.note_color,
PopupItem::Link => cfg.colors.link_color,
PopupItem::Default => cfg.colors.main_text_color,
PopupItem::None => cfg.colors.main_text_color,
};
ListItem::from(
Line::from(vec![
Span::styled(mes, Style::new().bold()),
Span::raw(obj),
])
.fg(style),
)
}, // ListItem::from(mes.to_owned() + obj)
)
.collect()
};
let title = if let Some(PopupKind::OpenRes) = app.bibiman.popup_area.popup_kind {
" Open "
} else if let Some(PopupKind::AppendToFile) = app.bibiman.popup_area.popup_kind {
" Select file to append entry "
} else if let Some(PopupKind::YankItem) = app.bibiman.popup_area.popup_kind {
" Yank to clipboard "
} else if let Some(PopupKind::CreateNote) = app.bibiman.popup_area.popup_kind {
" Create Note with extension "
} else {
" Select "
};
let bottom_info = if let Some(PopupKind::OpenRes) = app.bibiman.popup_area.popup_kind {
" (j,k|↓,↑) ━ (o,l,n) ━ (ENTER) ━ (ESC) "
} else if let Some(PopupKind::YankItem) = app.bibiman.popup_area.popup_kind {
" (j,k|↓,↑) ━ (y) ━ (ENTER) ━ (ESC) "
} else {
" (j,k|↓,↑) ━ (ENTER) ━ (ESC) "
};
let block = Block::bordered()
.title_top(title.bold())
.title_bottom(bottom_info.bold())
.title_alignment(Alignment::Center)
.style(
Style::new()
.fg(cfg.colors.main_text_color)
.bg(cfg.colors.popup_bg_color),
)
.border_set(symbols::border::THICK)
.border_style(Style::new().fg(cfg.colors.popup_fg_color));
let list = List::new(list_items).block(block).highlight_style(
Style::new()
// .fg(cfg.colors.entry_color)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED),
);
// To find the longest line, we need to collect the chars of every item
// and add.
let list_widths = app
.bibiman
.popup_area
.popup_list
.iter()
.max_by(|(mes, obj, _ik), (m, o, _i)| {
let x = mes.chars().count() + obj.chars().count();
let y = m.chars().count() + o.chars().count();
x.cmp(&y)
})
.unwrap();
// .map(|(m, o)| m.chars().count() as u16 + o.chars().count() as u16)
// .collect();
// Now take the max number for the width of the popup
// let max_item = list_widths.iter().max().unwrap().to_owned();
let max_item = list_widths.0.clone() + &list_widths.1;
// list_widths.0.chars().count() as u16 + list_widths.1.chars().count() as u16;
let fitting_width: u16 = {
let lines = vec![title, bottom_info, &max_item];
let lline = lines
.iter()
.max_by(|a, b| a.chars().count().cmp(&b.chars().count()))
.unwrap();
// lines.first().unwrap().chars().count() as u16
lline.chars().count() as u16
};
// Check if the popup would exceed the terminal frame width
let popup_width = if fitting_width + 2 > frame.area().width - 2 {
frame.area().width - 2
} else {
fitting_width + 2
};
// } else if title.chars().count() as u16 > max_item {
// (title.chars().count() + 2) as u16
// } else {
// max_item + 2
// };
let popup_heigth = list.len() + 2;
let popup_area = popup_area(frame.area(), popup_width, popup_heigth as u16);
frame.render_widget(Clear, popup_area);
frame.render_stateful_widget(list, popup_area, &mut app.bibiman.popup_area.popup_state)
}
None => {}
}
}
pub fn render_header(cfg: &BibiConfig, frame: &mut Frame, rect: Rect) {
let main_header = Paragraph::new("BIBIMAN – BibLaTeX manager TUI")
.bold()
.fg(cfg.colors.entry_color)
.centered();
frame.render_widget(main_header, rect)
}
pub fn render_footer(app: &mut App, cfg: &BibiConfig, frame: &mut Frame, rect: Rect) {
let search_title = {
match app.bibiman.former_area {
Some(FormerArea::EntryArea) => "Search Entries: ".to_string(),
Some(FormerArea::TagArea) => "Search Keywords: ".to_string(),
_ => " Search ".to_string(),
}
};
let title_lenght: u16 = search_title.chars().count() as u16;
let block = Block::new()
.padding(Padding::horizontal(1))
.bg(cfg.colors.bar_bg_color);
let search_string = Paragraph::new(Line::from(vec![
Span::styled(
search_title,
if let Some(FormerArea::EntryArea) = app.bibiman.former_area {
Style::new()
.fg(cfg.colors.entry_color)
.add_modifier(Modifier::BOLD)
} else if let Some(FormerArea::TagArea) = app.bibiman.former_area {
Style::new()
.fg(cfg.colors.keyword_color)
.add_modifier(Modifier::BOLD)
} else {
Style::new()
.fg(cfg.colors.highlight_text_color)
.add_modifier(Modifier::BOLD)
},
),
Span::raw(app.bibiman.search_struct.search_string.clone())
.fg(cfg.colors.highlight_text_color),
]))
.block(block);
render_cursor(app, frame, rect, title_lenght + 1, 1);
frame.render_widget(search_string, rect);
}
// Render info of the current file and process
// 1. Basename of the currently loaded file
// 2. Keyword by which the entries are filtered at the moment
// 3. Currently selected entry and total count of entries
pub fn render_file_info(app: &mut App, cfg: &BibiConfig, frame: &mut Frame, rect: Rect) {
let block = Block::new() // can also be Block::new
// Leave Top empty to simulate one large box with borders of entry list
.borders(Borders::LEFT | Borders::RIGHT | Borders::BOTTOM)
.border_set(if let CurrentArea::EntryArea = app.bibiman.current_area {
symbols::border::THICK
} else {
symbols::border::PLAIN
})
.border_style(if let CurrentArea::EntryArea = app.bibiman.current_area {
Style::new().fg(cfg.colors.highlight_text_color)
} else {
Style::new().fg(cfg.colors.main_text_color)
});
frame.render_widget(block, rect);
let [file_area, keyword_area, count_area] = Layout::horizontal([
Constraint::Fill(3),
Constraint::Fill(4),
Constraint::Fill(1),
])
.horizontal_margin(1)
.areas(rect);
let file_info = if app.bibiman.main_bibfiles.len() == 1
&& app.bibiman.main_bibfiles.first().unwrap().is_file()
{
Line::from(vec![
Span::raw("File: ").fg(cfg.colors.main_text_color).bold(),
Span::raw(
app.bibiman.main_bibfiles[0]
.file_name()
.unwrap()
.to_string_lossy(),
)
.fg(cfg.colors.main_text_color)
.bold(),
])
.bg(cfg.colors.bar_bg_color)
} else {
Line::from(vec![
Span::raw("Multiple files (")
.fg(cfg.colors.main_text_color)
.bold(),
Span::raw(count_files(&app.bibiman.main_bibfiles).to_string())
.fg(cfg.colors.main_text_color)
.bold(),
Span::raw(")").fg(cfg.colors.main_text_color).bold(),
])
.bg(cfg.colors.bar_bg_color)
};
let cur_keywords = Line::from(if !app.bibiman.tag_list.selected_keywords.is_empty() {
vec![
Span::raw("Selected keywords: ").fg(cfg.colors.main_text_color),
// Show all keywords in correct order if list is filtered
// successively by multiple keywords
Span::raw(app.bibiman.tag_list.selected_keywords.join(" → "))
.bold()
.green(),
]
} else {
vec![Span::raw(" ")]
})
.bg(cfg.colors.bar_bg_color);
// .render(keyword_area, buf);
let item_count = Line::from(
if app
.bibiman
.entry_table
.entry_table_state
.selected()
.is_some()
{
vec![
Span::raw(
// Because method scroll_down_by() of TableState lets numbers
// printed overflow for short moment, we have to check manually
// that we do not print a number higher than len() of table
if app
.bibiman
.entry_table
.entry_table_state
.selected()
.unwrap()
+ 1
> app.bibiman.entry_table.entry_table_items.len()
{
app.bibiman.entry_table.entry_table_items.len().to_string()
} else {
(app.bibiman
.entry_table
.entry_table_state
.selected()
.unwrap()
+ 1)
.to_string()
},
)
.fg(cfg.colors.main_text_color)
.bold(),
Span::raw("/").fg(cfg.colors.main_text_color),
Span::raw(app.bibiman.entry_table.entry_table_items.len().to_string())
.fg(cfg.colors.main_text_color),
]
} else {
vec![Span::raw("No entries")]
},
)
.right_aligned()
.bg(cfg.colors.bar_bg_color);
frame.render_widget(file_info, file_area);
frame.render_widget(cur_keywords, keyword_area);
frame.render_widget(item_count, count_area);
}
pub fn render_entrytable(app: &mut App, cfg: &BibiConfig, frame: &mut Frame, rect: Rect) {
let entry_box_selected_border_style: Style = Style::new().fg(cfg.colors.highlight_text_color);
let entry_box_selected_title_style: Style = Style::new()
.fg(cfg.colors.entry_color)
.add_modifier(Modifier::BOLD);
let entry_box_unselected_border_style: Style = Style::new().fg(cfg.colors.main_text_color);
let entry_box_unselected_title_style: Style = Style::new()
.fg(cfg.colors.entry_color)
.add_modifier(Modifier::BOLD);
let selected_table_col_style: Style = Style::new().add_modifier(Modifier::BOLD);
let selectec_table_cell_style: Style = Style::new().add_modifier(Modifier::REVERSED);
let entry_selected_row_style: Style = Style::new()
.fg(cfg.colors.entry_color)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED);
let block = Block::new() // can also be Block::new
.title(
Line::styled(
" Bibliographic Entries ",
if let CurrentArea::EntryArea = app.bibiman.current_area {
entry_box_selected_title_style
} else {
entry_box_unselected_title_style
},
)
.centered(),
)
.borders(Borders::LEFT | Borders::RIGHT | Borders::TOP)
.border_set(if let CurrentArea::EntryArea = app.bibiman.current_area {
symbols::border::THICK
} else {
symbols::border::PLAIN
})
.border_style(if let CurrentArea::EntryArea = app.bibiman.current_area {
entry_box_selected_border_style
} else {
entry_box_unselected_border_style
});
let header_style = Style::default()
.bold()
.fg(cfg.colors.main_text_color)
.bg(cfg.colors.bar_bg_color);
let custom_col_title: &str = match cfg.general.custom_column {
CustomField::Journaltitle => "Journaltitle",
CustomField::Organization => "Organization",
CustomField::Institution => "Institution",
CustomField::Series => "Series",
CustomField::Publisher => "Publisher",
CustomField::Pubtype => "Pubtype",
};
let header = Row::new(vec![
Cell::from(Line::from("Res.")).bg(cfg.colors.bar_bg_color),
Cell::from(
Line::from(vec![{ Span::raw("Author") }, {
if let Some(EntryTableColumn::Authors) =
app.bibiman.entry_table.entry_table_sorted_by_col
{
Span::raw(format!(
" {}",
if app.bibiman.entry_table.entry_table_reversed_sort {
SORTED_ENTRIES_REVERSED
} else {
SORTED_ENTRIES
}
))
} else {
Span::raw("")
}
}])
.bg(
if let EntryTableColumn::Authors =
app.bibiman.entry_table.entry_table_selected_column
{
cfg.colors.selected_row_bg_color
} else {
cfg.colors.bar_bg_color
},
),
),
Cell::from(
Line::from(vec![{ Span::raw("Title") }, {
if let Some(EntryTableColumn::Title) =
app.bibiman.entry_table.entry_table_sorted_by_col
{
Span::raw(format!(
" {}",
if app.bibiman.entry_table.entry_table_reversed_sort {
SORTED_ENTRIES_REVERSED
} else {
SORTED_ENTRIES
}
))
} else {
Span::raw("")
}
}])
.bg(
if let EntryTableColumn::Title = app.bibiman.entry_table.entry_table_selected_column
{
cfg.colors.selected_row_bg_color
} else {
cfg.colors.bar_bg_color
},
),
),
Cell::from(
Line::from(vec![{ Span::raw("Year") }, {
if let Some(EntryTableColumn::Year) =
app.bibiman.entry_table.entry_table_sorted_by_col
{
Span::raw(format!(
" {}",
if app.bibiman.entry_table.entry_table_reversed_sort {
SORTED_ENTRIES_REVERSED
} else {
SORTED_ENTRIES
}
))
} else {
Span::raw("")
}
}])
.bg(
if let EntryTableColumn::Year = app.bibiman.entry_table.entry_table_selected_column
{
cfg.colors.selected_row_bg_color
} else {
cfg.colors.bar_bg_color
},
),
),
Cell::from(
Line::from(vec![{ Span::raw(custom_col_title) }, {
if let Some(EntryTableColumn::Pubtype) =
app.bibiman.entry_table.entry_table_sorted_by_col
{
Span::raw(format!(
" {}",
if app.bibiman.entry_table.entry_table_reversed_sort {
SORTED_ENTRIES_REVERSED
} else {
SORTED_ENTRIES
}
))
} else {
Span::raw("")
}
}])
.bg(
if let EntryTableColumn::Pubtype =
app.bibiman.entry_table.entry_table_selected_column
{
cfg.colors.selected_row_bg_color
} else {
cfg.colors.bar_bg_color
},
),
),
])
.style(header_style)
.height(1);
// Iterate over vector storing each entries data fields
let rows = app
.bibiman
.entry_table
.entry_table_items
.iter_mut()
.enumerate()
.map(|(_i, data)| {
let item = data.ref_vec(cfg);
let mut symbol_vec = vec![];
// use default or custom symbols for resources
// if an entry has no, replace it with the correct number
// of whitespace to align the symbols correct
if let Some(f) = &item.symbols[0] {
symbol_vec.push(Span::styled(
f,
Style::new().fg(cfg.colors.file_color).bold(),
));
} else {
symbol_vec.push(Span::raw(
" ".repeat(cfg.general.file_symbol.chars().count()),
));
}
if let Some(l) = &item.symbols[1] {
symbol_vec.push(Span::styled(
l,
Style::new().fg(cfg.colors.link_color).bold(),
));
} else {
symbol_vec.push(Span::raw(
" ".repeat(cfg.general.link_symbol.chars().count()),
));
}
if let Some(n) = &item.symbols[2] {
symbol_vec.push(Span::styled(
n,
Style::new().fg(cfg.colors.note_color).bold(),
))
} else {
symbol_vec.push(Span::raw(
" ".repeat(cfg.general.note_symbol.chars().count()),
));
}
let row = Row::new(vec![
Cell::from(Line::from(symbol_vec)),
Cell::from(Line::from(item.authors)),
Cell::from(Line::from(item.title)),
Cell::from(Line::from(item.year)),
Cell::from(Line::from(item.custom_field_value)),
]);
// let row = item
// .into_iter()
// .map(|content| Cell::from(Text::from(content.to_string())))
// .collect::();
row.style(
Style::new().fg(if let CurrentArea::EntryArea = app.bibiman.current_area {
cfg.colors.highlight_text_color
} else {
cfg.colors.main_text_color
}),
)
.height(1)
});
let entry_table = Table::new(
rows,
[
Constraint::Length(
(cfg.general.file_symbol.chars().count()
+ cfg.general.link_symbol.chars().count()
+ cfg.general.note_symbol.chars().count()) as u16,
),
Constraint::Percentage(20),
Constraint::Fill(1),
Constraint::Length(
if let Some(EntryTableColumn::Year) =
app.bibiman.entry_table.entry_table_sorted_by_col
{
6
} else {
4
},
),
Constraint::Percentage(10),
],
)
.block(block)
.header(header)
.column_spacing(2)
.row_highlight_style(entry_selected_row_style)
.column_highlight_style(selected_table_col_style)
.cell_highlight_style(selectec_table_cell_style)
.highlight_spacing(HighlightSpacing::Always);
frame.render_stateful_widget(
entry_table,
rect,
&mut app.bibiman.entry_table.entry_table_state,
);
// Scrollbar for entry table
let scrollbar = Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.track_symbol(None)
.begin_symbol(SCROLLBAR_UPPER_CORNER)
.end_symbol(None)
.thumb_style(Style::new().fg(Color::DarkGray));
if let CurrentArea::EntryArea = app.bibiman.current_area {
// render the scrollbar
frame.render_stateful_widget(
scrollbar,
rect,
&mut app.bibiman.entry_table.entry_scroll_state,
);
}
}
pub fn render_selected_item(app: &mut App, cfg: &BibiConfig, frame: &mut Frame, rect: Rect) {
// We get the info depending on the item's state.
let style_value = Style::new().bold().fg(cfg.colors.main_text_color);
let lines = {
if app
.bibiman
.entry_table
.entry_table_state
.selected()
.is_some()
{
let idx = app
.bibiman
.entry_table
.entry_table_state
.selected()
.unwrap();
let cur_entry = &app.bibiman.entry_table.entry_table_items[idx];
let mut lines = vec![];
lines.push(Line::from(vec![
Span::styled("Authors: ", style_value),
// Span::styled(cur_entry.authors.clone(), Style::new().green()),
Span::styled(
cur_entry.authors(),
Style::new().fg(cfg.colors.author_color),
),
]));
if cur_entry.subtitle.is_some() {
lines.push(Line::from(vec![
Span::styled("Title: ", style_value),
Span::styled(
cur_entry.title(),
Style::new()
.fg(cfg.colors.title_color)
.add_modifier(Modifier::ITALIC),
),
Span::styled(
": ",
Style::new()
.fg(cfg.colors.title_color)
.add_modifier(Modifier::ITALIC),
),
Span::styled(
cur_entry.subtitle(),
Style::new()
.fg(cfg.colors.title_color)
.add_modifier(Modifier::ITALIC),
),
]));
} else {
lines.push(Line::from(vec![
Span::styled("Title: ", style_value),
Span::styled(
cur_entry.title(),
Style::new()
.fg(cfg.colors.title_color)
.add_modifier(Modifier::ITALIC),
),
]));
}
lines.push(Line::from(vec![
Span::styled("Year: ", style_value),
Span::styled(cur_entry.year(), Style::new().fg(cfg.colors.year_color)),
]));
// Render keywords in info box in Markdown code style
if !cur_entry.keywords.is_empty() {
let kw: Vec<&str> = cur_entry
.keywords
.split(",")
.map(|k| k.trim())
.filter(|k| !k.is_empty())
.collect();
let mut content = vec![Span::styled("Keywords: ", style_value)];
for k in kw {
// Add half block highlighted in bg color to enlarge block
content.push(Span::raw("▐").fg(cfg.colors.bar_bg_color));
content.push(Span::styled(
k,
Style::default().bg(cfg.colors.bar_bg_color).fg(
// Highlight selected keyword green
if app
.bibiman
.tag_list
.selected_keywords
.iter()
.any(|e| e == k)
{
Color::Green
} else {
cfg.colors.main_text_color
},
),
));
content.push(Span::raw("▌").fg(cfg.colors.bar_bg_color));
}
lines.push(Line::from(content))
}
if cur_entry.doi_url.is_some() || cur_entry.filepath.is_some() {
lines.push(Line::raw(""));
}
if cur_entry.doi_url.is_some() {
lines.push(Line::from(vec![
Span::styled("DOI/URL: ", style_value),
Span::styled(
cur_entry.doi_url(),
Style::new().fg(cfg.colors.link_color).underlined(),
),
]));
}
if let Some(p) = &cur_entry.filepath {
lines.push(Line::from(vec![
Span::styled("File: ", style_value),
Span::styled(
p.iter().map(|f| f.to_str().unwrap()).join("; "),
Style::new().fg(cfg.colors.file_color),
),
]));
}
if let Some(n) = &cur_entry.notes {
lines.push(Line::from(vec![
Span::styled("Note: ", style_value),
Span::styled(
n.iter().map(|n| n.to_str().unwrap()).join("; "),
Style::new().fg(cfg.colors.note_color),
),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
cur_entry.abstract_text.clone(),
Style::new().fg(cfg.colors.main_text_color),
)]));
lines
} else {
let lines = vec![
Line::from(" "),
"No entry selected".bold().into_centered_line().red(),
];
lines
}
};
let info = Text::from(lines);
// We show the list item's info under the list in this paragraph
let block = Block::bordered()
.title(
Line::raw(" Entry Information ")
.centered()
.bold()
.fg(cfg.colors.info_color),
)
.border_set(symbols::border::PLAIN)
.border_style(Style::new().fg(cfg.colors.main_text_color))
.padding(Padding::horizontal(1));
// INFO: '.line_count' method only possible with unstable-rendered-line-info feature -> API might change: https://github.com/ratatui/ratatui/issues/293#ref-pullrequest-2027056434
let box_height = Paragraph::new(info.clone())
.block(block.clone())
.wrap(Wrap { trim: false })
.line_count(rect.width);
// Make sure to allow scroll only if text is larger than the rendered area and stop scrolling when last line is reached
let scroll_height = {
if app.bibiman.entry_table.entry_info_scroll == 0 {
app.bibiman.entry_table.entry_info_scroll
} else if rect.height > box_height as u16 {
app.bibiman.entry_table.entry_info_scroll = 0;
app.bibiman.entry_table.entry_info_scroll
} else if app.bibiman.entry_table.entry_info_scroll > (box_height as u16 + 2 - rect.height)
{
app.bibiman.entry_table.entry_info_scroll = box_height as u16 + 2 - rect.height;
app.bibiman.entry_table.entry_info_scroll
} else {
app.bibiman.entry_table.entry_info_scroll
}
};
// We can now render the item info
let item_info = Paragraph::new(info)
.block(
block
// Render arrows to show that info box has content outside the block
.title_bottom(
Line::from(
if box_height > rect.height.into()
&& app.bibiman.entry_table.entry_info_scroll
< box_height as u16 + 2 - rect.height
{
" ▼ "
} else {
""
},
)
.alignment(Alignment::Right),
)
.title_top(
Line::from(if scroll_height > 0 { " ▲ " } else { "" })
.alignment(Alignment::Right),
),
)
.wrap(Wrap { trim: false })
.scroll((scroll_height, 0));
frame.render_widget(item_info, rect);
}
pub fn render_taglist(app: &mut App, cfg: &BibiConfig, frame: &mut Frame, rect: Rect) {
let keyword_box_selected_border_style: Style = Style::new().fg(cfg.colors.highlight_text_color);
let keyword_box_selected_title_style: Style = Style::new()
.fg(cfg.colors.keyword_color)
.add_modifier(Modifier::BOLD);
let keyword_box_unselected_border_style: Style = Style::new().fg(cfg.colors.main_text_color);
let keyword_box_unselected_title_style: Style = Style::new()
.fg(cfg.colors.keyword_color)
.add_modifier(Modifier::BOLD);
let keyword_selected_row_style: Style = Style::new()
.fg(cfg.colors.keyword_color)
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::REVERSED);
let block = Block::bordered()
.title(
Line::styled(
" Keywords ",
if let CurrentArea::TagArea = app.bibiman.current_area {
keyword_box_selected_title_style
} else {
keyword_box_unselected_title_style
},
)
.centered(),
)
.border_set(if let CurrentArea::TagArea = app.bibiman.current_area {
symbols::border::THICK
} else {
symbols::border::PLAIN
})
.border_style(if let CurrentArea::TagArea = app.bibiman.current_area {
keyword_box_selected_border_style
} else {
keyword_box_unselected_border_style
});
// Iterate through all elements in the `items` and stylize them.
let items: Vec = app
.bibiman
.tag_list
.tag_list_items
.iter()
.enumerate()
.map(|(_i, keyword)| {
ListItem::from(keyword.to_owned()).style(Style::new().fg(
if app.bibiman.tag_list.tag_list_state.selected().is_some() {
// color_list(
// args,
// i as i32,
// app.bibiman.tag_list.tag_list_state.selected().unwrap() as i32,
// args.colors.highlight_text_color,
// 20,
// )
cfg.colors.highlight_text_color
} else {
cfg.colors.main_text_color
},
)) //.bg(color)
})
.collect();
// Create a List from all list items and highlight the currently selected one
let list = List::new(items)
.block(block)
.highlight_style(keyword_selected_row_style);
// Save list length for calculating scrollbar need
// Add 2 to compmensate lines of the block border
let list_length = list.len() + 2;
frame.render_stateful_widget(list, rect, &mut app.bibiman.tag_list.tag_list_state);
// Scrollbar for keyword list
let scrollbar = Scrollbar::default()
.orientation(ScrollbarOrientation::VerticalRight)
.track_symbol(None)
.begin_symbol(SCROLLBAR_UPPER_CORNER)
.end_symbol(SCROLLBAR_LOWER_CORNER)
.thumb_style(Style::new().fg(Color::DarkGray));
if list_length > rect.height.into() {
if let CurrentArea::TagArea = app.bibiman.current_area {
// render the scrollbar
frame.render_stateful_widget(
scrollbar,
rect,
&mut app.bibiman.tag_list.tag_scroll_state,
);
}
}
}
/// Render the cursor when in InputMode
fn render_cursor(app: &mut App, frame: &mut Frame, area: Rect, x_offset: u16, y_offset: u16) {
let scroll = app.input.visual_scroll(area.width as usize);
if app.input_mode {
let (x, y) = (
area.x + ((app.input.visual_cursor()).max(scroll) - scroll) as u16 + x_offset,
// area.bottom().saturating_sub(1) + y_offset,
area.bottom().saturating_sub(y_offset),
);
frame.render_widget(
Clear,
Rect {
x,
y,
width: 1,
height: 1,
},
);
frame.set_cursor_position(Position::new(x, y));
}
}
/// helper function to create a centered rect using up certain percentage of the available rect `r`
fn popup_area(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let vertical = Layout::vertical([Constraint::Length(percent_y)]).flex(Flex::Center);
let horizontal = Layout::horizontal([Constraint::Length(percent_x)]).flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
area
}