|
|
@@ -0,0 +1,351 @@
|
|
|
+// TODO: Key/auth system
|
|
|
+// TODO: Args or config for username, language, server, and refresh rate.
|
|
|
+
|
|
|
+mod actions;
|
|
|
+mod message;
|
|
|
+mod ui;
|
|
|
+
|
|
|
+use cursive::views::{
|
|
|
+ Button, Dialog, DummyView, EditView, LinearLayout, NamedView, Panel, ScrollView, SelectView,
|
|
|
+ TextArea, TextView,
|
|
|
+};
|
|
|
+use cursive::{event, Cursive};
|
|
|
+use cursive::{traits::*, CursiveExt as _};
|
|
|
+use message::Message;
|
|
|
+use std::collections::HashMap;
|
|
|
+use std::thread;
|
|
|
+
|
|
|
+use ui::{
|
|
|
+ DIALOGUE_MIN_SIZE, INPUT_BUTTON_ID, INPUT_CLEAR_BUTTON_ID, INPUT_FIELD_ID, INPUT_PANEL_ID,
|
|
|
+ LANGUAGE_BUTTON_ID, MESSAGE_PANEL_ID, REFRESH_BUTTON_ID, USERNAME_BUTTON_ID, USERNAME_FIELD_ID,
|
|
|
+ USERS_PANEL_ID, USER_PANEL_SIZE,
|
|
|
+};
|
|
|
+
|
|
|
+const MAX_MESSAGE_LENGTH: usize = 512;
|
|
|
+const MAX_USERNAME_LENGTH: usize = 16;
|
|
|
+const DEFAULT_USERNAME_PREFIX: &str = "Myst";
|
|
|
+
|
|
|
+const REMOTE_REFRESH_RATE: u64 = 10;
|
|
|
+
|
|
|
+#[derive(Clone)]
|
|
|
+pub struct Appdata {
|
|
|
+ pub messages: HashMap<String, Message>,
|
|
|
+ pub username: String,
|
|
|
+ pub language: ui::Language,
|
|
|
+ pub quick_close_window_count: usize,
|
|
|
+ pub api_endpoint: String,
|
|
|
+ pub blocked_phrases: Vec<String>,
|
|
|
+ pub blocked_phrases_censor_char: char,
|
|
|
+}
|
|
|
+
|
|
|
+impl Default for Appdata {
|
|
|
+ fn default() -> Self {
|
|
|
+ Self::new()
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+impl Appdata {
|
|
|
+ pub fn new() -> Self {
|
|
|
+ Self {
|
|
|
+ messages: HashMap::new(),
|
|
|
+ username: format!(
|
|
|
+ "{}#{}",
|
|
|
+ DEFAULT_USERNAME_PREFIX,
|
|
|
+ utils::rng::random_numeric_string(4)
|
|
|
+ ),
|
|
|
+ language: ui::Language::English,
|
|
|
+ quick_close_window_count: 0,
|
|
|
+ api_endpoint: "http://127.0.0.1:8080".to_string(),
|
|
|
+ blocked_phrases: Vec::new(),
|
|
|
+ blocked_phrases_censor_char: '*',
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+pub fn get_appdata(siv: &mut Cursive) -> Appdata {
|
|
|
+ siv.with_user_data(|appdata: &mut Appdata| appdata.clone())
|
|
|
+ .expect("Failed to retrieve appdata.")
|
|
|
+}
|
|
|
+
|
|
|
+fn main() {
|
|
|
+ utils::rng::shuffle_rng();
|
|
|
+
|
|
|
+ let mut siv = Cursive::default();
|
|
|
+ siv.set_user_data(Appdata::new());
|
|
|
+
|
|
|
+ // Global hotkeys
|
|
|
+ siv.add_global_callback(event::Key::Backspace, |siv| {
|
|
|
+ let _ = siv.focus_name(INPUT_FIELD_ID);
|
|
|
+ });
|
|
|
+
|
|
|
+ // Background thread
|
|
|
+ {
|
|
|
+ let cb_sink = siv.cb_sink().clone();
|
|
|
+ let mut timer = 0;
|
|
|
+ thread::spawn(move || loop {
|
|
|
+ cb_sink
|
|
|
+ .send(Box::new(move |siv| {
|
|
|
+ ui::visual_update(siv);
|
|
|
+
|
|
|
+ if timer % REMOTE_REFRESH_RATE == 0 {
|
|
|
+ actions::load_messages(siv);
|
|
|
+ }
|
|
|
+ }))
|
|
|
+ .expect("Failed to send callback from background thread.");
|
|
|
+
|
|
|
+ timer += 1;
|
|
|
+ thread::sleep(std::time::Duration::from_secs(1));
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // Users
|
|
|
+ let users_panel = {
|
|
|
+ let mut users_view: SelectView<String> = SelectView::new();
|
|
|
+ users_view.set_on_submit(move |siv, user: &String| actions::on_user_click(siv, user));
|
|
|
+ Panel::new(ScrollView::new(users_view)).with_name(USERS_PANEL_ID)
|
|
|
+ };
|
|
|
+
|
|
|
+ // Messages
|
|
|
+ let messages_panel = {
|
|
|
+ let mut view: SelectView<String> = SelectView::new();
|
|
|
+ view.set_on_submit(move |siv, message_id: &str| actions::on_message_click(siv, message_id));
|
|
|
+ Panel::new(ScrollView::new(view)).with_name(MESSAGE_PANEL_ID)
|
|
|
+ };
|
|
|
+
|
|
|
+ // Input
|
|
|
+ let input_panel = {
|
|
|
+ let input_field = TextArea::new()
|
|
|
+ .with_name(INPUT_FIELD_ID)
|
|
|
+ .full_width()
|
|
|
+ .max_height(10); // NOTE: 10 seems to be the size of 2 vertical buttons
|
|
|
+ let input_button =
|
|
|
+ Button::new("", move |siv| actions::on_input_submit(siv, INPUT_FIELD_ID))
|
|
|
+ .with_name(INPUT_BUTTON_ID);
|
|
|
+
|
|
|
+ let input_clear_button =
|
|
|
+ Button::new("", actions::clear_input_field).with_name(INPUT_CLEAR_BUTTON_ID);
|
|
|
+
|
|
|
+ Panel::new(
|
|
|
+ LinearLayout::horizontal().child(input_field).child(
|
|
|
+ LinearLayout::vertical()
|
|
|
+ .child(input_button)
|
|
|
+ .child(input_clear_button),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ .with_name(INPUT_PANEL_ID)
|
|
|
+ };
|
|
|
+
|
|
|
+ // Language selector
|
|
|
+ let language_button = Button::new("", move |siv| {
|
|
|
+ // NOTE: This must match the order in the SelectView
|
|
|
+ let current_language_index = match get_appdata(siv).language {
|
|
|
+ ui::Language::English => 0,
|
|
|
+ ui::Language::Dutch => 1,
|
|
|
+ ui::Language::Japanese => 2,
|
|
|
+ };
|
|
|
+
|
|
|
+ let language = get_appdata(siv).language;
|
|
|
+
|
|
|
+ ui::keybind_close_once(siv);
|
|
|
+
|
|
|
+ siv.add_layer(
|
|
|
+ cursive::views::Dialog::new()
|
|
|
+ .content(
|
|
|
+ LinearLayout::vertical().child(
|
|
|
+ SelectView::<ui::Language>::new()
|
|
|
+ .item("English", ui::Language::English)
|
|
|
+ .item("Nederlands", ui::Language::Dutch)
|
|
|
+ .item("日本語", ui::Language::Japanese)
|
|
|
+ .selected(current_language_index)
|
|
|
+ .on_submit(move |siv, language| {
|
|
|
+ ui::change_language(siv, *language);
|
|
|
+ ui::keybind_close_manual_end(siv, false);
|
|
|
+ }),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ .title(ui::Labels::SetLanguage.localize(language))
|
|
|
+ .button(ui::Labels::Cancel.localize(language), |siv| {
|
|
|
+ ui::keybind_close_manual_end(siv, false);
|
|
|
+ })
|
|
|
+ .min_size(DIALOGUE_MIN_SIZE),
|
|
|
+ );
|
|
|
+ })
|
|
|
+ .with_name(LANGUAGE_BUTTON_ID);
|
|
|
+
|
|
|
+ // Current username panel
|
|
|
+ let current_username_panel = {
|
|
|
+ let current_username = get_appdata(&mut siv).username.clone();
|
|
|
+ let view = TextView::new(current_username);
|
|
|
+ Panel::new(view).with_name(ui::CURRENT_USERNAME_PANEL_ID)
|
|
|
+ };
|
|
|
+
|
|
|
+ // Set username
|
|
|
+ let username_button = Button::new("", move |siv| {
|
|
|
+ let current_name = get_appdata(siv).username.clone();
|
|
|
+ let language = get_appdata(siv).language;
|
|
|
+
|
|
|
+ ui::keybind_close_once(siv);
|
|
|
+
|
|
|
+ siv.add_layer(
|
|
|
+ cursive::views::Dialog::new()
|
|
|
+ .title(ui::Labels::Username.localize(language))
|
|
|
+ .content(
|
|
|
+ LinearLayout::vertical().child(
|
|
|
+ EditView::new()
|
|
|
+ .content(current_name)
|
|
|
+ .with_name(USERNAME_FIELD_ID)
|
|
|
+ .fixed_width(DIALOGUE_MIN_SIZE.0.into()),
|
|
|
+ ),
|
|
|
+ )
|
|
|
+ .button(ui::Labels::Ok.localize(language), |siv| {
|
|
|
+ let input = siv
|
|
|
+ .call_on_name(USERNAME_FIELD_ID, |view: &mut EditView| view.get_content())
|
|
|
+ .expect("Failed to retrieve username input.");
|
|
|
+
|
|
|
+ if message::is_valid_username(&input) {
|
|
|
+ // Valid
|
|
|
+ siv.with_user_data(|appdata: &mut Appdata| {
|
|
|
+ appdata.username = input.to_string();
|
|
|
+ });
|
|
|
+
|
|
|
+ ui::visual_update(siv);
|
|
|
+ ui::keybind_close_manual_end(siv, false);
|
|
|
+ } else {
|
|
|
+ // Invalid
|
|
|
+ let language = get_appdata(siv).language;
|
|
|
+ ui::keybind_close_manual_end(siv, false); // NOTE: Error dialogue overwrites this one, so end it here beforehand.
|
|
|
+ ui::error(
|
|
|
+ siv,
|
|
|
+ ui::Labels::InvalidUsernameExplination.localize(language),
|
|
|
+ );
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .button(ui::Labels::Cancel.localize(language), |siv| {
|
|
|
+ ui::keybind_close_manual_end(siv, false);
|
|
|
+ })
|
|
|
+ .min_size(DIALOGUE_MIN_SIZE),
|
|
|
+ );
|
|
|
+ })
|
|
|
+ .with_name(USERNAME_BUTTON_ID);
|
|
|
+
|
|
|
+ // Refresh button
|
|
|
+ let refresh_button = Button::new("", move |siv| {
|
|
|
+ if actions::load_messages(siv).is_err() {
|
|
|
+ let language = get_appdata(siv).language;
|
|
|
+ ui::error(siv, ui::Labels::FailedToLoadMessages.localize(language));
|
|
|
+ }
|
|
|
+ ui::visual_update(siv);
|
|
|
+ })
|
|
|
+ .with_name(REFRESH_BUTTON_ID);
|
|
|
+
|
|
|
+ // Blocked words list
|
|
|
+ let blocked_words_list_button = {
|
|
|
+ Button::new("", move |siv| {
|
|
|
+ let appdata = get_appdata(siv);
|
|
|
+ let language = appdata.language;
|
|
|
+
|
|
|
+ let wordslist_id = utils::new_id();
|
|
|
+ let wordslist_id_clone = wordslist_id.clone();
|
|
|
+ let wordslist_id_typingarea_clone = wordslist_id.clone();
|
|
|
+ let mut wordslist_view =
|
|
|
+ SelectView::<String>::new().on_submit(move |siv, word: &str| {
|
|
|
+ siv.with_user_data(|appdata: &mut Appdata| {
|
|
|
+ appdata.blocked_phrases.retain(|x| x != word);
|
|
|
+ });
|
|
|
+
|
|
|
+ siv.call_on_name(&wordslist_id_clone, |view: &mut NamedView<SelectView>| {
|
|
|
+ let idx = view
|
|
|
+ .get_mut()
|
|
|
+ .selected_id()
|
|
|
+ .expect("Selection should exist when fired from submit.");
|
|
|
+ view.get_mut().remove_item(idx);
|
|
|
+ });
|
|
|
+
|
|
|
+ ui::visual_update(siv);
|
|
|
+ });
|
|
|
+ wordslist_view.add_all_str(&appdata.blocked_phrases);
|
|
|
+
|
|
|
+ let typingarea_id = utils::new_id();
|
|
|
+ let appdata_typingarea_clone = appdata.clone();
|
|
|
+ let typing_area = EditView::new().with_name(&typingarea_id);
|
|
|
+ let typing_button = Button::new(ui::Labels::Submit.localize(language), move |siv| {
|
|
|
+ let text = siv
|
|
|
+ .call_on_name(&typingarea_id, |view: &mut EditView| {
|
|
|
+ let s = view.get_content();
|
|
|
+ view.set_content("");
|
|
|
+ s
|
|
|
+ })
|
|
|
+ .map(|x| x.to_string())
|
|
|
+ .unwrap_or("".to_string());
|
|
|
+
|
|
|
+ if appdata_typingarea_clone.blocked_phrases.contains(&text) || text.is_empty() {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ siv.with_user_data(|appdata: &mut Appdata| {
|
|
|
+ appdata.blocked_phrases.push(text.clone());
|
|
|
+ });
|
|
|
+
|
|
|
+ siv.call_on_name(
|
|
|
+ &wordslist_id_typingarea_clone,
|
|
|
+ |view: &mut NamedView<SelectView>| {
|
|
|
+ view.get_mut().add_item_str(text);
|
|
|
+ },
|
|
|
+ );
|
|
|
+
|
|
|
+ ui::visual_update(siv);
|
|
|
+ });
|
|
|
+
|
|
|
+ let d = Dialog::new()
|
|
|
+ .title(ui::Labels::BlockedWords.localize(language))
|
|
|
+ .button(ui::Labels::Close.localize(language), |siv| {
|
|
|
+ ui::keybind_close_manual_end(siv, false);
|
|
|
+ })
|
|
|
+ .content(
|
|
|
+ LinearLayout::vertical()
|
|
|
+ .child(ScrollView::new(wordslist_view.with_name(wordslist_id)))
|
|
|
+ .child(DummyView.fixed_height(1))
|
|
|
+ .child(
|
|
|
+ LinearLayout::horizontal()
|
|
|
+ .child(typing_area.min_width(DIALOGUE_MIN_SIZE.0 as usize))
|
|
|
+ .child(typing_button),
|
|
|
+ ),
|
|
|
+ );
|
|
|
+
|
|
|
+ ui::keybind_close_once(siv);
|
|
|
+ siv.add_layer(d);
|
|
|
+ })
|
|
|
+ .with_name(ui::BLOCKED_WORDS_BUTTON_ID)
|
|
|
+ };
|
|
|
+
|
|
|
+ // Main layout
|
|
|
+ let main_layout = LinearLayout::vertical()
|
|
|
+ .child(
|
|
|
+ LinearLayout::horizontal()
|
|
|
+ .child(
|
|
|
+ LinearLayout::vertical()
|
|
|
+ .child(users_panel.resized(
|
|
|
+ cursive::view::SizeConstraint::Fixed(USER_PANEL_SIZE),
|
|
|
+ cursive::view::SizeConstraint::Full,
|
|
|
+ ))
|
|
|
+ .child(current_username_panel),
|
|
|
+ )
|
|
|
+ .child(messages_panel.full_width()),
|
|
|
+ )
|
|
|
+ .child(input_panel)
|
|
|
+ .child(
|
|
|
+ LinearLayout::horizontal()
|
|
|
+ .child(language_button)
|
|
|
+ .child(DummyView.full_width())
|
|
|
+ .child(username_button)
|
|
|
+ .child(DummyView.full_width())
|
|
|
+ .child(blocked_words_list_button)
|
|
|
+ .child(DummyView.full_width())
|
|
|
+ .child(refresh_button)
|
|
|
+ .full_width(),
|
|
|
+ );
|
|
|
+
|
|
|
+ siv.add_fullscreen_layer(main_layout);
|
|
|
+ ui::change_language(&mut siv, ui::Language::English);
|
|
|
+ siv.run();
|
|
|
+}
|