Notifications

This commit is contained in:
veeso
2021-09-26 18:14:13 +02:00
parent f0a91b1579
commit 198d421ab0
31 changed files with 1363 additions and 198 deletions

View File

@@ -27,7 +27,8 @@
*/
use super::{AuthActivity, FileTransferParams, FileTransferProtocol};
use crate::filetransfer::params::{AwsS3Params, GenericProtocolParams, ProtocolParams};
use crate::system::auto_update::{Update, UpdateStatus};
use crate::system::auto_update::{Release, Update, UpdateStatus};
use crate::system::notifications::Notification;
impl AuthActivity {
/// ### protocol_opt_to_enum
@@ -155,6 +156,51 @@ impl AuthActivity {
// -- update install
/// ### check_for_updates
///
/// If enabled in configuration, check for updates from Github
pub(super) fn check_for_updates(&mut self) {
debug!("Check for updates...");
// Check version only if unset in the store
let ctx = self.context_mut();
if !ctx.store().isset(super::STORE_KEY_LATEST_VERSION) {
debug!("Version is not set in storage");
if ctx.config().get_check_for_updates() {
debug!("Check for updates is enabled");
// Send request
match Update::is_new_version_available() {
Ok(Some(Release { version, body })) => {
// If some, store version and release notes
info!("Latest version is: {}", version);
if ctx.config().get_notifications() {
// Notify new version available
Notification::update_available(version.as_str());
}
// Store info
ctx.store_mut()
.set_string(super::STORE_KEY_LATEST_VERSION, version);
ctx.store_mut()
.set_string(super::STORE_KEY_RELEASE_NOTES, body);
}
Ok(None) => {
info!("Latest version is: {} (current)", env!("CARGO_PKG_VERSION"));
// Just set flag as check
ctx.store_mut().set(super::STORE_KEY_LATEST_VERSION);
}
Err(err) => {
// Report error
error!("Failed to get latest version: {}", err);
self.mount_error(
format!("Could not check for new updates: {}", err).as_str(),
);
}
}
} else {
info!("Check for updates is disabled");
}
}
}
/// ### install_update
///
/// Install latest termscp version via GUI
@@ -173,9 +219,17 @@ impl AuthActivity {
match result {
Ok(UpdateStatus::AlreadyUptodate) => self.mount_info("termscp is already up to date!"),
Ok(UpdateStatus::UpdateInstalled(ver)) => {
if self.config().get_notifications() {
Notification::update_installed(ver.as_str());
}
self.mount_info(format!("termscp has been updated to version {}!", ver))
}
Err(err) => self.mount_error(format!("Could not install update: {}", err)),
Err(err) => {
if self.config().get_notifications() {
Notification::update_failed(err.to_string());
}
self.mount_error(format!("Could not install update: {}", err))
}
}
}
}

View File

@@ -35,8 +35,8 @@ mod view;
use super::{Activity, Context, ExitReason};
use crate::config::themes::Theme;
use crate::filetransfer::{FileTransferParams, FileTransferProtocol};
use crate::system::auto_update::{Release, Update as TermscpUpdate};
use crate::system::bookmarks_client::BookmarksClient;
use crate::system::config_client::ConfigClient;
// Includes
use crossterm::event::Event;
@@ -110,45 +110,6 @@ impl AuthActivity {
}
}
/// ### on_create
///
/// If enabled in configuration, check for updates from Github
fn check_for_updates(&mut self) {
debug!("Check for updates...");
// Check version only if unset in the store
let ctx: &mut Context = self.context_mut();
if !ctx.store().isset(STORE_KEY_LATEST_VERSION) {
debug!("Version is not set in storage");
if ctx.config().get_check_for_updates() {
debug!("Check for updates is enabled");
// Send request
match TermscpUpdate::is_new_version_available() {
Ok(Some(Release { version, body })) => {
// If some, store version and release notes
info!("Latest version is: {}", version);
ctx.store_mut()
.set_string(STORE_KEY_LATEST_VERSION, version);
ctx.store_mut().set_string(STORE_KEY_RELEASE_NOTES, body);
}
Ok(None) => {
info!("Latest version is: {} (current)", env!("CARGO_PKG_VERSION"));
// Just set flag as check
ctx.store_mut().set(STORE_KEY_LATEST_VERSION);
}
Err(err) => {
// Report error
error!("Failed to get latest version: {}", err);
self.mount_error(
format!("Could not check for new updates: {}", err).as_str(),
);
}
}
} else {
info!("Check for updates is disabled");
}
}
}
/// ### context
///
/// Returns a reference to context
@@ -163,6 +124,13 @@ impl AuthActivity {
self.context.as_mut().unwrap()
}
/// ### config
///
/// Returns config client reference
fn config(&self) -> &ConfigClient {
self.context().config()
}
/// ### theme
///
/// Returns a reference to theme

