use anyhow::{Context, Result};
use flexi_logger::trc::FormatConfig;
use flexi_logger::writers::FileLogWriter;
use flexi_logger::{FileSpec, LogSpecification};
use ratatui::crossterm::event::KeyEvent;
use ratatui::layout::Constraint::Percentage;
use ratatui::layout::{Flex, Layout};
use ratatui::prelude::Constraint::{Length, Min};
use ratatui::prelude::{Constraint, Line, Rect, Style, Stylize};
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Clear, Row, Table, TableState, Wrap};
use ratatui::{
crossterm::event::{self, Event, KeyCode},
widgets::Paragraph,
DefaultTerminal, Frame,
};
use savefile_derive::Savefile;
use std::pin::Pin;
use std::time::Duration;
use tracing::trace;
use noatun::communication::{DatabaseCommunication, DatabaseCommunicationConfig};
use noatun::data_types::{NoatunHashMap, NoatunHashMapEntry, NoatunString, NoatunVec};
use noatun::ratatui_inspector::RatatuiInspector;
use noatun::simple_metrics::SimpleMetricsRecorder;
use noatun::{
noatun_object, CutOffDuration, Database, DatabaseSettings, Message, MessageId, NoatunTime,
Object, OpenMode, SavefileMessageSerializer,
};
use tui_textarea::TextArea;
noatun_object!(
struct DescriptionText {
pod time: NoatunTime,
object text: NoatunString,
object added_by: NoatunString,
}
);
noatun_object!(
struct Issue {
pod created: NoatunTime,
object reporter: NoatunString,
object description: NoatunVec<DescriptionText>,
}
);
noatun_object!(
struct IssueDb {
object issues: NoatunHashMap<NoatunString, Issue>,
object aliases: NoatunHashMap<NoatunString, NoatunString>,
}
);
#[derive(Savefile, Debug)]
enum IssueMessage {
AddIssue {
reporter: String,
heading: String,
},
AppendText {
id: String,
reporter: String,
text: String,
},
RemoveIssue {
id: String,
},
RenameIssue {
old_id: String,
new_id: String,
},
ReassignIssue {
id: String,
new_reporter: String,
},
}
fn remap(root: &IssueDbPinProject, id: &str) -> String {
let mut ret = id.to_string();
loop {
if let Some(new) = root.aliases.get(&ret) {
ret = new.to_string();
} else {
return ret;
}
}
}
impl Message for IssueMessage {
type Root = IssueDb;
type Serializer = SavefileMessageSerializer<IssueMessage>;
fn apply(&self, message_id: MessageId, root: Pin<&mut Self::Root>) {
let mut root = root.pin_project();
match self {
IssueMessage::AddIssue { reporter, heading } => {
let heading = remap(&root, heading);
let issue = root.issues.get_insert(heading.as_str());
let issue = issue.pin_project();
trace!("assigning created");
issue.created.set(message_id.timestamp());
trace!("assigning reporter");
issue.reporter.assign(reporter);
}
IssueMessage::RemoveIssue { id } => {
let id = remap(&root, id).to_owned();
root.issues.as_mut().remove(id.as_str());
root.aliases.as_mut().retain(|_k, v| **v != id);
}
IssueMessage::AppendText { id, reporter, text } => {
let id = remap(&root, id);
if let Some(issue) = root.issues.get_mut_val(id.as_str()) {
let issue = issue.pin_project();
issue.description.push(DescriptionTextNative {
time: message_id.timestamp(),
text: text.to_string(),
added_by: reporter.to_string(),
});
}
}
IssueMessage::RenameIssue {
old_id: id,
new_id: id_new,
} => {
if !root.issues.as_mut().untracked_contains_key(id_new)
&& !root.aliases.as_mut().untracked_contains_key(id)
{
match root.issues.as_mut().untracked_entry(id.to_string()) {
NoatunHashMapEntry::Occupied(o) => {
let prev = o.remove();
root.issues.as_mut().insert(id_new.as_str(), &prev);
}
NoatunHashMapEntry::Vacant(_) => {}
}
}
}
IssueMessage::ReassignIssue { id, new_reporter } => {
let id = remap(&root, id);
if let Some(issue) = root.issues.get_mut_val(id.as_str()) {
let issue = issue.pin_project();
issue.reporter.init_from(new_reporter);
}
}
}
}
}
fn main() -> Result<()> {
let _keep_alive_handles = flexi_logger::trc::setup_tracing(
LogSpecification::env().unwrap(),
None,
FileLogWriter::builder(FileSpec::default().suppress_timestamp()),
&FormatConfig::default().with_file(true),
)?;
let terminal = ratatui::init();
let app_result = run(terminal).context("app loop failed");
ratatui::restore();
app_result
}
enum Popup {
None,
AddHeading(TextArea<'static>),
AddText(TextArea<'static>),
Reassign(TextArea<'static>),
Rename(TextArea<'static>),
}
impl Popup {
fn is_some(&self) -> bool {
!matches!(self, Popup::None)
}
}
struct AppState {
text_table: Table<'static>,
text_table_state: TableState,
table: Table<'static>,
table_state: TableState,
selected_heading: Option<String>,
row_count: usize,
user: String,
comms: DatabaseCommunication<IssueMessage>,
popup: Popup,
diagnostics: bool,
recorder: SimpleMetricsRecorder,
inspector: RatatuiInspector,
}
impl AppState {
pub fn create_event(&mut self, heading: &str) -> Result<()> {
self.comms.blocking_add_message(IssueMessage::AddIssue {
reporter: self.user.clone(),
heading: heading.to_string(),
})
}
pub fn reassign(&mut self, new_reporter: String) -> Result<()> {
if let Some(selected_heading) = &self.selected_heading {
self.comms
.blocking_add_message(IssueMessage::ReassignIssue {
id: selected_heading.to_string(),
new_reporter,
})?;
}
Ok(())
}
pub fn rename(&mut self, new: String) -> Result<()> {
if let Some(selected_heading) = &self.selected_heading {
self.comms.blocking_add_message(IssueMessage::RenameIssue {
old_id: selected_heading.to_string(),
new_id: new,
})?;
}
Ok(())
}
pub fn add_text(&mut self, text: &str) -> Result<()> {
if let Some(selected_heading) = &self.selected_heading {
self.comms.blocking_add_message(IssueMessage::AppendText {
reporter: self.user.clone(),
id: selected_heading.to_string(),
text: text.to_string(),
})?;
}
Ok(())
}
pub fn delete_event(&mut self, heading: &str) -> Result<()> {
self.comms.blocking_add_message(IssueMessage::RemoveIssue {
id: heading.to_string(),
})
}
}
fn start_communication(bind: Option<String>) -> Result<DatabaseCommunication<IssueMessage>> {
let db: Database<IssueMessage> = Database::create_new(
"issue_db",
OpenMode::OpenCreate,
DatabaseSettings {
cutoff_interval: CutOffDuration::from_minutes(2),
..DatabaseSettings::default()
},
)?;
let distributed_db = DatabaseCommunication::new(
db,
DatabaseCommunicationConfig {
listen_address: bind.unwrap_or_else(|| "127.0.0.1".to_string()),
enable_diagnostics: true,
..Default::default()
},
)?;
Ok(distributed_db)
}
fn run(mut terminal: DefaultTerminal) -> Result<()> {
let recorder = SimpleMetricsRecorder::default();
recorder.clone().register_global();
let comms = start_communication(std::env::args().nth(1))?;
let user = std::env::var("USER").unwrap_or("default-user".to_string());
let table_state = TableState::default();
let text_table_state = TableState::default();
let widths = [Length(25), Min(10), Length(15)];
let rows: [Row; 0] = [];
let table = Table::new(rows.clone(), widths)
.block(Block::new().title("Table"))
.header(Row::new(vec!["Time", "Heading", "Reporter"]))
.row_highlight_style(Style::new().reversed())
.highlight_symbol(">>");
let text_widths = [
Length(25), Length(25), Min(10), ];
let text_table = Table::new(rows, text_widths)
.block(Block::new().title("Table"))
.header(Row::new(vec!["Time", "Reporter", "Text"]))
.row_highlight_style(Style::new().reversed())
.highlight_symbol(">>");
let mut app = AppState {
text_table,
text_table_state,
table,
selected_heading: None,
table_state,
row_count: 0,
user,
comms,
popup: Popup::None,
diagnostics: false,
recorder,
inspector: RatatuiInspector::new(),
};
loop {
terminal.draw(|frame| {
if app.diagnostics {
app.inspector.draw(frame, Some(&app.recorder), &app.comms);
} else {
draw(frame, &mut app).expect("rendering should not fail");
}
match &mut app.popup {
Popup::None => {}
Popup::AddHeading(text)
| Popup::AddText(text)
| Popup::Reassign(text)
| Popup::Rename(text) => {
let area = popup_area(frame.area(), 75);
frame.render_widget(Clear, area); frame.render_widget(&*text, area);
}
}
})?;
if poll_input(&mut app)? {
break;
}
}
Ok(())
}
fn draw(frame: &mut Frame, app: &mut AppState) -> Result<()> {
app.selected_heading = None;
let rows: Vec<Row> = app.comms.with_root(|root| {
let mut rows = Vec::new();
for (k, v) in root.issues.iter() {
rows.push((v.created.get(), k.export(), v.reporter.export()));
}
rows.sort();
for (idx, item) in rows.iter().enumerate() {
if Some(idx) == app.table_state.selected() {
app.selected_heading = Some(item.1.to_string());
}
}
app.row_count = rows.len();
rows.into_iter()
.map(|(time, heading, count)| Row::new([time.to_string(), heading, count.to_string()]))
.collect()
});
let message_count = app.comms.count_messages();
let sync_status = app
.comms
.get_status_blocking()
.map(|x| x.to_string())
.unwrap_or_else(|_| "Failed".to_string());
let debug_spans = vec![
ratatui::prelude::Span::styled("User: ", Style::default()),
ratatui::prelude::Span::styled(app.user.to_string(), Style::default().bold()),
ratatui::prelude::Span::styled(", Message count: ", Style::default()),
ratatui::prelude::Span::styled(message_count.to_string(), Style::default().bold()),
ratatui::prelude::Span::styled(", Sync status: ", Style::default()),
ratatui::prelude::Span::styled(sync_status.to_string(), Style::default().bold()),
];
let metrics_paragraph = Paragraph::new(Line::from(debug_spans)).wrap(Wrap { trim: true });
let text_rows: Vec<Row> = app.comms.with_root(|root| {
let mut rows = Vec::new();
if let Some(selected_heading) = &app.selected_heading {
if let Some(item) = root.issues.get(selected_heading) {
for text in item.description.iter() {
rows.push((
text.time.to_string(),
text.added_by.to_string(),
text.text.export(),
));
}
}
}
rows.sort();
rows.into_iter()
.map(|(time, heading, count)| Row::new([time.to_string(), heading, count.to_string()]))
.collect()
});
let main_status_layout = Layout::vertical([
Length(3), Min(0), Length(
(2 + metrics_paragraph.line_count(frame.area().width.saturating_sub(2)) as u16).min(20),
), ]);
let [keybinds_area, list_area, status_area] = main_status_layout.areas(frame.area());
let list_details_layout = Layout::horizontal([Percentage(50), Percentage(50)]);
let [list_area, details_area] = list_details_layout.areas(list_area);
let details_layout = Layout::vertical([
Length(4), Min(4), ]);
let [time_heading_area, descriptions_label_area] = details_layout.areas(details_area);
if let Some(selected_row) = &app.selected_heading {
let issue_block = Block::new().borders(Borders::ALL).title("Issue");
if let Some(issue) = app
.comms
.with_root(|root| root.issues.get(selected_row).map(|x| x.export()))
{
frame.render_widget(
Paragraph::new(vec![
ratatui::prelude::Line::from(vec![
Span::styled("Timestamp: ", Style::default().bold()),
Span::styled(issue.created.to_string(), Style::default()),
]),
ratatui::prelude::Line::from(vec![
Span::styled("Heading: ", Style::default().bold()),
Span::styled(selected_row, Style::default()),
]),
])
.block(issue_block),
time_heading_area,
);
}
}
let issue_block = Block::new().borders(Borders::ALL).title("Issues");
let text_block = Block::new().borders(Borders::ALL).title("Texts");
let status_block = Block::new().borders(Borders::ALL).title("Status");
let keybinds_block = Block::new().borders(Borders::ALL).title("Keys");
frame.render_widget(
Paragraph::new(Line::from(
ratatui::prelude::Span::styled("A - Add entry, DEL - Delete entry, F2 - Rename entry, R - Reassign, T - Add text, D - Diagnostics", Style::default()),
)).block(keybinds_block),
keybinds_area);
frame.render_widget(metrics_paragraph.block(status_block), status_area);
let table = app.table.clone().rows(rows);
frame.render_stateful_widget(table.block(issue_block), list_area, &mut app.table_state);
if app.selected_heading.is_some() {
let text_table = app.text_table.clone().rows(text_rows);
frame.render_stateful_widget(
text_table.block(text_block),
descriptions_label_area,
&mut app.text_table_state,
);
}
Ok(())
}
fn popup_area(area: Rect, percent_x: u16) -> Rect {
let vertical = Layout::vertical([Constraint::Length(3)]).flex(Flex::Center);
let horizontal = Layout::horizontal([Constraint::Percentage(percent_x)]).flex(Flex::Center);
let [area] = vertical.areas(area);
let [area] = horizontal.areas(area);
area
}
fn poll_input(app: &mut AppState) -> Result<bool> {
if event::poll(Duration::from_millis(250)).context("event poll failed")? {
let event = event::read().context("event read failed")?;
if app.popup.is_some() {
match &mut app.popup {
Popup::None => {}
Popup::AddHeading(w)
| Popup::Reassign(w)
| Popup::Rename(w)
| Popup::AddText(w) => {
match &event {
Event::Key(KeyEvent {
code: KeyCode::Esc, ..
}) => {
app.popup = Popup::None;
return Ok(false);
}
Event::Key(KeyEvent {
code: KeyCode::Enter,
..
}) => {
match &mut app.popup {
Popup::AddHeading(w) => {
let heading: String = w.lines()[0].to_string();
app.create_event(&heading)?;
}
Popup::AddText(w) => {
let text: String = w.lines()[0].to_string();
app.add_text(&text)?;
}
Popup::Rename(new) => {
let text: String = new.lines()[0].to_string();
app.rename(text)?;
}
Popup::Reassign(new) => {
let text: String = new.lines()[0].to_string();
app.reassign(text)?;
}
Popup::None => {}
}
app.popup = Popup::None;
return Ok(false);
}
_ => {}
}
w.input(event);
return Ok(false);
}
}
}
#[allow(clippy::single_match)]
match &event {
Event::Key(key) => match &key.code {
KeyCode::Esc if app.diagnostics => {
app.diagnostics = false;
return Ok(false);
}
KeyCode::Char('q') | KeyCode::Char('Q') => {
return Ok(true);
}
KeyCode::Char('d') | KeyCode::Char('D') => {
app.diagnostics = !app.diagnostics;
app.popup = Popup::None;
return Ok(false);
}
KeyCode::Char('v') | KeyCode::Char('V') => {
app.comms
.inner_database()
.begin_session_mut()?
.maybe_advance_cutoff()?;
return Ok(false);
}
KeyCode::Char('p') | KeyCode::Char('P') => {
app.comms
.inner_database()
.begin_session_mut()?
.compact_index()?;
app.comms
.inner_database()
.begin_session_mut()?
.reproject()?;
return Ok(false);
}
_ => {}
},
_ => {}
}
#[allow(clippy::collapsible_if)]
if app.diagnostics {
_ = app.inspector.input(&event);
return Ok(false);
}
#[allow(clippy::single_match)]
match event {
Event::Key(key) => {
match key.code {
KeyCode::Esc => {
if app.diagnostics {
app.diagnostics = false;
} else {
return Ok(true);
}
}
KeyCode::Delete => {
if let Some(heading) = &app.selected_heading {
let heading = heading.to_string();
app.delete_event(&heading)?;
return Ok(false);
}
}
KeyCode::F(2) => {
if app.selected_heading.is_some() {
let mut text = TextArea::default();
text.set_block(Block::new().borders(Borders::ALL).title("New heading"));
app.popup = Popup::Rename(text);
}
}
KeyCode::Char('a') | KeyCode::Char('A') => {
let mut text = TextArea::default();
text.set_block(Block::new().borders(Borders::ALL).title("Enter heading"));
app.popup = Popup::AddHeading(text);
}
KeyCode::Char('t') | KeyCode::Char('T') => {
if app.selected_heading.is_some() {
let mut text = TextArea::default();
text.set_block(Block::new().borders(Borders::ALL).title("Enter text"));
app.popup = Popup::AddText(text);
}
}
KeyCode::Char('r') | KeyCode::Char('R') => {
if app.selected_heading.is_some() {
let mut text = TextArea::default();
text.set_block(Block::new().borders(Borders::ALL).title("Enter text"));
app.popup = Popup::Reassign(text);
}
}
KeyCode::Up => {
let next = app.table_state.selected().unwrap_or(0).saturating_sub(1);
let next = next.clamp(0, app.row_count.saturating_sub(1));
app.table_state.select(Some(next));
}
KeyCode::Down => {
let next = app.table_state.selected().unwrap_or(0) + 1;
let next = next.clamp(0, app.row_count.saturating_sub(1));
app.table_state.select(Some(next));
}
_ => {}
}
return Ok(KeyCode::Char('q') == key.code);
}
_ => {}
}
}
Ok(false)
}