#![warn(missing_docs)]
mod app_state;
mod arg_state;
mod child_app;
mod error;
pub mod output;
mod settings;
use app_state::AppState;
use child_app::{ChildApp, StdinType};
use clap::{App, ArgMatches, FromArgMatches, IntoApp};
use eframe::{
egui::{
self, style::Spacing, Button, Color32, CtxRef, FontDefinitions, Grid, Style, TextEdit, Ui,
},
epi,
};
use error::ExecutionError;
use native_dialog::FileDialog;
use output::Output;
pub use settings::Settings;
use std::{borrow::Cow, hash::Hash};
const CHILD_APP_ENV_VAR: &str = "KLASK_CHILD_APP";
pub fn run_app(app: App<'static>, settings: Settings, f: impl FnOnce(&ArgMatches)) {
if std::env::var(CHILD_APP_ENV_VAR).is_ok() {
std::env::remove_var(CHILD_APP_ENV_VAR);
let matches = app
.try_get_matches()
.expect("Internal error, arguments should've been verified by the GUI app");
f(&matches)
} else {
let app = app.setting(clap::AppSettings::NoBinaryName);
let klask = Klask {
state: AppState::new(&app),
tab: Tab::Arguments,
env: settings.enable_env.map(|desc| (desc, vec![])),
stdin: settings
.enable_stdin
.map(|desc| (desc, StdinType::Text(String::new()))),
working_dir: settings
.enable_working_dir
.map(|desc| (desc, String::new())),
output: Output::None,
app,
custom_font: settings.custom_font.map(Cow::from),
};
let native_options = eframe::NativeOptions::default();
eframe::run_native(Box::new(klask), native_options);
}
}
pub fn run_derived<C, F>(settings: Settings, f: F)
where
C: IntoApp + FromArgMatches,
F: FnOnce(C),
{
run_app(C::into_app(), settings, |m| {
let matches = C::from_arg_matches(m)
.expect("Internal error, C::from_arg_matches should always succeed");
f(matches);
});
}
#[derive(Debug)]
struct Klask {
state: AppState,
tab: Tab,
env: Option<(String, Vec<(String, String)>)>,
stdin: Option<(String, StdinType)>,
working_dir: Option<(String, String)>,
output: Output,
app: App<'static>,
custom_font: Option<Cow<'static, [u8]>>,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
enum Tab {
Arguments,
Env,
Stdin,
}
impl epi::App for Klask {
fn name(&self) -> &str {
self.app.get_name()
}
fn update(&mut self, ctx: &CtxRef, _frame: &mut epi::Frame<'_>) {
egui::CentralPanel::default().show(ctx, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
let tab_count = 1
+ if self.env.is_some() { 1 } else { 0 }
+ if self.stdin.is_some() { 1 } else { 0 };
if tab_count > 1 {
ui.columns(tab_count, |ui| {
let mut index = 0;
ui[index].selectable_value(&mut self.tab, Tab::Arguments, "Arguments");
index += 1;
if self.env.is_some() {
ui[index].selectable_value(
&mut self.tab,
Tab::Env,
"Environment variables",
);
index += 1;
}
if self.stdin.is_some() {
ui[index].selectable_value(&mut self.tab, Tab::Stdin, "Input");
}
});
ui.separator();
}
match self.tab {
Tab::Arguments => {
ui.add(&mut self.state);
if let Some((ref desc, path)) = &mut self.working_dir {
if !desc.is_empty() {
ui.label(desc);
}
ui.horizontal(|ui| {
if ui.button("Select directory...").clicked() {
if let Some(file) =
FileDialog::new().show_open_single_dir().ok().flatten()
{
*path = file.to_string_lossy().into_owned();
}
}
ui.add(TextEdit::singleline(path).hint_text("Working directory"))
});
ui.add_space(10.0);
}
}
Tab::Env => self.update_env(ui),
Tab::Stdin => self.update_stdin(ui),
}
ui.horizontal(|ui| {
if ui
.add_enabled(!self.is_child_running(), Button::new("Run!"))
.clicked()
{
match self.try_start_execution() {
Ok(child) => {
self.state.update_validation_error("", "");
self.output = Output::new_with_child(child);
}
Err(err) => {
if let ExecutionError::ValidationError { name, message } = &err {
self.state.update_validation_error(name, message);
}
self.output = Output::Err(err);
}
}
}
if self.is_child_running() && ui.button("Kill").clicked() {
self.kill_child();
}
if self.is_child_running() {
let mut running_text = String::from("Running");
for _ in 0..((2.0 * ui.input().time) as i32 % 4) {
running_text.push('.')
}
ui.label(running_text);
}
});
ui.add(&mut self.output);
});
});
}
fn setup(&mut self, ctx: &CtxRef, _: &mut epi::Frame<'_>, _: Option<&dyn epi::Storage>) {
ctx.set_style(Klask::klask_style());
if let Some(custom_font) = self.custom_font.take() {
let mut fonts = FontDefinitions::default();
fonts
.font_data
.insert(String::from("custom_font"), custom_font);
fonts
.fonts_for_family
.get_mut(&egui::FontFamily::Proportional)
.expect("fonts_for_family should include FontFamily::Proportional")
.insert(0, String::from("custom_font"));
fonts
.fonts_for_family
.get_mut(&egui::FontFamily::Monospace)
.expect("fonts_for_family should include FontFamily::Monospace")
.push(String::from("custom_font"));
ctx.set_fonts(fonts);
}
}
}
impl Klask {
fn try_start_execution(&mut self) -> Result<ChildApp, ExecutionError> {
let args = self.state.get_cmd_args(vec![])?;
self.app.try_get_matches_from_mut(args.iter())?;
if self
.env
.as_ref()
.and_then(|(_, v)| v.iter().find(|(key, _)| key.is_empty()))
.is_some()
{
return Err("Environment variable can't be empty".into());
}
ChildApp::run(
args,
self.env.clone().map(|(_, env)| env),
self.stdin.clone().map(|(_, stdin)| stdin),
self.working_dir.clone().map(|(_, dir)| dir),
)
}
fn kill_child(&mut self) {
if let Output::Output(child, _) = &mut self.output {
child.kill();
}
}
fn is_child_running(&self) -> bool {
match &self.output {
Output::Output(child, _) => child.is_running(),
_ => false,
}
}
fn update_env(&mut self, ui: &mut Ui) {
let (ref desc, env) = self.env.as_mut().unwrap();
if !desc.is_empty() {
ui.label(desc);
}
if !env.is_empty() {
let mut remove_index = None;
Grid::new(Tab::Env)
.striped(true)
.min_col_width(ui.available_width() / 3.0)
.num_columns(2)
.show(ui, |ui| {
for (index, (key, value)) in env.iter_mut().enumerate() {
ui.horizontal(|ui| {
if ui.small_button("-").clicked() {
remove_index = Some(index);
}
if key.is_empty() {
ui.set_style(Klask::error_style());
}
ui.text_edit_singleline(key);
if key.is_empty() {
ui.set_style(Klask::klask_style());
}
});
ui.horizontal(|ui| {
ui.label("=");
ui.text_edit_singleline(value);
});
ui.end_row();
}
});
if let Some(remove_index) = remove_index {
env.remove(remove_index);
}
}
if ui.button("New").clicked() {
env.push(Default::default());
}
ui.separator();
}
fn update_stdin(&mut self, ui: &mut Ui) {
let (ref desc, stdin) = self.stdin.as_mut().unwrap();
if !desc.is_empty() {
ui.label(desc);
}
ui.columns(2, |ui| {
if ui[0]
.selectable_label(matches!(stdin, StdinType::Text(_)), "Text")
.clicked()
&& matches!(stdin, StdinType::File(_))
{
*stdin = StdinType::Text(String::new());
}
if ui[1]
.selectable_label(matches!(stdin, StdinType::File(_)), "File")
.clicked()
&& matches!(stdin, StdinType::Text(_))
{
*stdin = StdinType::File(String::new());
}
});
match stdin {
StdinType::File(path) => {
ui.horizontal(|ui| {
if ui.button("Select file...").clicked() {
if let Some(file) = FileDialog::new().show_open_single_file().ok().flatten()
{
*path = file.to_string_lossy().into_owned();
}
}
ui.text_edit_singleline(path);
});
}
StdinType::Text(text) => {
ui.text_edit_multiline(text);
}
};
}
fn klask_style() -> Style {
Style {
spacing: Spacing {
text_edit_width: f32::MAX,
item_spacing: egui::vec2(8.0, 8.0),
..Default::default()
},
..Default::default()
}
}
fn error_style() -> Style {
let mut style = Self::klask_style();
style.visuals.widgets.inactive.bg_stroke.color = Color32::RED;
style.visuals.widgets.inactive.bg_stroke.width = 1.0;
style.visuals.widgets.hovered.bg_stroke.color = Color32::RED;
style.visuals.widgets.active.bg_stroke.color = Color32::RED;
style.visuals.widgets.open.bg_stroke.color = Color32::RED;
style.visuals.widgets.noninteractive.bg_stroke.color = Color32::RED;
style.visuals.selection.stroke.color = Color32::RED;
style
}
}