View File

@@ -87,6 +87,13 @@ impl TransferStates {
pub fn aborted(&self) -> bool {
self.aborted
}
/// ### full_size
///
/// Returns the size of the entire transfer
pub fn full_size(&self) -> usize {
self.full.total
}
}
impl Default for ProgressStates {
@@ -305,6 +312,8 @@ mod test {
assert_eq!(states.aborted(), true);
states.reset();
assert_eq!(states.aborted(), false);
states.full.total = 1024;
assert_eq!(states.full_size(), 1024);
}
#[test]

View File

@@ -22,12 +22,15 @@
* SOFTWARE.
*/
// Locals
use super::{ConfigClient, FileTransferActivity, LogLevel, LogRecord};
use super::{ConfigClient, FileTransferActivity, LogLevel, LogRecord, TransferPayload};
use crate::filetransfer::ProtocolParams;
use crate::system::environment;
use crate::system::notifications::Notification;
use crate::system::sshkey_storage::SshKeyStorage;
use crate::utils::fmt::fmt_millis;
use crate::utils::path;
// Ext
use bytesize::ByteSize;
use std::env;
use std::path::{Path, PathBuf};
use tuirealm::Update;
@@ -146,4 +149,86 @@ impl FileTransferActivity {
ProtocolParams::AwsS3(params) => params.bucket_name.clone(),
}
}
/// ### get_connection_msg
///
/// Get connection message to show to client
pub(super) fn get_connection_msg(params: &ProtocolParams) -> String {
match params {
ProtocolParams::Generic(params) => {
info!(
"Client is not connected to remote; connecting to {}:{}",
params.address, params.port
);
format!("Connecting to {}:{}", params.address, params.port)
}
ProtocolParams::AwsS3(params) => {
info!(
"Client is not connected to remote; connecting to {} ({})",
params.bucket_name, params.region
);
format!("Connecting to {}", params.bucket_name)
}
}
}
/// ### notify_transfer_completed
///
/// Send notification regarding transfer completed
/// The notification is sent only when these conditions are satisfied:
///
/// - notifications are enabled
/// - transfer size is greater or equal than notification threshold
pub(super) fn notify_transfer_completed(&self, payload: &TransferPayload) {
if self.config().get_notifications()
&& self.config().get_notification_threshold() as usize <= self.transfer.full_size()
{
Notification::transfer_completed(self.transfer_completed_msg(payload));
}
}
/// ### notify_transfer_error
///
/// Send notification regarding transfer error
/// The notification is sent only when these conditions are satisfied:
///
/// - notifications are enabled
/// - transfer size is greater or equal than notification threshold
pub(super) fn notify_transfer_error(&self, msg: &str) {
if self.config().get_notifications()
&& self.config().get_notification_threshold() as usize <= self.transfer.full_size()
{
Notification::transfer_error(msg);
}
}
fn transfer_completed_msg(&self, payload: &TransferPayload) -> String {
let transfer_stats = format!(
"took {} seconds; at {}/s",
fmt_millis(self.transfer.partial.started().elapsed()),
ByteSize(self.transfer.partial.calc_bytes_per_second()),
);
match payload {
TransferPayload::File(file) => {
format!(
"File \"{}\" has been successfully transferred ({})",
file.name, transfer_stats
)
}
TransferPayload::Any(entry) => {
format!(
"\"{}\" has been successfully transferred ({})",
entry.get_name(),
transfer_stats
)
}
TransferPayload::Many(entries) => {
format!(
"{} files has been successfully transferred ({})",
entries.len(),
transfer_stats
)
}
}
}
}

View File

