Skip to content

Commit

Permalink
Refactored selected lines handling
Browse files Browse the repository at this point in the history
  • Loading branch information
fritzrehde committed Nov 7, 2023
1 parent a8e085d commit 62539cd
Show file tree
Hide file tree
Showing 6 changed files with 263 additions and 90 deletions.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ crossterm = { version = "0.27", features = ["events", "event-stream"] }
itertools = "0.11.0"
anyhow = "1.0.75"
indoc = "2.0.4"
derive_more = { version = "0.99.17", default-features = false, features = ["from", "into_iterator", "as_ref"] }
derive_more = { version = "0.99.17", default-features = false, features = ["from", "into", "into_iterator", "as_ref"] }
tabwriter = "1.3.0"
parse-display = "0.8.2"
derive-new = "0.5.9"
Expand Down
35 changes: 16 additions & 19 deletions src/ui/state/lines/line.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ pub struct Line {
/// This string will be made available to the user's command's subshell
/// through an environment variable.
unformatted: String,
/// Cell containing the text string that will be displayed in the TUI.
/// Widget containing the text string that will be displayed in the TUI.
/// The text string is formatted according to the user's field separator.
/// The text string has ANSI color codes converted to ratatui colors.
displayed: Cell<'static>,
Expand All @@ -25,24 +25,13 @@ impl Line {
formatted_ansi: Option<String>,
style: Style,
) -> Result<Self> {
let (unformatted_ansi_stripped, displayed_colored_text) = match formatted_ansi {
Some(formatted_ansi) => {
// We have to convert ANSI to ratatui Style in both formatted and unformatted.
let displayed = Self::format_line_content(&formatted_ansi).into_text()?;
let unformatted = unformatted_ansi.into_text()?.to_unformatted_string();
(unformatted, displayed)
}
None => {
// We only have to convert ANSI to ratatui Style in unformatted.
let displayed = Self::format_line_content(&unformatted_ansi).into_text()?;
let unformatted = displayed.to_unformatted_string();
(unformatted, displayed)
}
};
let formatted_or_unformatted = formatted_ansi.as_ref().unwrap_or(&unformatted_ansi);
let displayed = Self::format_line_content(formatted_or_unformatted).into_text()?;
let unformatted = unformatted_ansi.into_text()?.to_unformatted_string();

Ok(Self {
unformatted: unformatted_ansi_stripped,
displayed: Cell::from(displayed_colored_text).style(style),
unformatted,
displayed: Cell::from(displayed).style(style),
})
}

Expand All @@ -53,19 +42,27 @@ impl Line {
format!(" {}", line_content)
}

/// Draw the line.
pub fn draw(&self) -> Cell {
self.displayed.clone()
}

/// Update the style of the line.
pub fn update_style(&mut self, new_style: Style) {
// TODO: ask ratatui to add a method to directly replace the style without having to clone
let displayed = self.displayed.clone();
self.displayed = displayed.style(new_style);
}

pub fn unformatted(&self) -> &String {
/// Get the line as a &str.
pub fn unformatted_str(&self) -> &str {
&self.unformatted
}

/// Get the line as an owned String.
pub fn unformatted_string(&self) -> String {
self.unformatted.to_owned()
}
}

trait ToUnformattedString {
Expand All @@ -80,7 +77,7 @@ impl<'a> ToUnformattedString for Text<'a> {
.map(|line| {
line.spans
.iter()
.map(|span| span.content.clone())
.map(|span| span.content.as_ref())
.collect::<String>()
})
.intersperse("\n".to_owned())
Expand Down
162 changes: 100 additions & 62 deletions src/ui/state/lines/mod.rs
Original file line number Diff line number Diff line change
@@ -1,22 +1,28 @@
mod line;
mod selected_lines;

pub use line::Line;

use self::selected_lines::LineSelections;
use crate::config::Styles;
use crate::config::{Fields, TableFormatter};
use anyhow::Result;
use derive_more::{From, Into};
use itertools::izip;
use itertools::Itertools;
use ratatui::{
prelude::{Backend, Constraint},
style::Style,
widgets::{Cell, Row, Table, TableState},
widgets::{Row, Table, TableState},
Frame,
};
use std::cmp::max;

// TODO: explain why Line and LineSelection have to be separate

pub struct Lines {
lines: Vec<Line>,
selected: Vec<bool>,
line_selections: LineSelections,
styles: Styles,
fields: Fields,
index_after_header_lines: usize,
Expand All @@ -29,7 +35,7 @@ impl Lines {
pub fn new(fields: Fields, styles: Styles, header_lines: usize) -> Self {
Self {
lines: vec![],
selected: vec![],
line_selections: LineSelections::new(styles.selected, styles.line),
fields,
cursor_index: None,
styles,
Expand All @@ -38,19 +44,11 @@ impl Lines {
}
}

/// Render to frame.
pub fn render<B: Backend>(&mut self, frame: &mut Frame<B>) {
// TODO: do as much as possible in update_lines to improve performance
let rows: Vec<Row> = izip!(&self.lines, &self.selected)
.map(|(line, &selected)| {
// TODO: consider replacing Vec<bool> with Vec<Style> directly, maybe even Vec<Cell<'static>> if possible
let selected_style = if selected {
self.styles.selected
} else {
self.styles.line
};

Row::new(vec![Cell::from(" ").style(selected_style), line.draw()])
})
let rows: Vec<Row> = izip!(self.lines.iter(), self.line_selections.iter())
.map(|(line, selected)| Row::new(vec![selected.draw(), line.draw()]))
.collect();

let table = Table::new(rows)
Expand Down Expand Up @@ -81,146 +79,186 @@ impl Lines {
})
.collect::<Result<_>>()?;

self.selected.resize(self.lines.len(), false);
// Resize the line selections to the same size as the lines.
self.line_selections.resize(self.lines.len());

self.calibrate_cursor();

Ok(())
}
}

// Moving cursor

// Moving cursor
impl Lines {
// TODO: don't use isize, instead use an enum Up|Down and saturating_{add,sub}

/// Move the cursor to `index`.
fn move_cursor(&mut self, index: isize) {
let old = self.get_cursor_position();
let new = if self.lines.is_empty() {
let old_cursor_index = self.get_cursor_position();
let new_cursor_index = if self.lines.is_empty() {
None
} else {
let first = self.index_after_header_lines as isize;
let last = self.last_index() as isize;
Some(index.clamp(first, last) as usize)
};

self.cursor_index = new;
self.cursor_index = new_cursor_index;
self.table_state.select(self.cursor_index);
self.adjust_cursor_style(old, new);
self.adjust_cursor_style(old_cursor_index, new_cursor_index);
}

/// Get the current cursor index, or `None` if there is currently no cursor.
fn get_cursor_position(&self) -> Option<usize> {
self.cursor_index
}

/// Calibrate the cursor. Calibration may be necessary if the cursor is
/// still on a line that no longer exists.
fn calibrate_cursor(&mut self) {
match self.get_cursor_position() {
None => self.move_cursor_to_first_line(),
Some(i) => self.move_cursor(i as isize),
};
}

/// Move the cursor down by `steps`.
pub fn move_cursor_down(&mut self, steps: usize) {
if let Some(i) = self.get_cursor_position() {
self.move_cursor(i as isize + steps as isize);
}
}

/// Move the cursor up by `steps`.
pub fn move_cursor_up(&mut self, steps: usize) {
if let Some(i) = self.get_cursor_position() {
self.move_cursor(i as isize - steps as isize);
}
}

/// Move the cursor to the first line.
pub fn move_cursor_to_first_line(&mut self) {
self.move_cursor(self.index_after_header_lines as isize);
}

/// Move the cursor to the last line.
pub fn move_cursor_to_last_line(&mut self) {
self.move_cursor(self.last_index() as isize);
}
}

// Styling cursor

fn adjust_cursor_style(&mut self, old: Option<usize>, new: Option<usize>) {
if let Some(old_index) = old {
// Styling cursor
impl Lines {
/// After changing cursor positions, the styles of the lines must be
/// updated. Revert the style of the line the cursor was on previously
/// (`old_cursor_index`), and update the line the cursor is on now
/// (`new_cursor_index`). It is possible that there is no old or new
/// cursor index.
fn adjust_cursor_style(
&mut self,
old_cursor_index: Option<usize>,
new_cursor_index: Option<usize>,
) {
if let Some(old_index) = old_cursor_index {
self.update_line_style(old_index, self.styles.line);
}
if let Some(new_index) = new {
if let Some(new_index) = new_cursor_index {
self.update_line_style(new_index, self.styles.cursor);
}
}

/// Update the style of the line at `index`.
pub fn update_line_style(&mut self, index: usize, new_style: Style) {
if let Some(line) = self.lines.get_mut(index) {
line.update_style(new_style);
}
}
}

// Selecting lines

// Selecting lines
impl Lines {
/// Select the line that the cursor is currently on.
pub fn select_current(&mut self) {
if let Some(i) = self.get_cursor_position() {
if let Some(selected) = self.selected.get_mut(i) {
*selected = true;
}
self.line_selections.select_at_index(i);
}
}

/// Unselect the line that the cursor is currently on.
pub fn unselect_current(&mut self) {
if let Some(i) = self.get_cursor_position() {
if let Some(selected) = self.selected.get_mut(i) {
*selected = false;
}
self.line_selections.unselect_at_index(i);
}
}

/// Toggle the selection of the line that the cursor is currently on.
pub fn toggle_selection_current(&mut self) {
if let Some(i) = self.get_cursor_position() {
if let Some(selected) = self.selected.get_mut(i) {
*selected = !(*selected);
}
self.line_selections.toggle_selection_at_index(i);
}
}

/// Select all lines.
pub fn select_all(&mut self) {
self.selected.fill(true);
self.line_selections.select_all();
}

/// Unselect all lines.
pub fn unselect_all(&mut self) {
self.selected.fill(false);
self.line_selections.unselect_all();
}
}

// Getting selected lines
/// String content of the line on which the cursor is currently on.
#[derive(From, Into, Clone)]
pub struct CursorLine(String);

fn get_line_under_cursor(&self) -> Option<String> {
self.get_cursor_position()
.and_then(|i| self.get_unformatted(i))
}
/// Concatenation of all contents of selected lines into string.
#[derive(From, Into)]
pub struct SelectedLines(String);

// TODO: not pretty API, maybe make cursor_line and selected_lines distinct types
pub fn get_selected_lines(&self) -> Option<(String, String)> {
// Getting selected lines
impl Lines {
/// Return the string content of the cursor line and the selected lines.
/// If there are no selected lines, the cursor line is returned for both
/// the cursor line and the selected lines.
pub fn get_cursor_line_and_selected_lines(&self) -> Option<(CursorLine, SelectedLines)> {
self.get_line_under_cursor().map(|cursor_line| {
let selected_lines = if self.selected.contains(&true) {
izip!(self.unformatted(), self.selected.iter())
.filter_map(|(line, &selected)| selected.then(|| line.to_owned()))
.collect::<Vec<String>>()
.join("\n")
} else {
cursor_line.clone()
let mut selected_lines_iter = izip!(self.lines.iter(), self.line_selections.iter())
.filter_map(|(line, selection)| {
selection.is_selected().then(|| line.unformatted_str())
})
.peekable();

let selected_lines = match selected_lines_iter.peek() {
// There are some selected lines.
Some(_) => selected_lines_iter.intersperse("\n").collect(),
// There are no selected lines.
None => cursor_line.clone(),
};
(cursor_line, selected_lines)

(cursor_line.into(), selected_lines.into())
})
}

// Formatting

pub fn unformatted(&self) -> Vec<&String> {
self.lines.iter().map(Line::unformatted).collect()
/// Get the string content of the line that the cursor is currently on,
/// or `None` if there is currently no cursor.
fn get_line_under_cursor(&self) -> Option<String> {
self.get_cursor_position()
.and_then(|i| self.get_unformatted_line(i))
}
}

pub fn get_unformatted(&self, index: usize) -> Option<String> {
self.lines.get(index).map(|line| line.unformatted().clone())
// Miscellaneous
impl Lines {
/// Get an owned, unformatted version of the line at `index`, or `None`
/// if it doesn't exist.
pub fn get_unformatted_line(&self, index: usize) -> Option<String> {
self.lines.get(index).map(Line::unformatted_string)
}

// Miscellaneous

/// Get the index of the last line. The returned index will never be within
/// the header lines.
fn last_index(&self) -> usize {
if self.lines.is_empty() {
self.index_after_header_lines
Expand Down
Loading

0 comments on commit 62539cd

Please sign in to comment.