FULL REWORK AND PORT TO TERMION

This patch is simply meant to mark the beginning of the newest phase for the tui build.
I've 100% settled on using termion as the backend and marking what is done so far

Renderer side:
	Termion has a similar issue where moving data happens very quietly so its best
	if the two (renderer and cache) have the their data to use as they please

Cache Side:
	Basically we own the data we're using because we constantly have to mutate data
	ourselves

Config in the middle:
	Mutable but only from the rendering side because the cache is completely transient
	It technically to own its data but it does anyway because the render(backend)
	 likes to consume data like there's no tomorrow
This commit is contained in:
shockrah 2021-03-17 20:39:42 -07:00
parent c3d5c75cc0
commit cb5975f235
15 changed files with 745 additions and 799 deletions

786
tui/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,16 +8,18 @@ edition = "2018"
[dependencies]
clap = "2.33.3"
cursive = { version = "0.15", features=["toml"] }
# UI Backend
tui = "0.14"
termion = "1.5"
unicode-width = "0.1.5"
# Better options support
clap = "2.33.3"
# For json
serde_json = "1.0"
serde = { version = "1.0.114", features = ["derive"] }
reqwest = "0.11.0"
tokio = { version = "1", features = ["full"] }
[patch.crates-io.enumset_derive]
git = "https://github.com/ocboogie/enumset"
branch = "span-fix"
# Networking related dependancies
tokio = { version = "1", features = ["rt", "net", "macros", "rt-multi-thread"] }
reqwest = { version = "0.11.2", features = [ "json", "blocking"] }

32
tui/src/api_types.rs Normal file
View File

@ -0,0 +1,32 @@
use serde::{Serialize, Deserialize};
#[allow(dead_code)]
const VOICE_CHANNEL: i32 = 1;
#[allow(dead_code)]
const TEXT_CHANNEL: i32 = 2;
// Network Types
#[allow(dead_code)]
pub struct Message {
pub id: u64,
pub time: i64,
pub content: String,
pub content_type: String,
pub channel_id: u64,
pub userid: u64,
pub username: String,
}
#[allow(dead_code)]
pub struct Channel {
pub name: String,
pub id: u64,
pub type_: i32,
pub description: Option<String>,
}
#[derive(Deserialize)]
pub struct Jwt {
pub jwt: String
}

View File

@ -1,6 +0,0 @@
// Deals with logging in and requesting new jwt's when required
async fn login() {}
async fn refresh_jwt() {}

58
tui/src/cache.rs Normal file
View File

@ -0,0 +1,58 @@
/**
* welcum to the cache zone
* Notes about data model here
* Basically none of the ever gets written to disk so its mutability is
*
* Memory Model things
* The cache should always own its own data and nothing else
* On calls where the cache system needs to take data it should actually
* make its own copy to not disturb the render side of things as it too requires
* ownership of its own data. For this reason all parts of this are basically
* going to be really selfish about its own data
*
*/
use crate::config::{ ServerMeta, UserConfig };
use crate::api_types::{Channel, Message};
use crate::command::Command;
use std::collections::HashMap;
struct ChannelCache {
meta: Channel,
messages: Vec<Message>
}
struct ServerCache {
meta: ServerMeta,
user: UserConfig,
channels: Vec<ChannelCache>
}
pub struct Cache {
// Hostname -> Cache
servers: HashMap<String, ServerCache>,
active_server: Option<String>
}
impl Default for Cache {
fn default() -> Cache {
Cache {
servers: HashMap::new(),
active_server: None
}
}
}
impl Cache {
pub async fn switch_channel(&mut self, id: u64) -> Command {
todo!()
}
pub async fn switch_server(&mut self, host: &str) -> Command {
todo!()
}
pub async fn send_message(&mut self, id: &str) -> Command {
todo!()
}
}

94
tui/src/command.rs Normal file
View File