@@ -36,7 +36,7 @@ pub(self) mod view;
// locals
use super::{Activity, Context, ExitReason};
use crate::config::themes::Theme;
use crate::filetransfer::{FileTransfer, FileTransferProtocol, ProtocolParams};
use crate::filetransfer::{FileTransfer, FileTransferProtocol};
use crate::filetransfer::{FtpFileTransfer, S3FileTransfer, ScpFileTransfer, SftpFileTransfer};
use crate::fs::explorer::FileExplorer;
use crate::fs::FsEntry;
@@ -239,28 +239,6 @@ impl FileTransferActivity {
fn theme(&self) -> &Theme {
self.context().theme_provider().theme()
}
/// ### get_connection_msg
///
/// Get connection message to show to client
fn get_connection_msg(params: &ProtocolParams) -> String {
match params {
ProtocolParams::Generic(params) => {
info!(
"Client is not connected to remote; connecting to {}:{}",
params.address, params.port
);
format!("Connecting to {}:{}", params.address, params.port)
}
ProtocolParams::AwsS3(params) => {
info!(
"Client is not connected to remote; connecting to {} ({})",
params.bucket_name, params.region
);
format!("Connecting to {}", params.bucket_name)
}
}
}
}
/**

View File

@@ -206,17 +206,27 @@ impl FileTransferActivity {
dst_name: Option<String>,
) -> Result<(), String> {
// Use different method based on payload
match payload {
TransferPayload::Any(entry) => {
self.filetransfer_send_any(&entry, curr_remote_path, dst_name)
let result = match payload {
TransferPayload::Any(ref entry) => {
self.filetransfer_send_any(entry, curr_remote_path, dst_name)
}
TransferPayload::File(file) => {
self.filetransfer_send_file(&file, curr_remote_path, dst_name)
TransferPayload::File(ref file) => {
self.filetransfer_send_file(file, curr_remote_path, dst_name)
}
TransferPayload::Many(entries) => {
TransferPayload::Many(ref entries) => {
self.filetransfer_send_many(entries, curr_remote_path)
}
};
// Notify
match &result {
Ok(_) => {
self.notify_transfer_completed(&payload);
}
Err(e) => {
self.notify_transfer_error(e.as_str());
}
}
result
}
/// ### filetransfer_send_file
@@ -268,10 +278,10 @@ impl FileTransferActivity {
// Mount progress bar
self.mount_progress_bar(format!("Uploading {}", entry.get_abs_path().display()));
// Send recurse
self.filetransfer_send_recurse(entry, curr_remote_path, dst_name);
let result = self.filetransfer_send_recurse(entry, curr_remote_path, dst_name);
// Umount progress bar
self.umount_progress_bar();
Ok(())
result
}
/// ### filetransfer_send_many
@@ -279,7 +289,7 @@ impl FileTransferActivity {
/// Send many entries to remote
fn filetransfer_send_many(
&mut self,
entries: Vec<FsEntry>,
entries: &[FsEntry],
curr_remote_path: &Path,
) -> Result<(), String> {
// Reset states
@@ -293,12 +303,14 @@ impl FileTransferActivity {
// Mount progress bar
self.mount_progress_bar(format!("Uploading {} entries…", entries.len()));
// Send recurse
entries
let result = entries
.iter()
.for_each(|x| self.filetransfer_send_recurse(x, curr_remote_path, None));
.map(|x| self.filetransfer_send_recurse(x, curr_remote_path, None))
.find(|x| x.is_err())
.unwrap_or(Ok(()));
// Umount progress bar
self.umount_progress_bar();
Ok(())
result
}
fn filetransfer_send_recurse(
@@ -306,7 +318,7 @@ impl FileTransferActivity {
entry: &FsEntry,
curr_remote_path: &Path,
dst_name: Option<String>,
) {
) -> Result<(), String> {
// Write popup
let file_name: String = match entry {
FsEntry::Directory(dir) => dir.name.clone(),
@@ -320,44 +332,42 @@ impl FileTransferActivity {
};
remote_path.push(remote_file_name);
// Match entry
match entry {
let result: Result<(), String> = match entry {
FsEntry::File(file) => {
if let Err(err) = self.filetransfer_send_one(file, remote_path.as_path(), file_name)
{
// Log error
self.log_and_alert(
LogLevel::Error,
format!("Failed to upload file {}: {}", file.name, err),
);
// If transfer was abrupted or there was an IO error on remote, remove file
if matches!(
err,
TransferErrorReason::Abrupted | TransferErrorReason::RemoteIoError(_)
) {
// Stat file on remote and remove it if exists
match self.client.stat(remote_path.as_path()) {
Err(err) => self.log(
LogLevel::Error,
format!(
"Could not remove created file {}: {}",
remote_path.display(),
err
match self.filetransfer_send_one(file, remote_path.as_path(), file_name) {
Err(err) => {
// If transfer was abrupted or there was an IO error on remote, remove file
if matches!(
err,
TransferErrorReason::Abrupted | TransferErrorReason::RemoteIoError(_)
) {
// Stat file on remote and remove it if exists
match self.client.stat(remote_path.as_path()) {
Err(err) => self.log(
LogLevel::Error,
format!(
"Could not remove created file {}: {}",
remote_path.display(),
err
),
),
),
Ok(entry) => {
if let Err(err) = self.client.remove(&entry) {
self.log(
LogLevel::Error,
format!(
"Could not remove created file {}: {}",
remote_path.display(),
err
),
);
Ok(entry) => {
if let Err(err) = self.client.remove(&entry) {
self.log(
LogLevel::Error,
format!(
"Could not remove created file {}: {}",
remote_path.display(),
err
),
);
}
}
}
}
Err(err.to_string())
}
Ok(_) => Ok(()),
}
}
FsEntry::Directory(dir) => {
@@ -387,7 +397,7 @@ impl FileTransferActivity {
err
),
);
return;
return Err(err.to_string());
}
}
// Get files in dir
@@ -400,8 +410,13 @@ impl FileTransferActivity {
break;
}
// Send entry; name is always None after first call
self.filetransfer_send_recurse(entry, remote_path.as_path(), None);
if let Err(err) =
self.filetransfer_send_recurse(entry, remote_path.as_path(), None)
{
return Err(err);
}
}
Ok(())
}
Err(err) => {
self.log_and_alert(
@@ -412,10 +427,11 @@ impl FileTransferActivity {
err
),
);
Err(err.to_string())
}
}
}
}
};
// Scan dir on remote
self.reload_remote_dir();
// If aborted; show popup
@@ -426,6 +442,7 @@ impl FileTransferActivity {
format!("Upload aborted for \"{}\"!", entry.get_abs_path().display()),
);
}
result
}
/// ### filetransfer_send_file
@@ -613,11 +630,23 @@ impl FileTransferActivity {
local_path: &Path,
dst_name: Option<String>,
) -> Result<(), String> {
match payload {
TransferPayload::Any(entry) => self.filetransfer_recv_any(&entry, local_path, dst_name),
TransferPayload::File(file) => self.filetransfer_recv_file(&file, local_path),
TransferPayload::Many(entries) => self.filetransfer_recv_many(entries, local_path),
let result = match payload {
TransferPayload::Any(ref entry) => {
self.filetransfer_recv_any(entry, local_path, dst_name)
}
TransferPayload::File(ref file) => self.filetransfer_recv_file(file, local_path),
TransferPayload::Many(ref entries) => self.filetransfer_recv_many(entries, local_path),
};
// Notify
match &result {
Ok(_) => {
self.notify_transfer_completed(&payload);
}
Err(e) => {
self.notify_transfer_error(e.as_str());
}
}
result
}
/// ### filetransfer_recv_any
@@ -639,10 +668,10 @@ impl FileTransferActivity {
// Mount progress bar
self.mount_progress_bar(format!("Downloading {}", entry.get_abs_path().display()));
// Receive
self.filetransfer_recv_recurse(entry, local_path, dst_name);
let result = self.filetransfer_recv_recurse(entry, local_path, dst_name);
// Umount progress bar
self.umount_progress_bar();
Ok(())
result
}
/// ### filetransfer_recv_file
@@ -669,7 +698,7 @@ impl FileTransferActivity {
/// Send many entries to remote
fn filetransfer_recv_many(
&mut self,
entries: Vec<FsEntry>,
entries: &[FsEntry],
curr_remote_path: &Path,
) -> Result<(), String> {
// Reset states
@@ -683,12 +712,14 @@ impl FileTransferActivity {
// Mount progress bar
self.mount_progress_bar(format!("Downloading {} entries…", entries.len()));
// Send recurse
entries
let result = entries
.iter()
.for_each(|x| self.filetransfer_recv_recurse(x, curr_remote_path, None));
.map(|x| self.filetransfer_recv_recurse(x, curr_remote_path, None))
.find(|x| x.is_err())
.unwrap_or(Ok(()));
// Umount progress bar
self.umount_progress_bar();
Ok(())
result
}
fn filetransfer_recv_recurse(
@@ -696,14 +727,14 @@ impl FileTransferActivity {
entry: &FsEntry,
local_path: &Path,
dst_name: Option<String>,
) {
) -> Result<(), String> {
// Write popup
let file_name: String = match entry {
FsEntry::Directory(dir) => dir.name.clone(),
FsEntry::File(file) => file.name.clone(),
};
// Match entry
match entry {
let result: Result<(), String> = match entry {
FsEntry::File(file) => {
// Get local file
let mut local_file_path: PathBuf = PathBuf::from(local_path);
@@ -716,10 +747,6 @@ impl FileTransferActivity {
if let Err(err) =
self.filetransfer_recv_one(local_file_path.as_path(), file, file_name)
{
self.log_and_alert(
LogLevel::Error,
format!("Could not download file {}: {}", file.name, err),
);
// If transfer was abrupted or there was an IO error on remote, remove file
if matches!(
err,
@@ -749,6 +776,9 @@ impl FileTransferActivity {
}
}
}
Err(err.to_string())
} else {
Ok(())
}
}
FsEntry::Directory(dir) => {
@@ -798,12 +828,15 @@ impl FileTransferActivity {
}
// Receive entry; name is always None after first call
// Local path becomes local_dir_path
self.filetransfer_recv_recurse(
if let Err(err) = self.filetransfer_recv_recurse(
entry,
local_dir_path.as_path(),
None,
);
) {
return Err(err);
}
}
Ok(())
}
Err(err) => {
self.log_and_alert(
@@ -814,6 +847,7 @@ impl FileTransferActivity {
err
),
);
Err(err.to_string())
}
}
}
@@ -826,10 +860,11 @@ impl FileTransferActivity {
err
),
);
Err(err.to_string())
}
}
}
}
};
// Reload directory on local
self.reload_local_dir();
// if aborted; show alert
@@ -843,6 +878,7 @@ impl FileTransferActivity {
),
);
}
result
}
/// ### filetransfer_recv_one

View File

@@ -58,6 +58,8 @@ const COMPONENT_RADIO_PROMPT_ON_FILE_REPLACE: &str = "RADIO_PROMPT_ON_FILE_REPLA
const COMPONENT_RADIO_GROUP_DIRS: &str = "RADIO_GROUP_DIRS";
const COMPONENT_INPUT_LOCAL_FILE_FMT: &str = "INPUT_LOCAL_FILE_FMT";
const COMPONENT_INPUT_REMOTE_FILE_FMT: &str = "INPUT_REMOTE_FILE_FMT";
const COMPONENT_RADIO_NOTIFICATIONS_ENABLED: &str = "RADIO_NOTIFICATIONS_ENABLED";
const COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD: &str = "INPUT_NOTIFICATIONS_THRESHOLD";
// -- ssh keys
const COMPONENT_LIST_SSH_KEYS: &str = "LIST_SSH_KEYS";
const COMPONENT_INPUT_SSH_HOST: &str = "INPUT_SSH_HOST";

View File

@@ -40,11 +40,13 @@ use super::{
COMPONENT_COLOR_TRANSFER_PROG_BAR_FULL, COMPONENT_COLOR_TRANSFER_PROG_BAR_PARTIAL,
COMPONENT_COLOR_TRANSFER_STATUS_HIDDEN, COMPONENT_COLOR_TRANSFER_STATUS_SORTING,
COMPONENT_COLOR_TRANSFER_STATUS_SYNC, COMPONENT_INPUT_LOCAL_FILE_FMT,
COMPONENT_INPUT_REMOTE_FILE_FMT, COMPONENT_INPUT_SSH_HOST, COMPONENT_INPUT_SSH_USERNAME,
COMPONENT_INPUT_TEXT_EDITOR, COMPONENT_LIST_SSH_KEYS, COMPONENT_RADIO_DEFAULT_PROTOCOL,
COMPONENT_RADIO_DEL_SSH_KEY, COMPONENT_RADIO_GROUP_DIRS, COMPONENT_RADIO_HIDDEN_FILES,
COMPONENT_RADIO_PROMPT_ON_FILE_REPLACE, COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SAVE,
COMPONENT_RADIO_UPDATES, COMPONENT_TEXT_ERROR, COMPONENT_TEXT_HELP,
COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD, COMPONENT_INPUT_REMOTE_FILE_FMT,
COMPONENT_INPUT_SSH_HOST, COMPONENT_INPUT_SSH_USERNAME, COMPONENT_INPUT_TEXT_EDITOR,
COMPONENT_LIST_SSH_KEYS, COMPONENT_RADIO_DEFAULT_PROTOCOL, COMPONENT_RADIO_DEL_SSH_KEY,
COMPONENT_RADIO_GROUP_DIRS, COMPONENT_RADIO_HIDDEN_FILES,
COMPONENT_RADIO_NOTIFICATIONS_ENABLED, COMPONENT_RADIO_PROMPT_ON_FILE_REPLACE,
COMPONENT_RADIO_QUIT, COMPONENT_RADIO_SAVE, COMPONENT_RADIO_UPDATES, COMPONENT_TEXT_ERROR,
COMPONENT_TEXT_HELP,
};
use crate::ui::keymap::*;
use crate::utils::parser::parse_color;
@@ -103,10 +105,26 @@ impl SetupActivity {
None
}
(COMPONENT_INPUT_REMOTE_FILE_FMT, key) if key == &MSG_KEY_DOWN => {
self.view.active(COMPONENT_RADIO_NOTIFICATIONS_ENABLED);
None
}
(COMPONENT_RADIO_NOTIFICATIONS_ENABLED, key) if key == &MSG_KEY_DOWN => {
self.view.active(COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD);
None
}
(COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD, key) if key == &MSG_KEY_DOWN => {
self.view.active(COMPONENT_INPUT_TEXT_EDITOR);
None
}
// Input field <UP>
(COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD, key) if key == &MSG_KEY_UP => {
self.view.active(COMPONENT_RADIO_NOTIFICATIONS_ENABLED);
None
}
(COMPONENT_RADIO_NOTIFICATIONS_ENABLED, key) if key == &MSG_KEY_UP => {
self.view.active(COMPONENT_INPUT_REMOTE_FILE_FMT);
None
}
(COMPONENT_INPUT_REMOTE_FILE_FMT, key) if key == &MSG_KEY_UP => {
self.view.active(COMPONENT_INPUT_LOCAL_FILE_FMT);
None
@@ -136,7 +154,7 @@ impl SetupActivity {
None
}
(COMPONENT_INPUT_TEXT_EDITOR, key) if key == &MSG_KEY_UP => {
self.view.active(COMPONENT_INPUT_REMOTE_FILE_FMT);
self.view.active(COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD);
None
}
// Error <ENTER> or <ESC>

View File

@@ -30,6 +30,7 @@
use super::{Context, SetupActivity};
use crate::filetransfer::FileTransferProtocol;
use crate::fs::explorer::GroupDirs;
use crate::ui::components::bytes::{Bytes, BytesPropsBuilder};
use crate::utils::ui::draw_area_in;
// Ext
use std::path::PathBuf;
@@ -143,8 +144,8 @@ impl SetupActivity {
super::COMPONENT_INPUT_LOCAL_FILE_FMT,
Box::new(Input::new(
InputPropsBuilder::default()
.with_foreground(Color::LightBlue)
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightBlue)
.with_foreground(Color::LightGreen)
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen)
.with_label("File formatter syntax (local)", Alignment::Left)
.build(),
)),
@@ -153,12 +154,35 @@ impl SetupActivity {
super::COMPONENT_INPUT_REMOTE_FILE_FMT,
Box::new(Input::new(
InputPropsBuilder::default()
.with_foreground(Color::LightGreen)
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightGreen)
.with_foreground(Color::LightCyan)
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightCyan)
.with_label("File formatter syntax (remote)", Alignment::Left)
.build(),
)),
);
self.view.mount(
super::COMPONENT_RADIO_NOTIFICATIONS_ENABLED,
Box::new(Radio::new(
RadioPropsBuilder::default()
.with_color(Color::LightRed)
.with_inverted_color(Color::Black)
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightRed)
.with_title("Enable notifications?", Alignment::Left)
.with_options(&[String::from("Yes"), String::from("No")])
.rewind(true)
.build(),
)),
);
self.view.mount(
super::COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD,
Box::new(Bytes::new(
BytesPropsBuilder::default()
.with_foreground(Color::LightYellow)
.with_borders(Borders::ALL, BorderType::Rounded, Color::LightYellow)
.with_label("Notifications: minimum transfer size", Alignment::Left)
.build(),
)),
);
// Load values
self.load_input_values();
}
@@ -173,7 +197,7 @@ impl SetupActivity {
.constraints(
[
Constraint::Length(3), // Current tab
Constraint::Length(21), // Main body
Constraint::Length(18), // Main body
Constraint::Length(3), // Help footer
]
.as_ref(),
@@ -182,8 +206,13 @@ impl SetupActivity {
// Render common widget
self.view.render(super::COMPONENT_RADIO_TAB, f, chunks[0]);
self.view.render(super::COMPONENT_TEXT_FOOTER, f, chunks[2]);
// Make chunks
// Make chunks (two columns)
let ui_cfg_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(chunks[1]);
// Column 1
let ui_cfg_chunks_col1 = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
@@ -193,31 +222,65 @@ impl SetupActivity {
Constraint::Length(3), // Updates tab
Constraint::Length(3), // Prompt file replace
Constraint::Length(3), // Group dirs
Constraint::Length(3), // Local Format input
Constraint::Length(3), // Remote Format input
]
.as_ref(),
)
.split(chunks[1]);
.split(ui_cfg_chunks[0]);
self.view
.render(super::COMPONENT_INPUT_TEXT_EDITOR, f, ui_cfg_chunks[0]);
.render(super::COMPONENT_INPUT_TEXT_EDITOR, f, ui_cfg_chunks_col1[0]);
self.view.render(
super::COMPONENT_RADIO_DEFAULT_PROTOCOL,
f,
ui_cfg_chunks_col1[1],
);
self.view.render(
super::COMPONENT_RADIO_HIDDEN_FILES,
f,
ui_cfg_chunks_col1[2],
);
self.view
.render(super::COMPONENT_RADIO_DEFAULT_PROTOCOL, f, ui_cfg_chunks[1]);
self.view
.render(super::COMPONENT_RADIO_HIDDEN_FILES, f, ui_cfg_chunks[2]);
self.view
.render(super::COMPONENT_RADIO_UPDATES, f, ui_cfg_chunks[3]);
.render(super::COMPONENT_RADIO_UPDATES, f, ui_cfg_chunks_col1[3]);
self.view.render(
super::COMPONENT_RADIO_PROMPT_ON_FILE_REPLACE,
f,
ui_cfg_chunks[4],
ui_cfg_chunks_col1[4],
);
self.view
.render(super::COMPONENT_RADIO_GROUP_DIRS, f, ui_cfg_chunks[5]);
self.view
.render(super::COMPONENT_INPUT_LOCAL_FILE_FMT, f, ui_cfg_chunks[6]);
self.view
.render(super::COMPONENT_INPUT_REMOTE_FILE_FMT, f, ui_cfg_chunks[7]);
.render(super::COMPONENT_RADIO_GROUP_DIRS, f, ui_cfg_chunks_col1[5]);
// Column 2
let ui_cfg_chunks_col2 = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3), // Local Format input
Constraint::Length(3), // Remote Format input
Constraint::Length(3), // Notifications enabled
Constraint::Length(3), // Notifications threshold
Constraint::Length(1), // Filler
]
.as_ref(),
)
.split(ui_cfg_chunks[1]);
self.view.render(
super::COMPONENT_INPUT_LOCAL_FILE_FMT,
f,
ui_cfg_chunks_col2[0],
);
self.view.render(
super::COMPONENT_INPUT_REMOTE_FILE_FMT,
f,
ui_cfg_chunks_col2[1],
);
self.view.render(
super::COMPONENT_RADIO_NOTIFICATIONS_ENABLED,
f,
ui_cfg_chunks_col2[2],
);
self.view.render(
super::COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD,
f,
ui_cfg_chunks_col2[3],
);
// Popups
if let Some(props) = self.view.get_props(super::COMPONENT_TEXT_ERROR) {
if props.visible {
@@ -341,6 +404,31 @@ impl SetupActivity {
.view
.update(super::COMPONENT_INPUT_REMOTE_FILE_FMT, props);
}
// Notifications enabled
if let Some(props) = self
.view
.get_props(super::COMPONENT_RADIO_NOTIFICATIONS_ENABLED)
{
let enabled: usize = match self.config().get_notifications() {
true => 0,
false => 1,
};
let props = RadioPropsBuilder::from(props).with_value(enabled).build();
let _ = self
.view
.update(super::COMPONENT_RADIO_NOTIFICATIONS_ENABLED, props);
}
// Notifications threshold
if let Some(props) = self
.view
.get_props(super::COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD)
{
let value: u64 = self.config().get_notification_threshold();
let props = BytesPropsBuilder::from(props).with_value(value).build();
let _ = self
.view
.update(super::COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD, props);
}
}
/// ### collect_input_values
@@ -404,5 +492,17 @@ impl SetupActivity {
};
self.config_mut().set_group_dirs(dirs);
}
if let Some(Payload::One(Value::Usize(opt))) = self
.view
.get_state(super::COMPONENT_RADIO_NOTIFICATIONS_ENABLED)
{
self.config_mut().set_notifications(opt == 0);
}
if let Some(Payload::One(Value::U64(bytes))) = self
.view
.get_state(super::COMPONENT_INPUT_NOTIFICATIONS_THRESHOLD)
{
self.config_mut().set_notification_threshold(bytes);
}
}
}

310
src/ui/components/bytes.rs Normal file
View File

@@ -0,0 +1,310 @@
//! ## Bytes
//!
//! `Bytes` component extends an `Input` component in order to provide an input type for byte size.
/**
* MIT License
*
* termscp - Copyright (c) 2021 Christian Visintin
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// locals
use crate::utils::fmt::fmt_bytes;
use crate::utils::parser::parse_bytesize;
// ext
use tui_realm_stdlib::{Input, InputPropsBuilder};
use tuirealm::event::Event;
use tuirealm::props::{Alignment, Props, PropsBuilder};
use tuirealm::tui::{
layout::Rect,
style::Color,
widgets::{BorderType, Borders},
};
use tuirealm::{Component, Frame, Msg, Payload, Value};
// -- props
/// ## BytesPropsBuilder
///
/// A wrapper around an `InputPropsBuilder`
pub struct BytesPropsBuilder {
puppet: InputPropsBuilder,
}
impl Default for BytesPropsBuilder {
fn default() -> Self {
Self {
puppet: InputPropsBuilder::default(),
}
}
}
impl PropsBuilder for BytesPropsBuilder {
fn build(&mut self) -> Props {
self.puppet.build()
}
fn hidden(&mut self) -> &mut Self {
self.puppet.hidden();
self
}
fn visible(&mut self) -> &mut Self {
self.puppet.visible();
self
}
}
impl From<Props> for BytesPropsBuilder {
fn from(props: Props) -> Self {
BytesPropsBuilder {
puppet: InputPropsBuilder::from(props),
}
}
}
impl BytesPropsBuilder {
/// ### with_borders
///
/// Set component borders style
pub fn with_borders(
&mut self,
borders: Borders,
variant: BorderType,
color: Color,
) -> &mut Self {
self.puppet.with_borders(borders, variant, color);
self
}
/// ### with_label
///
/// Set input label
pub fn with_label<S: AsRef<str>>(&mut self, label: S, alignment: Alignment) -> &mut Self {
self.puppet.with_label(label, alignment);
self
}
/// ### with_color
///
/// Set initial value for component
pub fn with_foreground(&mut self, color: Color) -> &mut Self {
self.puppet.with_foreground(color);
self
}
/// ### with_color
///
/// Set initial value for component
pub fn with_value(&mut self, val: u64) -> &mut Self {
self.puppet.with_value(fmt_bytes(val));
self
}
}
// -- component
/// ## Bytes
///
/// a wrapper component of `Input` which adds a superset of rules to behave as a color picker
pub struct Bytes {
input: Input,
native_color: Color,
}
impl Bytes {
/// ### new
///
/// Instantiate a new `Bytes`
pub fn new(props: Props) -> Self {
// Instantiate a new color picker using input
Self {
native_color: props.foreground,
input: Input::new(props),
}
}
/// ### update_colors
///
/// Update colors to match selected color, with provided one
fn update_colors(&mut self, color: Color) {
let mut props = self.get_props();
props.foreground = color;
props.borders.color = color;
let _ = self.input.update(props);
}
}
impl Component for Bytes {
/// ### render
///
/// Based on the current properties and states, renders a widget using the provided render engine in the provided Area
/// If focused, cursor is also set (if supported by widget)
#[cfg(not(tarpaulin_include))]
fn render(&self, render: &mut Frame, area: Rect) {
self.input.render(render, area);
}
/// ### update
///
/// Update component properties
/// Properties should first be retrieved through `get_props` which creates a builder from
/// existing properties and then edited before calling update.
/// Returns a Msg to the view
fn update(&mut self, props: Props) -> Msg {
let msg: Msg = self.input.update(props);
match msg {
Msg::OnChange(Payload::One(Value::Str(input))) => {
match parse_bytesize(input.as_str()) {
Some(bytes) => {
// return OK
self.update_colors(self.native_color);
Msg::OnChange(Payload::One(Value::U64(bytes.as_u64())))
}
None => {
// Invalid color
self.update_colors(Color::Red);
Msg::None
}
}
}
msg => msg,
}
}
/// ### get_props
///
/// Returns a props builder starting from component properties.
/// This returns a prop builder in order to make easier to create
/// new properties for the element.
fn get_props(&self) -> Props {
self.input.get_props()
}
/// ### on
///
/// Handle input event and update internal states.
/// Returns a Msg to the view
fn on(&mut self, ev: Event) -> Msg {
// Capture message from input
match self.input.on(ev) {
Msg::OnChange(Payload::One(Value::Str(input))) => {
// Capture color and validate
match parse_bytesize(input.as_str()) {
Some(bytes) => {
// Update color and return OK
self.update_colors(self.native_color);
Msg::OnChange(Payload::One(Value::U64(bytes.as_u64())))
}
None => {
// Invalid color
self.update_colors(Color::Red);
Msg::None
}
}
}
Msg::OnSubmit(_) => Msg::None,
msg => msg,
}
}
/// ### get_state
///
/// Get current state from component
/// For this component returns Unsigned if the input type is a number, otherwise a text
/// The value is always the current input.
fn get_state(&self) -> Payload {
match self.input.get_state() {
Payload::One(Value::Str(bytes)) => match parse_bytesize(bytes.as_str()) {
None => Payload::None,
Some(bytes) => Payload::One(Value::U64(bytes.as_u64())),
},
_ => Payload::None,
}
}
// -- events
/// ### blur
///
/// Blur component; basically remove focus
fn blur(&mut self) {
self.input.blur();
}
/// ### active
///
/// Active component; basically give focus
fn active(&mut self) {
self.input.active();
}
}
#[cfg(test)]
mod test {
use super::*;
use crossterm::event::{KeyCode, KeyEvent};
use pretty_assertions::assert_eq;
#[test]
fn bytes_input() {
let mut component: Bytes = Bytes::new(
BytesPropsBuilder::default()
.visible()
.with_value(1024)
.with_borders(Borders::ALL, BorderType::Double, Color::Rgb(204, 170, 0))
.with_label("omar", Alignment::Left)
.with_foreground(Color::Red)
.build(),
);
// Focus
component.blur();
component.active();
// Get value
assert_eq!(component.get_state(), Payload::One(Value::U64(1024)));
// Set an invalid color
let props = InputPropsBuilder::from(component.get_props())
.with_value(String::from("#pippo1"))
.hidden()
.build();
assert_eq!(component.update(props), Msg::None);
assert_eq!(component.get_state(), Payload::None);
// Reset color
let props = BytesPropsBuilder::from(component.get_props())
.with_value(111)
.hidden()
.build();
assert_eq!(
component.update(props),
Msg::OnChange(Payload::One(Value::U64(111)))
);
// Backspace (invalid)
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Backspace))),
Msg::None
);
// Press '1'
assert_eq!(
component.on(Event::Key(KeyEvent::from(KeyCode::Char('B')))),
Msg::OnChange(Payload::One(Value::U64(111)))
);
}
}

View File

@@ -27,6 +27,7 @@
*/
// exports
pub mod bookmark_list;
pub mod bytes;
pub mod color_picker;
pub mod file_list;
pub mod logbox;