@ -0,0 +1,94 @@
use tui::text::{Span, Spans};
use tui::style::{Style, Modifier};
pub enum Command {
Help,
// Picking based on id
Channel(u64),
// Choose server based on hostname
Server(String),
// Send regular message
Message(String),
// Command that failed with some message
Failure(&'static str),
}
impl Command {
// Pulls out channel id from a line
// Examples: /chan 123
// /channel 789
// /channelswag 1
fn parse_chan_id(s: &str) -> Option<u64> {
let parts: Vec<&str> = s.split(" ").collect();
return if parts.len() < 2 {
None
} else {
let id_s = parts.get(1).unwrap();
match id_s.parse::<u64>() {
Ok(id) => Some(id),
_ => None
}
}
}
fn parse_hostname(s: &str) -> Option<String> {
let parts: Vec<&str> = s.split(" ").collect();
return if parts.len() < 2{
None
} else{
let hostname: String = (*parts.get(1).unwrap()).into();
Some(hostname)
}
}
pub fn from(s: String) -> Command {
let s = s.trim();
if s.starts_with("/chan") {
match Command::parse_chan_id(s.as_ref()) {
Some(id) => Command::Channel(id),
None => Command::Failure("no valid id(u64) provided")
}
} else if s.starts_with("/serv") {
match Command::parse_hostname(s.as_ref()) {
Some(hostname) => Command::Server(hostname),
None => Command::Failure("no hostname provided")
}
} else if s.starts_with("/help") {
Command::Help
}
else {
if s.starts_with("/") {
Command::Failure("command not found")
} else {
Command::Message(s.into())
}
}
}
pub fn styled(&self) -> Spans {
use Command::*;
return match self {
Help => Spans::from(vec![
Span::styled("! /help\n", Style::default().add_modifier(Modifier::BOLD)),
Span::styled("! /channel <u64>", Style::default().add_modifier(Modifier::BOLD)),
Span::styled("! /server <u64>", Style::default().add_modifier(Modifier::BOLD)),
]),
Channel(id) => Spans::from(vec![
Span::styled(format!("! /channel "), Style::default().add_modifier(Modifier::BOLD)),
Span::raw(format!("{}", id)),
]),
Server(hostname) => Spans::from(vec![
Span::styled("! /server ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(hostname)
]),
Message(msg) => Spans::from(vec![
Span::styled("(You) ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(msg)
]),
Failure(msg) => Spans::from(vec![
Span::styled("! error ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(*msg)
])
}
}
}

72
tui/src/config.rs Normal file
View File

@ -0,0 +1,72 @@
use serde::{Serialize, Deserialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ServerMeta {
pub protocol: String,
pub hostname: String,
pub port: Option<u16>,
pub description: String,
pub name: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ServerConfig {
pub user: UserConfig,
pub server: ServerMeta
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct UserConfig {
pub id: u64,
pub secret: String,
pub jwt: Option<String>,
pub permissions: u64,
pub joindate: i64,
pub status: i32,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Config {
pub username: Option<String>,
pub servers: Vec<ServerConfig>
}
impl Config {
pub fn update_jwt(&mut self, hostname: String, jwt: String) {
for servermeta in self.servers.iter_mut() {
if servermeta.server.hostname == hostname {
servermeta.user.jwt = Some(jwt);
break
}
}
}
pub fn server_url(&self, hostname: &str) -> Option<String> {
// Finds the base-url for the given hostname, assuming it exists
let mut url: Option<String> = None;
for meta in self.servers.iter() {
if meta.server.hostname == hostname {
url = match meta.server.port {
Some(p) => Some(format!("{}://{}:{}", meta.server.protocol, hostname, p)),
None => Some(format!("{}://{}", meta.server.protocol, hostname))
};
break
}
}
return url;
}
pub fn save(&self, path: String) -> std::io::Result<()>{
use std::io::prelude::Write;
use std::fs::File;
let content = serde_json::to_string_pretty(self)?;
let mut file = File::create(path)?;
file.write(content.as_bytes())?;
Ok(())
}
}

View File

@ -1,27 +0,0 @@
use cursive::Cursive;
use reqwest;
use crate::types::Channel;
pub async fn fetch_channels(domain: &str, port: u16) -> Option<Vec<Channel>>{
let url = format!("http://{}:{}/channels/list", domain, port);
if let Ok(resp) = reqwest::get(&url).await {
let bytes = resp.bytes().await.unwrap();
let res: Result<Vec<Channel>, serde_json::Error> = serde_json::from_slice(&bytes);
return match res {
Ok(res) => Some(res),
_ => None
};
}
return None;
}
pub mod sync {
use cursive::Cursive;
use std::net::Ipv4Addr;
pub fn open_channel(ip: Ipv4Addr, name: &str, s: &mut Cursive) {
}
}

View File

@ -1,99 +1,224 @@
extern crate serde;
extern crate clap;
extern crate cursive;
mod util;
mod command;
mod config;
mod api_types;
mod cache;
extern crate tokio;
extern crate reqwest;
use crate::util::event::{Event, Events};
use crate::command::Command;
use crate::cache::Cache;
use std::{env, fs, error::Error, io};
use clap::{App as Clap, Arg, ArgMatches};
use termion::{event::Key, input::MouseTerminal, raw::IntoRawMode, screen::AlternateScreen};
use tui::{
backend::TermionBackend,
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Span, Spans, Text},
widgets::{Wrap, Block, Borders, Paragraph},
Terminal,
};
use unicode_width::UnicodeWidthStr;
use std::{fs, env};
use std::path::PathBuf;
enum InputMode {
Normal,
Editing,
}
use clap::{Arg, App};
use cursive::Cursive;
use cursive::menu::MenuTree;
use cursive::event::Key;
/// App holds the state of the application
struct App {
/// Current value of the input box
input: String,
/// Current input mode
input_mode: InputMode,
/// History of recorded messages
messages: Vec<Command>,
cache: Cache
}
use serde_json;
impl Default for App {
fn default() -> App {
App {
input: String::new(),
input_mode: InputMode::Normal,
messages: Vec::new(),
cache: Cache::default() // empty cache lad
}
}
}
mod types;
mod http;
#[tokio::main]
async fn main() {
let args = App::new("Freechat TUI")
.version("69.420")
.author("godrah")
.about("oh you know")
fn get_args() -> ArgMatches<'static> {
Clap::new("Freechat TUI")
.version("v69.420")
.author("Author: shockrah")
.about("Description: A terminal based program for interacting with freechat servers")
.arg(Arg::with_name("config")
.short("c")
.long("config")
.value_name("CONFIG")
.help("Specify path of config to use")
.takes_value(true))
.arg(Arg::with_name("theme")
.short("t")
.long("theme")
.value_name("THEME_FILE")
.help("Specify theme file on startup")
.takes_value(true)).get_matches();
let config: types::Config = if args.args.len() == 0 {
.arg(Arg::with_name("no_login")
.short("l")
.long("no-login")
.help("Don't automatically login on startup")).get_matches()
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
// parameter things first
let args = get_args();
let (config_path, mut config): (String, config::Config) = if args.args.len() == 0 {
let home = env::var("HOME").unwrap();
match fs::read_to_string(format!("{}/.config/freechat/config.json", home)) {
Ok(data) => serde_json::from_str(&data).unwrap(),
Err(e) => panic!("Bro: {}", e)
let path = format!("{}/.config/freechat/config.json", home);
match fs::read_to_string(&path) {
Ok(data) => (path, serde_json::from_str(&data).unwrap()),
Err(e) => panic!("Unable to parse config file @{} : {}", path, e)
}
} else{
let path = args.value_of("config").unwrap();
match fs::read_to_string(path) {
Ok(data) => serde_json::from_str(&data).unwrap(),
Err(e) => panic!("Bro: {}", e)
Ok(data) => (path.into(), serde_json::from_str(&data).unwrap()),
Err(e) => panic!("Unable to parse config @ {} : {}", path, e)
}
};
// Terminal initialization
let stdout = io::stdout().into_raw_mode()?;
let stdout = MouseTerminal::from(stdout);
let stdout = AlternateScreen::from(stdout);
let backend = TermionBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// only load a theme if requested
let theme = if let Some(theme) = args.value_of("theme") {
Some(PathBuf::from(theme))
} else {
None
};
// Setup event handlers
let mut events = Events::new();
let mut app = cursive::default();
// Create default app state
let mut app = App::default();
// optionally load optional theme
if let Some(theme) = theme { let _ = app.load_theme_file(theme); }
loop {
// Draw UI
terminal.draw(|f| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints(
[
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(1),
]
.as_ref(),
)
.split(f.size());
app.add_global_callback('q', Cursive::quit);
app.add_global_callback(Key::Esc, |s| s.select_menubar());
app.set_autohide_menu(false); // don't hide the menubar all the time
let (msg, style) = match app.input_mode {
InputMode::Normal => (
vec![
Span::styled("I", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" for insert mode"),
],
Style::default()
),
InputMode::Editing => (
vec![
Span::raw("Keys: "),
Span::styled("Esc", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(" Enter ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled("/help", Style::default().add_modifier(Modifier::BOLD))
],
Style::default(),
),
};
let mut text = Text::from(Spans::from(msg));
text.patch_style(style);
let help_message = Paragraph::new(text);
f.render_widget(help_message, chunks[0]);
let input = Paragraph::new(app.input.as_ref())
.style(match app.input_mode {
InputMode::Normal => Style::default(),
InputMode::Editing => Style::default().fg(Color::Yellow),
})
.block(Block::default().borders(Borders::ALL));
f.render_widget(input, chunks[1]);
let mut lines: Vec<Spans> = Vec::new();
for cmd in app.messages.iter() {
&lines.push(cmd.styled());
}
// menu bar at the top lets us pick between different servers in the config
for server in config.servers.iter() {
let name = match &server.name {
Some(name) => name.to_string(),
//None => String::from(&format!("{}", server.ip))
None => String::from("None")
};
let list = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(Block::default()
.borders(Borders::ALL)
.title("Messages"));
app.menubar().add_subtree(&name, MenuTree::new()); // add server name
// on action:
// open up search able list of channels
// choose from that list of channels which one you want to see
f.render_widget(list, chunks[2]);
// NOTE: not passing the domain as the IP is resolved on server join
if let Some(channels) = http::fetch_channels(&server.ip, server.port).await {
// add a bunch of actionable leafs to our sub tree
for channel in channels {
app.menubar().find_subtree(name.as_ref()).unwrap().add_leaf(channel.name.clone(), move |s| {
let (ip, name) = channel.parts();
http::sync::open_channel(ip, name, s);
});
match app.input_mode {
InputMode::Normal =>
// Hide the cursor. `Frame` does this by default, so we don't need to do anything here
{}
InputMode::Editing => {
// Make the cursor visible and ask tui-rs to put it at the specified coordinates after rendering
f.set_cursor(
// Put cursor past the end of the input text
chunks[1].x + app.input.width() as u16 + 1,
// Move one line down, from the border to the input line
chunks[1].y + 1,
)
}
}
})?;
// Handle input
if let Event::Input(input) = events.next()? {
match app.input_mode {
InputMode::Normal => match input {
Key::Char('i') => {
app.input_mode = InputMode::Editing;
events.disable_exit_key();
}
Key::Char('q') => {
// save the config as is then quit out
match config.save(config_path) {
Ok(_) => println!(":^)"),
Err(e) => eprintln!("Couldn't save config state: {}", e)
};
break;
}
_ => {}
},
InputMode::Editing => match input {
Key::Char('\n') => {
let cmd = Command::from(app.input.drain(..).collect());
app.messages.push(match cmd {
// only for networked commands do we need to touch cache
Command::Channel(id) => app.cache.switch_channel(id).await,
Command::Server(host) => app.cache.switch_server(&host).await,
Command::Message(msg) => app.cache.send_message(&msg).await,
_ => cmd
});
}
Key::Char(c) => {
app.input.push(c);
}
Key::Backspace => {
app.input.pop();
}
Key::Ctrl('c') | Key::Esc => {
app.input_mode = InputMode::Normal;
events.enable_exit_key();
}
_ => {}
},
}
}
}
app.run();
Ok(())
}

View File

@ -1,36 +0,0 @@
use serde::{Serialize,Deserialize};
use std::net::Ipv4Addr;
use std::str::FromStr;
#[derive(Debug, Deserialize, Serialize)]
pub struct Server {
pub name: Option<String>,
pub domain: Option<String>,
pub ip: String,
pub port: u16,
pub description: Option<String>,
pub key: String, // the secret hush hush uwu
pub id: u64,
pub nickname: Option<String>
}
#[derive(Deserialize, Serialize)]
pub struct Config {
pub username: String, // global username that is only overriden in server context's if nickname is used
pub servers: Vec<Server>
}
#[derive(Debug, Deserialize)]
pub struct Channel {
pub ip: String,
pub name: String,
}
impl Channel {
pub fn parts(&self) -> (Ipv4Addr, &str) {
// return the ip/name of the channel
let addr = Ipv4Addr::from_str(&self.ip).unwrap();
(addr, self.name.as_ref())
}
}

95
tui/src/util/event.rs Normal file
View File

@ -0,0 +1,95 @@
use std::io;
use std::sync::mpsc;
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread;
use std::time::Duration;
use termion::event::Key;
use termion::input::TermRead;
pub enum Event<I> {
Input(I),
Tick,
}
/// A small event handler that wrap termion input and tick events. Each event
/// type is handled in its own thread and returned to a common `Receiver`
pub struct Events {
rx: mpsc::Receiver<Event<Key>>,
input_handle: thread::JoinHandle<()>,
ignore_exit_key: Arc<AtomicBool>,
tick_handle: thread::JoinHandle<()>,
}
#[derive(Debug, Clone, Copy)]
pub struct Config {
pub exit_key: Key,
pub tick_rate: Duration,
}
impl Default for Config {
fn default() -> Config {
Config {
exit_key: Key::Char('q'),
tick_rate: Duration::from_millis(250),
}
}
}
impl Events {
pub fn new() -> Events {
Events::with_config(Config::default())
}
pub fn with_config(config: Config) -> Events {
let (tx, rx) = mpsc::channel();
let ignore_exit_key = Arc::new(AtomicBool::new(false));
let input_handle = {
let tx = tx.clone();
let ignore_exit_key = ignore_exit_key.clone();
thread::spawn(move || {
let stdin = io::stdin();
for evt in stdin.keys() {
if let Ok(key) = evt {
if let Err(err) = tx.send(Event::Input(key)) {
eprintln!("{}", err);
return;
}
if !ignore_exit_key.load(Ordering::Relaxed) && key == config.exit_key {
return;
}
}
}
})
};
let tick_handle = {
thread::spawn(move || loop {
if tx.send(Event::Tick).is_err() {
break;
}
thread::sleep(config.tick_rate);
})
};
Events {
rx,
ignore_exit_key,
input_handle,
tick_handle,
}
}
pub fn next(&self) -> Result<Event<Key>, mpsc::RecvError> {
self.rx.recv()
}
pub fn disable_exit_key(&mut self) {
self.ignore_exit_key.store(true, Ordering::Relaxed);
}
pub fn enable_exit_key(&mut self) {
self.ignore_exit_key.store(false, Ordering::Relaxed);
}
}

21
tui/src/util/mod.rs Normal file
View File

@ -0,0 +1,21 @@
//#[cfg(feature = "termion")]
pub mod event;
#[derive(Clone)]
pub struct SinSignal {
x: f64,
interval: f64,
period: f64,
scale: f64,
}
impl Iterator for SinSignal {
type Item = (f64, f64);
fn next(&mut self) -> Option<Self::Item> {
let point = (self.x, (self.x * 1.0 / self.period).sin() * self.scale);
self.x += self.interval;
Some(point)
}
}

View File

@ -1,14 +0,0 @@
shadow = true
borders = "outset"
[colors]
shadow = "#1a1a1a"
background = "black"
primary = ["black"]
secondary = "black"
tertiary = "black"
title_primary = "#3560e0"
title_secondary = "#ffff55"
highlight = "#3560e0"
highlight_inactive = "#7a93c9"

View File

@ -1,13 +0,0 @@
shadow = false
borders = "outset"
[colors]
background = "black"
primary = ["#000000"]
secondary = "black"
tertiary = "#000000"
title_primary = "black"
title_secondary = "#000000"
highlight = "#000000"
highlight_inactive = "#7a7a7a"

View File

@ -1,11 +0,0 @@
[colors]
background = "white"
primary = "white"
secondary = "white"
tertiary = "white"
title_primary = "white"
title_secondary = "white"
highlight = "white"
highlight_inactive = "#e0e0e0"
view = "black"