diff --git a/espanso-modulo/Cargo.toml b/espanso-modulo/Cargo.toml new file mode 100644 index 0000000..3e3da60 --- /dev/null +++ b/espanso-modulo/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "espanso-modulo" +version = "0.1.0" +authors = ["Federico Terzi "] +edition = "2018" +build="build.rs" + +[dependencies] +log = "0.4.14" +serde_json = "1.0.61" +serde = { version = "1.0.123", features = ["derive"] } +anyhow = "1.0.38" +thiserror = "1.0.23" +lazy_static = "1.4.0" +regex = "1.4.3" + +[build-dependencies] +cc = "1.0.66" +regex = "1.4.3" +zip = "0.5.12" +winres = "0.1.11" \ No newline at end of file diff --git a/espanso-modulo/build.rs b/espanso-modulo/build.rs new file mode 100644 index 0000000..bdd3606 --- /dev/null +++ b/espanso-modulo/build.rs @@ -0,0 +1,322 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +use std::path::{PathBuf}; + +#[cfg(not(target_os = "windows"))] +use std::path::{Path}; + +const WX_WIDGETS_ARCHIVE_NAME: &str = "wxWidgets-3.1.5.zip"; + +#[cfg(target_os = "windows")] +fn build_native() { + use std::process::Command; + + let project_dir = + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("missing CARGO_MANIFEST_DIR")); + let wx_archive = project_dir.join("vendor").join(WX_WIDGETS_ARCHIVE_NAME); + if !wx_archive.is_file() { + panic!("could not find wxWidgets archive!"); + } + + let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("missing OUT_DIR")); + let out_wx_dir = out_dir.join("wx"); + + if !out_wx_dir.is_dir() { + // Extract the wxWidgets archive + let wx_archive = + std::fs::File::open(&wx_archive).expect("unable to open wxWidgets source archive"); + let mut archive = zip::ZipArchive::new(wx_archive).expect("unable to read wxWidgets archive"); + archive + .extract(&out_wx_dir) + .expect("unable to extract wxWidgets source dir"); + + // Compile wxWidgets + let tool = cc::windows_registry::find_tool("msvc", "msbuild") + .expect("unable to locate MSVC compiler, did you install Visual Studio?"); + let mut vcvars_path = None; + let mut current_root = tool.path(); + while let Some(parent) = current_root.parent() { + let target = parent + .join("VC") + .join("Auxiliary") + .join("Build") + .join("vcvars64.bat"); + if target.exists() { + vcvars_path = Some(target); + break; + } + current_root = parent; + } + + let vcvars_path = vcvars_path.expect("unable to find vcvars64.bat file"); + let mut handle = Command::new("cmd") + .current_dir( + out_wx_dir + .join("build") + .join("msw") + .to_string_lossy() + .to_string(), + ) + .args(&[ + "/k", + &vcvars_path.to_string_lossy().to_string(), + "&", + "nmake", + "/f", + "makefile.vc", + "BUILD=release", + "TARGET_CPU=X64", + "&", + "exit", + ]) + .spawn() + .expect("failed to execute nmake"); + handle.wait().expect("unable to wait for nmake command"); + } + + // Make sure wxWidgets is compiled + if !out_wx_dir + .join("build") + .join("msw") + .join("vc_mswu_x64") + .is_dir() + { + panic!("wxWidgets is not compiled correctly, missing 'build/msw/vc_mswu_x64' directory") + } + + let wx_include_dir = out_wx_dir.join("include"); + let wx_include_msvc_dir = wx_include_dir.join("msvc"); + let wx_lib_dir = out_wx_dir.join("lib").join("vc_x64_lib"); + + cc::Build::new() + .cpp(true) + .file("src/sys/form/form.cpp") + .file("src/sys/search/search.cpp") + .file("src/sys/common/common.cpp") + .flag("/EHsc") + .include(wx_include_dir) + .include(wx_include_msvc_dir) + .compile("espansomodulosys"); + + // Add resources (manifest) + let mut resources = winres::WindowsResource::new(); + resources.set_manifest(include_str!("res/win.manifest")); + resources + .compile() + .expect("unable to compile resource file"); + + println!( + "cargo:rustc-link-search=native={}", + wx_lib_dir.to_string_lossy() + ); +} + +// TODO: add documentation for macos +// Install LLVM: +// brew install llvm +// Compile wxWidgets: +// mkdir build-cocoa +// cd build-cocoa +// ../configure --disable-shared --enable-macosx_arch=x86_64 +// make -j6 +// +// Run +// WXMAC=/Users/freddy/wxWidgets cargo run +#[cfg(target_os = "macos")] +fn build_native() { + let wx_location = std::env::var("WXMAC").expect( + "unable to find wxWidgets directory, please add a WXMAC env variable with the absolute path", + ); + let wx_path = PathBuf::from(&wx_location); + println!("{}", wx_location); + if !wx_path.is_dir() { + panic!("The given WXMAC directory is not valid"); + } + + // Make sure wxWidgets is compiled + if !wx_path.join("build-cocoa").is_dir() { + panic!("wxWidgets is not compiled correctly, missing 'build-cocoa/' directory") + } + + let config_path = wx_path.join("build-cocoa").join("wx-config"); + let cpp_flags = get_cpp_flags(&config_path); + + let mut build = cc::Build::new(); + build + .cpp(true) + .file("native/form.cpp") + .file("native/common.cpp") + .file("native/search.cpp") + .file("native/mac.mm"); + build.flag("-std=c++17"); + + for flag in cpp_flags { + build.flag(&flag); + } + + build.compile("modulosys"); + + // Render linker flags + + generate_linker_flags(&config_path); + + // On (older) OSX we need to link against the clang runtime, + // which is hidden in some non-default path. + // + // More details at https://github.com/alexcrichton/curl-rust/issues/279. + if let Some(path) = macos_link_search_path() { + println!("cargo:rustc-link-lib=clang_rt.osx"); + println!("cargo:rustc-link-search={}", path); + } +} + +#[cfg(not(target_os = "windows"))] +fn get_cpp_flags(wx_config_path: &Path) -> Vec { + let config_output = std::process::Command::new(&wx_config_path) + .arg("--cxxflags") + .output() + .expect("unable to execute wx-config"); + let config_libs = + String::from_utf8(config_output.stdout).expect("unable to parse wx-config output"); + let cpp_flags: Vec = config_libs + .split(" ") + .into_iter() + .filter_map(|s| { + if !s.trim().is_empty() { + Some(s.trim().to_owned()) + } else { + None + } + }) + .collect(); + cpp_flags +} + +#[cfg(not(target_os = "windows"))] +fn generate_linker_flags(wx_config_path: &Path) { + use regex::Regex; + let config_output = std::process::Command::new(&wx_config_path) + .arg("--libs") + .output() + .expect("unable to execute wx-config libs"); + let config_libs = + String::from_utf8(config_output.stdout).expect("unable to parse wx-config libs output"); + let linker_flags: Vec = config_libs + .split(" ") + .into_iter() + .filter_map(|s| { + if !s.trim().is_empty() { + Some(s.trim().to_owned()) + } else { + None + } + }) + .collect(); + + let static_lib_extract = Regex::new(r"lib/lib(.*)\.a").unwrap(); + + // Translate the flags generated by `wx-config` to commands + // that cargo can understand. + for (i, flag) in linker_flags.iter().enumerate() { + if flag.starts_with("-L") { + let path = flag.trim_start_matches("-L"); + println!("cargo:rustc-link-search=native={}", path); + } else if flag.starts_with("-framework") { + println!("cargo:rustc-link-lib=framework={}", linker_flags[i + 1]); + } else if flag.starts_with("/") { + let captures = static_lib_extract + .captures(flag) + .expect("unable to capture flag regex"); + let libname = captures.get(1).expect("unable to find static libname"); + println!("cargo:rustc-link-lib=static={}", libname.as_str()); + } else if flag.starts_with("-l") { + let libname = flag.trim_start_matches("-l"); + println!("cargo:rustc-link-lib=dylib={}", libname); + } + } +} + +// Taken from curl-rust: https://github.com/alexcrichton/curl-rust/pull/283/files +#[cfg(target_os = "macos")] +fn macos_link_search_path() -> Option { + let output = std::process::Command::new("clang") + .arg("--print-search-dirs") + .output() + .ok()?; + if !output.status.success() { + println!("failed to run 'clang --print-search-dirs', continuing without a link search path"); + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.contains("libraries: =") { + let path = line.split('=').skip(1).next()?; + return Some(format!("{}/lib/darwin", path)); + } + } + + println!("failed to determine link search path, continuing without it"); + None +} + +// TODO: add documentation for linux +// Install LLVM: +// sudo apt install clang +// Install wxWidgets: +// sudo apt install libwxgtk3.0-0v5 libwxgtk3.0-dev +// +// cargo run +#[cfg(target_os = "linux")] +fn build_native() { + // Make sure wxWidgets is installed + if !std::process::Command::new("wx-config") + .arg("--version") + .output() + .is_ok() + { + panic!("wxWidgets is not installed, as `wx-config` cannot be exectued") + } + + let config_path = PathBuf::from("wx-config"); + let cpp_flags = get_cpp_flags(&config_path); + + let mut build = cc::Build::new(); + build + .cpp(true) + .file("native/form.cpp") + .file("native/search.cpp") + .file("native/common.cpp"); + build.flag("-std=c++17"); + + for flag in cpp_flags { + build.flag(&flag); + } + + build.compile("modulosys"); + + // Render linker flags + + generate_linker_flags(&config_path); +} + +fn main() { + build_native(); +} diff --git a/espanso-modulo/res/win.manifest b/espanso-modulo/res/win.manifest new file mode 100644 index 0000000..88d7e2a --- /dev/null +++ b/espanso-modulo/res/win.manifest @@ -0,0 +1,10 @@ + + + + TODO + + + + + + \ No newline at end of file diff --git a/espanso-modulo/src/form/config.rs b/espanso-modulo/src/form/config.rs new file mode 100644 index 0000000..236137a --- /dev/null +++ b/espanso-modulo/src/form/config.rs @@ -0,0 +1,202 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::HashMap; + +fn default_title() -> String { + "espanso".to_owned() +} + +fn default_icon() -> Option { + None +} + +fn default_fields() -> HashMap { + HashMap::new() +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct FormConfig { + #[serde(default = "default_title")] + pub title: String, + + #[serde(default = "default_icon")] + pub icon: Option, + + pub layout: String, + + #[serde(default = "default_fields")] + pub fields: HashMap, +} + +#[derive(Debug, Serialize, Clone)] +pub struct FieldConfig { + pub field_type: FieldTypeConfig, +} + +impl Default for FieldConfig { + fn default() -> Self { + Self { + field_type: FieldTypeConfig::Text(TextFieldConfig { + ..Default::default() + }), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub enum FieldTypeConfig { + Text(TextFieldConfig), + Choice(ChoiceFieldConfig), + List(ListFieldConfig), +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct TextFieldConfig { + pub default: String, + pub multiline: bool, +} + +impl Default for TextFieldConfig { + fn default() -> Self { + Self { + default: "".to_owned(), + multiline: false, + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ChoiceFieldConfig { + pub values: Vec, + pub default: String, +} + +impl Default for ChoiceFieldConfig { + fn default() -> Self { + Self { + values: Vec::new(), + default: "".to_owned(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ListFieldConfig { + pub values: Vec, + pub default: String, +} + +impl Default for ListFieldConfig { + fn default() -> Self { + Self { + values: Vec::new(), + default: "".to_owned(), + } + } +} + +impl<'de> serde::Deserialize<'de> for FieldConfig { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let auto_field = AutoFieldConfig::deserialize(deserializer)?; + Ok(FieldConfig::from(&auto_field)) + } +} + +impl<'a> From<&'a AutoFieldConfig> for FieldConfig { + fn from(other: &'a AutoFieldConfig) -> Self { + let field_type = match other.field_type.as_ref() { + "text" => { + let mut config: TextFieldConfig = Default::default(); + + if let Some(default) = &other.default { + config.default = default.clone(); + } + + config.multiline = other.multiline; + + FieldTypeConfig::Text(config) + } + "choice" => { + let mut config = ChoiceFieldConfig { + values: other.values.clone(), + ..Default::default() + }; + + if let Some(default) = &other.default { + config.default = default.clone(); + } + + FieldTypeConfig::Choice(config) + } + "list" => { + let mut config = ListFieldConfig { + values: other.values.clone(), + ..Default::default() + }; + + if let Some(default) = &other.default { + config.default = default.clone(); + } + + FieldTypeConfig::List(config) + } + _ => { + panic!("invalid field type: {}", other.field_type); + } + }; + + Self { field_type } + } +} + +fn default_type() -> String { + "text".to_owned() +} + +fn default_default() -> Option { + None +} + +fn default_multiline() -> bool { + false +} + +fn default_values() -> Vec { + Vec::new() +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AutoFieldConfig { + #[serde(rename = "type", default = "default_type")] + pub field_type: String, + + #[serde(default = "default_default")] + pub default: Option, + + #[serde(default = "default_multiline")] + pub multiline: bool, + + #[serde(default = "default_values")] + pub values: Vec, +} diff --git a/espanso-modulo/src/form/generator.rs b/espanso-modulo/src/form/generator.rs new file mode 100644 index 0000000..fb13e5e --- /dev/null +++ b/espanso-modulo/src/form/generator.rs @@ -0,0 +1,99 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +use super::config::{FieldConfig, FieldTypeConfig, FormConfig}; +use super::parser::layout::Token; +use crate::sys::form::types::*; +use std::collections::HashMap; + +pub fn generate(config: FormConfig) -> Form { + let structure = super::parser::layout::parse_layout(&config.layout); + build_form(config, structure) +} + +fn create_field(token: &Token, field_map: &HashMap) -> Field { + match token { + Token::Text(text) => Field { + field_type: FieldType::Label(LabelMetadata { text: text.clone() }), + ..Default::default() + }, + Token::Field(name) => { + let config = if let Some(config) = field_map.get(name) { + config.clone() + } else { + Default::default() + }; + + let field_type = match &config.field_type { + FieldTypeConfig::Text(config) => FieldType::Text(TextMetadata { + default_text: config.default.clone(), + multiline: config.multiline, + }), + FieldTypeConfig::Choice(config) => FieldType::Choice(ChoiceMetadata { + values: config.values.clone(), + choice_type: ChoiceType::Dropdown, + default_value: config.default.clone(), + }), + FieldTypeConfig::List(config) => FieldType::Choice(ChoiceMetadata { + values: config.values.clone(), + choice_type: ChoiceType::List, + default_value: config.default.clone(), + }), + }; + + Field { + id: Some(name.clone()), + field_type, + } + } + } +} + +fn build_form(form: FormConfig, structure: Vec>) -> Form { + let field_map = form.fields; + let mut fields = Vec::new(); + + for row in structure.iter() { + let current_field = if row.len() == 1 { + // Single field + create_field(&row[0], &field_map) + } else { + // Row field + let inner_fields = row + .iter() + .map(|token| create_field(token, &field_map)) + .collect(); + + Field { + field_type: FieldType::Row(RowMetadata { + fields: inner_fields, + }), + ..Default::default() + } + }; + + fields.push(current_field) + } + + Form { + title: form.title, + icon: form.icon, + fields, + } +} diff --git a/espanso-modulo/src/form/mod.rs b/espanso-modulo/src/form/mod.rs new file mode 100644 index 0000000..0e66678 --- /dev/null +++ b/espanso-modulo/src/form/mod.rs @@ -0,0 +1,24 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +pub mod config; +pub mod generator; +pub mod parser; + +pub use crate::sys::form::show; \ No newline at end of file diff --git a/espanso-modulo/src/form/parser/layout.rs b/espanso-modulo/src/form/parser/layout.rs new file mode 100644 index 0000000..0f77587 --- /dev/null +++ b/espanso-modulo/src/form/parser/layout.rs @@ -0,0 +1,108 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +use super::split::*; +use regex::Regex; + +lazy_static! { + static ref FIELD_REGEX: Regex = Regex::new(r"\{\{(.*?)\}\}").unwrap(); +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Token { + Text(String), // Text + Field(String), // id: String +} + +pub fn parse_layout(layout: &str) -> Vec> { + let mut rows = Vec::new(); + + for line in layout.lines() { + let line = line.trim(); + + // Skip empty lines + if line.is_empty() { + continue; + } + + let mut row: Vec = Vec::new(); + + let splitter = SplitCaptures::new(&FIELD_REGEX, line); + + // Get the individual tokens + for state in splitter { + match state { + SplitState::Unmatched(text) => { + if !text.is_empty() { + row.push(Token::Text(text.to_owned())) + } + } + SplitState::Captured(caps) => { + if let Some(name) = caps.get(1) { + let name = name.as_str().to_owned(); + row.push(Token::Field(name)); + } + } + } + } + + rows.push(row); + } + + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_layout() { + let layout = "Hey {{name}},\nHow are you?\n \nCheers"; + let result = parse_layout(layout); + assert_eq!( + result, + vec![ + vec![ + Token::Text("Hey ".to_owned()), + Token::Field("name".to_owned()), + Token::Text(",".to_owned()) + ], + vec![Token::Text("How are you?".to_owned())], + vec![Token::Text("Cheers".to_owned())], + ] + ); + } + + #[test] + fn test_parse_layout_2() { + let layout = "Hey {{name}} {{surname}},"; + let result = parse_layout(layout); + assert_eq!( + result, + vec![vec![ + Token::Text("Hey ".to_owned()), + Token::Field("name".to_owned()), + Token::Text(" ".to_owned()), + Token::Field("surname".to_owned()), + Token::Text(",".to_owned()) + ],] + ); + } +} diff --git a/espanso-modulo/src/form/parser/mod.rs b/espanso-modulo/src/form/parser/mod.rs new file mode 100644 index 0000000..75c5493 --- /dev/null +++ b/espanso-modulo/src/form/parser/mod.rs @@ -0,0 +1,21 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +pub mod layout; +mod split; diff --git a/espanso-modulo/src/form/parser/split.rs b/espanso-modulo/src/form/parser/split.rs new file mode 100644 index 0000000..b510d6d --- /dev/null +++ b/espanso-modulo/src/form/parser/split.rs @@ -0,0 +1,54 @@ +// Taken from: https://github.com/rust-lang/regex/issues/330#issuecomment-274058261 +use regex::{CaptureMatches, Captures, Regex}; + +pub struct SplitCaptures<'r, 't> { + finder: CaptureMatches<'r, 't>, + text: &'t str, + last: usize, + caps: Option>, +} + +impl<'r, 't> SplitCaptures<'r, 't> { + pub fn new(re: &'r Regex, text: &'t str) -> SplitCaptures<'r, 't> { + SplitCaptures { + finder: re.captures_iter(text), + text, + last: 0, + caps: None, + } + } +} + +#[derive(Debug)] +pub enum SplitState<'t> { + Unmatched(&'t str), + Captured(Captures<'t>), +} + +impl<'r, 't> Iterator for SplitCaptures<'r, 't> { + type Item = SplitState<'t>; + + fn next(&mut self) -> Option> { + if let Some(caps) = self.caps.take() { + return Some(SplitState::Captured(caps)); + } + match self.finder.next() { + None => { + if self.last >= self.text.len() { + None + } else { + let s = &self.text[self.last..]; + self.last = self.text.len(); + Some(SplitState::Unmatched(s)) + } + } + Some(caps) => { + let m = caps.get(0).unwrap(); + let unmatched = &self.text[self.last..m.start()]; + self.last = m.end(); + self.caps = Some(caps); + Some(SplitState::Unmatched(unmatched)) + } + } + } +} diff --git a/espanso-modulo/src/lib.rs b/espanso-modulo/src/lib.rs new file mode 100644 index 0000000..377a605 --- /dev/null +++ b/espanso-modulo/src/lib.rs @@ -0,0 +1,25 @@ +/* + * This file is part of espanso. + * + * Copyright (C) 2019-2021 Federico Terzi + * + * espanso is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * espanso is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with espanso. If not, see . + */ + +#[macro_use] +extern crate lazy_static; + +pub mod form; +pub mod search; +mod sys; \ No newline at end of file diff --git a/espanso-modulo/src/search/algorithm.rs b/espanso-modulo/src/search/algorithm.rs new file mode 100644 index 0000000..65d4845 --- /dev/null +++ b/espanso-modulo/src/search/algorithm.rs @@ -0,0 +1,80 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +use crate::sys::search::types::SearchItem; + +pub fn get_algorithm(name: &str) -> Box Vec> { + match name { + "exact" => Box::new(exact_match), + "iexact" => Box::new(case_insensitive_exact_match), + "ikey" => Box::new(case_insensitive_keyword), + _ => panic!("unknown search algorithm: {}", name), + } +} + +fn exact_match(query: &str, items: &[SearchItem]) -> Vec { + items + .iter() + .enumerate() + .filter(|(_, item)| { + item.label.contains(query) || item.trigger.as_deref().map_or(false, |t| t.contains(query)) + }) + .map(|(i, _)| i) + .collect() +} + +fn case_insensitive_exact_match(query: &str, items: &[SearchItem]) -> Vec { + let lowercase_query = query.to_lowercase(); + items + .iter() + .enumerate() + .filter(|(_, item)| { + item.label.to_lowercase().contains(&lowercase_query) + || item + .trigger + .as_deref() + .map_or(false, |t| t.to_lowercase().contains(query)) + }) + .map(|(i, _)| i) + .collect() +} + +fn case_insensitive_keyword(query: &str, items: &[SearchItem]) -> Vec { + let lowercase_query = query.to_lowercase(); + let keywords: Vec<&str> = lowercase_query.split_whitespace().collect(); + items + .iter() + .enumerate() + .filter(|(_, item)| { + for keyword in keywords.iter() { + if !item.label.to_lowercase().contains(keyword) + && !item + .trigger + .as_deref() + .map_or(false, |t| t.to_lowercase().contains(keyword)) + { + return false; + } + } + + true + }) + .map(|(i, _)| i) + .collect() +} diff --git a/espanso-modulo/src/search/config.rs b/espanso-modulo/src/search/config.rs new file mode 100644 index 0000000..3f32ce6 --- /dev/null +++ b/espanso-modulo/src/search/config.rs @@ -0,0 +1,58 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +use serde::{Deserialize, Serialize}; + +fn default_title() -> String { + "espanso".to_owned() +} + +fn default_icon() -> Option { + None +} + +fn default_items() -> Vec { + Vec::new() +} + +fn default_algorithm() -> String { + "ikey".to_owned() +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SearchConfig { + #[serde(default = "default_title")] + pub title: String, + + #[serde(default = "default_icon")] + pub icon: Option, + + #[serde(default = "default_items")] + pub items: Vec, + + #[serde(default = "default_algorithm")] + pub algorithm: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SearchItem { + pub id: String, + pub label: String, + pub trigger: Option, +} diff --git a/espanso-modulo/src/search/generator.rs b/espanso-modulo/src/search/generator.rs new file mode 100644 index 0000000..3a8ddf9 --- /dev/null +++ b/espanso-modulo/src/search/generator.rs @@ -0,0 +1,39 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +use crate::search::config::SearchConfig; +use crate::sys::search::types; + +pub fn generate(config: SearchConfig) -> types::Search { + let items = config + .items + .into_iter() + .map(|item| types::SearchItem { + id: item.id, + label: item.label, + trigger: item.trigger, + }) + .collect(); + + types::Search { + title: config.title, + items, + icon: config.icon, + } +} diff --git a/espanso-modulo/src/search/mod.rs b/espanso-modulo/src/search/mod.rs new file mode 100644 index 0000000..d1c4a9c --- /dev/null +++ b/espanso-modulo/src/search/mod.rs @@ -0,0 +1,24 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +pub mod algorithm; +pub mod config; +pub mod generator; + +pub use crate::sys::search::show; \ No newline at end of file diff --git a/espanso-modulo/src/sys/common/common.cpp b/espanso-modulo/src/sys/common/common.cpp new file mode 100644 index 0000000..53a4e16 --- /dev/null +++ b/espanso-modulo/src/sys/common/common.cpp @@ -0,0 +1,83 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +#include "common.h" + +#ifdef __WXMSW__ +#include +#endif +#ifdef __WXOSX__ +#include "mac.h" +#endif + +void setFrameIcon(const char * iconPath, wxFrame * frame) { + if (iconPath) { + wxString iconPath(iconPath); + wxBitmapType imgType = wxICON_DEFAULT_TYPE; + + #ifdef __WXMSW__ + imgType = wxBITMAP_TYPE_ICO; + #endif + + wxIcon icon; + icon.LoadFile(iconPath, imgType); + if (icon.IsOk()) { + frame->SetIcon(icon); + } + } +} + +void Activate(wxFrame * frame) { + #ifdef __WXMSW__ + + HWND handle = frame->GetHandle(); + if (handle == GetForegroundWindow()) { + return; + } + + if (IsIconic(handle)) { + ShowWindow(handle, 9); + } + + INPUT ip; + ip.type = INPUT_KEYBOARD; + ip.ki.wScan = 0; + ip.ki.time = 0; + ip.ki.dwExtraInfo = 0; + ip.ki.wVk = VK_MENU; + ip.ki.dwFlags = 0; + + SendInput(1, &ip, sizeof(INPUT)); + ip.ki.dwFlags = KEYEVENTF_KEYUP; + + SendInput(1, &ip, sizeof(INPUT)); + + SetForegroundWindow(handle); + + #endif + #ifdef __WXOSX__ + ActivateApp(); + #endif +} + +void SetupWindowStyle(wxFrame * frame) { + #ifdef __WXOSX__ + SetWindowStyles((NSWindow*) frame->MacGetTopLevelWindowRef()); + #endif +} \ No newline at end of file diff --git a/espanso-modulo/src/sys/common/common.h b/espanso-modulo/src/sys/common/common.h new file mode 100644 index 0000000..99b1d7a --- /dev/null +++ b/espanso-modulo/src/sys/common/common.h @@ -0,0 +1,36 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +#ifndef MODULO_COMMON +#define MODULO_COMMON + +#define _UNICODE + +#include +#ifndef WX_PRECOMP + #include +#endif + +void setFrameIcon(const char * iconPath, wxFrame * frame); + +void Activate(wxFrame * frame); + +void SetupWindowStyle(wxFrame * frame); + +#endif \ No newline at end of file diff --git a/espanso-modulo/src/sys/common/mac.h b/espanso-modulo/src/sys/common/mac.h new file mode 100644 index 0000000..4bdd981 --- /dev/null +++ b/espanso-modulo/src/sys/common/mac.h @@ -0,0 +1,21 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +void ActivateApp(); +void SetWindowStyles(NSWindow * window); \ No newline at end of file diff --git a/espanso-modulo/src/sys/common/mac.mm b/espanso-modulo/src/sys/common/mac.mm new file mode 100644 index 0000000..d08348b --- /dev/null +++ b/espanso-modulo/src/sys/common/mac.mm @@ -0,0 +1,30 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +#import + +void ActivateApp() { + [[NSRunningApplication currentApplication] activateWithOptions:(NSApplicationActivateAllWindows | NSApplicationActivateIgnoringOtherApps)]; +} + +void SetWindowStyles(NSWindow * window) { + window.titleVisibility = NSWindowTitleHidden; + window.styleMask &= ~NSWindowStyleMaskTitled; + window.movableByWindowBackground = true; +} \ No newline at end of file diff --git a/espanso-modulo/src/sys/form/form.cpp b/espanso-modulo/src/sys/form/form.cpp new file mode 100644 index 0000000..120c053 --- /dev/null +++ b/espanso-modulo/src/sys/form/form.cpp @@ -0,0 +1,289 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +#define _UNICODE + +#include "../common/common.h" +#include "../interop/interop.h" + +#include +#include +#include + +// https://docs.wxwidgets.org/stable/classwx_frame.html +const long DEFAULT_STYLE = wxSTAY_ON_TOP | wxCLOSE_BOX | wxCAPTION; + +const int PADDING = 5; +const int MULTILINE_MIN_HEIGHT = 100; +const int MULTILINE_MIN_WIDTH = 100; + +FormMetadata *formMetadata = nullptr; +std::vector values; + +// Field Wrappers + +class FieldWrapper { +public: + virtual wxString getValue() = 0; +}; + +class TextFieldWrapper { + wxTextCtrl * control; +public: + explicit TextFieldWrapper(wxTextCtrl * control): control(control) {} + + virtual wxString getValue() { + return control->GetValue(); + } +}; + +class ChoiceFieldWrapper { + wxChoice * control; +public: + explicit ChoiceFieldWrapper(wxChoice * control): control(control) {} + + virtual wxString getValue() { + return control->GetStringSelection(); + } +}; + +class ListFieldWrapper { + wxListBox * control; +public: + explicit ListFieldWrapper(wxListBox * control): control(control) {} + + virtual wxString getValue() { + return control->GetStringSelection(); + } +}; + +// App Code + +class FormApp: public wxApp +{ +public: + virtual bool OnInit(); +}; +class FormFrame: public wxFrame +{ +public: + FormFrame(const wxString& title, const wxPoint& pos, const wxSize& size); + + wxPanel *panel; + std::vector fields; + std::unordered_map> idMap; + wxButton *submit; +private: + void AddComponent(wxPanel *parent, wxBoxSizer *sizer, FieldMetadata meta); + void Submit(); + void OnSubmitBtn(wxCommandEvent& event); + void OnEscape(wxKeyEvent& event); +}; +enum +{ + ID_Submit = 20000 +}; + +bool FormApp::OnInit() +{ + FormFrame *frame = new FormFrame(formMetadata->windowTitle, wxPoint(50, 50), wxSize(450, 340) ); + setFrameIcon(formMetadata->iconPath, frame); + frame->Show( true ); + + Activate(frame); + + return true; +} +FormFrame::FormFrame(const wxString& title, const wxPoint& pos, const wxSize& size) + : wxFrame(NULL, wxID_ANY, title, pos, size, DEFAULT_STYLE) +{ + panel = new wxPanel(this, wxID_ANY); + wxBoxSizer *vbox = new wxBoxSizer(wxVERTICAL); + panel->SetSizer(vbox); + + for (int field = 0; field < formMetadata->fieldSize; field++) { + FieldMetadata meta = formMetadata->fields[field]; + AddComponent(panel, vbox, meta); + } + + submit = new wxButton(panel, ID_Submit, "Submit"); + vbox->Add(submit, 1, wxEXPAND | wxALL, PADDING); + + Bind(wxEVT_BUTTON, &FormFrame::OnSubmitBtn, this, ID_Submit); + Bind(wxEVT_CHAR_HOOK, &FormFrame::OnEscape, this, wxID_ANY); + // TODO: register ESC click handler: https://forums.wxwidgets.org/viewtopic.php?t=41926 + + this->SetClientSize(panel->GetBestSize()); + this->CentreOnScreen(); +} + +void FormFrame::AddComponent(wxPanel *parent, wxBoxSizer *sizer, FieldMetadata meta) { + void * control = nullptr; + + switch (meta.fieldType) { + case FieldType::LABEL: + { + const LabelMetadata *labelMeta = static_cast(meta.specific); + auto label = new wxStaticText(parent, wxID_ANY, wxString::FromUTF8(labelMeta->text), wxDefaultPosition, wxDefaultSize); + control = label; + fields.push_back(label); + break; + } + case FieldType::TEXT: + { + const TextMetadata *textMeta = static_cast(meta.specific); + long style = 0; + if (textMeta->multiline) { + style |= wxTE_MULTILINE; + } + + auto textControl = new wxTextCtrl(parent, NewControlId(), wxString::FromUTF8(textMeta->defaultText), wxDefaultPosition, wxDefaultSize, style); + + if (textMeta->multiline) { + textControl->SetMinSize(wxSize(MULTILINE_MIN_WIDTH, MULTILINE_MIN_HEIGHT)); + } + + // Create the field wrapper + std::unique_ptr field((FieldWrapper*) new TextFieldWrapper(textControl)); + idMap[meta.id] = std::move(field); + control = textControl; + fields.push_back(textControl); + break; + } + case FieldType::CHOICE: + { + const ChoiceMetadata *choiceMeta = static_cast(meta.specific); + + int selectedItem = -1; + wxArrayString choices; + for (int i = 0; ivalueSize; i++) { + choices.Add(wxString::FromUTF8(choiceMeta->values[i])); + + if (strcmp(choiceMeta->values[i], choiceMeta->defaultValue) == 0) { + selectedItem = i; + } + } + + void * choice = nullptr; + if (choiceMeta->choiceType == ChoiceType::DROPDOWN) { + choice = (void*) new wxChoice(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, choices); + + if (selectedItem >= 0) { + ((wxChoice*)choice)->SetSelection(selectedItem); + } + + // Create the field wrapper + std::unique_ptr field((FieldWrapper*) new ChoiceFieldWrapper((wxChoice*) choice)); + idMap[meta.id] = std::move(field); + }else { + choice = (void*) new wxListBox(parent, wxID_ANY, wxDefaultPosition, wxDefaultSize, choices); + + if (selectedItem >= 0) { + ((wxListBox*)choice)->SetSelection(selectedItem); + } + + // Create the field wrapper + std::unique_ptr field((FieldWrapper*) new ListFieldWrapper((wxListBox*) choice)); + idMap[meta.id] = std::move(field); + } + + + + control = choice; + fields.push_back(choice); + break; + } + case FieldType::ROW: + { + const RowMetadata *rowMeta = static_cast(meta.specific); + + auto innerPanel = new wxPanel(panel, wxID_ANY); + wxBoxSizer *hbox = new wxBoxSizer(wxHORIZONTAL); + innerPanel->SetSizer(hbox); + sizer->Add(innerPanel, 0, wxEXPAND | wxALL, 0); + fields.push_back(innerPanel); + + for (int field = 0; field < rowMeta->fieldSize; field++) { + FieldMetadata innerMeta = rowMeta->fields[field]; + AddComponent(innerPanel, hbox, innerMeta); + } + + break; + } + default: + // TODO: handle unknown field type + break; + } + + if (control) { + sizer->Add((wxWindow*) control, 0, wxEXPAND | wxALL, PADDING); + } +} + +void FormFrame::Submit() { + for (auto& field: idMap) { + FieldWrapper * fieldWrapper = (FieldWrapper*) field.second.get(); + wxString value {fieldWrapper->getValue()}; + wxCharBuffer buffer {value.ToUTF8()}; + char * id = strdup(field.first); + char * c_value = strdup(buffer.data()); + ValuePair valuePair = { + id, + c_value, + }; + values.push_back(valuePair); + } + + Close(true); +} + +void FormFrame::OnSubmitBtn(wxCommandEvent &event) { + Submit(); +} + +void FormFrame::OnEscape(wxKeyEvent& event) { + if (event.GetKeyCode() == WXK_ESCAPE) { + Close(true); + }else if(event.GetKeyCode() == WXK_RETURN && wxGetKeyState(WXK_RAW_CONTROL)) { + Submit(); + }else{ + event.Skip(); + } +} + +extern "C" void interop_show_form(FormMetadata * _metadata, void (*callback)(ValuePair *values, int size, void *data), void *data) { + // Setup high DPI support on Windows + #ifdef __WXMSW__ + SetProcessDPIAware(); + #endif + + formMetadata = _metadata; + + wxApp::SetInstance(new FormApp()); + int argc = 0; + wxEntry(argc, (char **)nullptr); + + callback(values.data(), values.size(), data); + + // Free up values + for (auto pair: values) { + free((void*) pair.id); + free((void*) pair.value); + } +} \ No newline at end of file diff --git a/espanso-modulo/src/sys/form/mod.rs b/espanso-modulo/src/sys/form/mod.rs new file mode 100644 index 0000000..f995c36 --- /dev/null +++ b/espanso-modulo/src/sys/form/mod.rs @@ -0,0 +1,386 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +use std::collections::HashMap; +use std::ffi::CStr; +use std::os::raw::c_int; + +// Form schema + +pub mod types { + #[derive(Debug)] + pub struct Form { + pub title: String, + pub icon: Option, + pub fields: Vec, + } + + #[derive(Debug)] + pub struct Field { + pub id: Option, + pub field_type: FieldType, + } + + impl Default for Field { + fn default() -> Self { + Self { + id: None, + field_type: FieldType::Unknown, + } + } + } + + #[derive(Debug)] + pub enum FieldType { + Unknown, + Row(RowMetadata), + Label(LabelMetadata), + Text(TextMetadata), + Choice(ChoiceMetadata), + } + + #[derive(Debug)] + pub struct RowMetadata { + pub fields: Vec, + } + + #[derive(Debug)] + pub struct LabelMetadata { + pub text: String, + } + + #[derive(Debug)] + pub struct TextMetadata { + pub default_text: String, + pub multiline: bool, + } + + #[derive(Debug)] + pub enum ChoiceType { + Dropdown, + List, + } + + #[derive(Debug)] + pub struct ChoiceMetadata { + pub values: Vec, + pub choice_type: ChoiceType, + pub default_value: String, + } +} + +// Form interop + +#[allow(dead_code)] +mod interop { + use super::super::interop::*; + use super::types; + use std::ffi::{c_void, CString}; + use std::os::raw::{c_char, c_int}; + use std::ptr::null; + + pub(crate) struct OwnedForm { + title: CString, + icon_path: CString, + fields: Vec, + + _metadata: Vec, + _interop: Box, + } + + impl Interoperable for OwnedForm { + fn as_ptr(&self) -> *const c_void { + &(*self._interop) as *const FormMetadata as *const c_void + } + } + + impl From for OwnedForm { + fn from(form: types::Form) -> Self { + let title = CString::new(form.title).expect("unable to convert form title to CString"); + let fields: Vec = form.fields.into_iter().map(|field| field.into()).collect(); + + let _metadata: Vec = fields.iter().map(|field| field.metadata()).collect(); + + let icon_path = if let Some(icon_path) = form.icon.as_ref() { + icon_path.clone() + } else { + "".to_owned() + }; + + let icon_path = CString::new(icon_path).expect("unable to convert form icon to CString"); + + let icon_path_ptr = if form.icon.is_some() { + icon_path.as_ptr() + } else { + std::ptr::null() + }; + + let _interop = Box::new(FormMetadata { + windowTitle: title.as_ptr(), + iconPath: icon_path_ptr, + fields: _metadata.as_ptr(), + fieldSize: fields.len() as c_int, + }); + + Self { + title, + icon_path, + fields, + _metadata, + _interop, + } + } + } + + struct OwnedField { + id: Option, + field_type: FieldType, + specific: Box, + } + + impl From for OwnedField { + fn from(field: types::Field) -> Self { + let id = if let Some(id) = field.id { + Some(CString::new(id).expect("unable to create cstring for field id")) + } else { + None + }; + + let field_type = match field.field_type { + types::FieldType::Row(_) => FieldType_ROW, + types::FieldType::Label(_) => FieldType_LABEL, + types::FieldType::Text(_) => FieldType_TEXT, + types::FieldType::Choice(_) => FieldType_CHOICE, + types::FieldType::Unknown => panic!("unknown field type"), + }; + + // TODO: clean up this match + let specific: Box = match field.field_type { + types::FieldType::Row(metadata) => { + let owned_metadata: OwnedRowMetadata = metadata.into(); + Box::new(owned_metadata) + } + types::FieldType::Label(metadata) => { + let owned_metadata: OwnedLabelMetadata = metadata.into(); + Box::new(owned_metadata) + } + types::FieldType::Text(metadata) => { + let owned_metadata: OwnedTextMetadata = metadata.into(); + Box::new(owned_metadata) + } + types::FieldType::Choice(metadata) => { + let owned_metadata: OwnedChoiceMetadata = metadata.into(); + Box::new(owned_metadata) + } + types::FieldType::Unknown => panic!("unknown field type"), + }; + + Self { + id, + field_type, + specific, + } + } + } + + impl OwnedField { + pub fn metadata(&self) -> FieldMetadata { + let id_ptr = if let Some(id) = self.id.as_ref() { + id.as_ptr() + } else { + null() + }; + + FieldMetadata { + id: id_ptr, + fieldType: self.field_type, + specific: self.specific.as_ptr(), + } + } + } + + struct OwnedLabelMetadata { + text: CString, + _interop: Box, + } + + impl Interoperable for OwnedLabelMetadata { + fn as_ptr(&self) -> *const c_void { + &(*self._interop) as *const LabelMetadata as *const c_void + } + } + + impl From for OwnedLabelMetadata { + fn from(label_metadata: types::LabelMetadata) -> Self { + let text = + CString::new(label_metadata.text).expect("unable to convert label text to CString"); + let _interop = Box::new(LabelMetadata { + text: text.as_ptr(), + }); + Self { text, _interop } + } + } + + struct OwnedTextMetadata { + default_text: CString, + _interop: Box, + } + + impl Interoperable for OwnedTextMetadata { + fn as_ptr(&self) -> *const c_void { + &(*self._interop) as *const TextMetadata as *const c_void + } + } + + impl From for OwnedTextMetadata { + fn from(text_metadata: types::TextMetadata) -> Self { + let default_text = CString::new(text_metadata.default_text) + .expect("unable to convert default text to CString"); + let _interop = Box::new(TextMetadata { + defaultText: default_text.as_ptr(), + multiline: if text_metadata.multiline { 1 } else { 0 }, + }); + Self { + default_text, + _interop, + } + } + } + + struct OwnedChoiceMetadata { + values: Vec, + values_ptr_array: Vec<*const c_char>, + default_value: CString, + _interop: Box, + } + + impl Interoperable for OwnedChoiceMetadata { + fn as_ptr(&self) -> *const c_void { + &(*self._interop) as *const ChoiceMetadata as *const c_void + } + } + + impl From for OwnedChoiceMetadata { + fn from(metadata: types::ChoiceMetadata) -> Self { + let values: Vec = metadata + .values + .into_iter() + .map(|value| CString::new(value).expect("unable to convert choice value to string")) + .collect(); + + let values_ptr_array: Vec<*const c_char> = + values.iter().map(|value| value.as_ptr()).collect(); + + let choice_type = match metadata.choice_type { + types::ChoiceType::Dropdown => ChoiceType_DROPDOWN, + types::ChoiceType::List => ChoiceType_LIST, + }; + + let default_value = + CString::new(metadata.default_value).expect("unable to convert default value to CString"); + + let _interop = Box::new(ChoiceMetadata { + values: values_ptr_array.as_ptr(), + valueSize: values.len() as c_int, + choiceType: choice_type, + defaultValue: default_value.as_ptr(), + }); + Self { + values, + values_ptr_array, + default_value, + _interop, + } + } + } + + struct OwnedRowMetadata { + fields: Vec, + + _metadata: Vec, + _interop: Box, + } + + impl Interoperable for OwnedRowMetadata { + fn as_ptr(&self) -> *const c_void { + &(*self._interop) as *const RowMetadata as *const c_void + } + } + + impl From for OwnedRowMetadata { + fn from(row_metadata: types::RowMetadata) -> Self { + let fields: Vec = row_metadata + .fields + .into_iter() + .map(|field| field.into()) + .collect(); + + let _metadata: Vec = fields.iter().map(|field| field.metadata()).collect(); + + let _interop = Box::new(RowMetadata { + fields: _metadata.as_ptr(), + fieldSize: _metadata.len() as c_int, + }); + + Self { + fields, + _metadata, + _interop, + } + } + } +} + +pub fn show(form: types::Form) -> HashMap { + use super::interop::*; + use std::os::raw::c_void; + + let owned_form: interop::OwnedForm = form.into(); + let metadata: *const FormMetadata = owned_form.as_ptr() as *const FormMetadata; + + let mut value_map: HashMap = HashMap::new(); + + extern "C" fn callback(values: *const ValuePair, size: c_int, map: *mut c_void) { + let values: &[ValuePair] = + unsafe { std::slice::from_raw_parts(values, size as usize) }; + let map = map as *mut HashMap; + let map = unsafe { &mut (*map) }; + for pair in values.iter() { + unsafe { + let id = CStr::from_ptr(pair.id); + let value = CStr::from_ptr(pair.value); + + let id = id.to_string_lossy().to_string(); + let value = value.to_string_lossy().to_string(); + map.insert(id, value); + } + } + } + + unsafe { + // TODO: Nested rows should fail, add check + interop_show_form( + metadata, + callback, + &mut value_map as *mut HashMap as *mut c_void, + ); + } + + value_map +} diff --git a/espanso-modulo/src/sys/interop/interop.h b/espanso-modulo/src/sys/interop/interop.h new file mode 100644 index 0000000..abe072c --- /dev/null +++ b/espanso-modulo/src/sys/interop/interop.h @@ -0,0 +1,90 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +// FORM + +typedef enum FieldType { + ROW, + LABEL, + TEXT, + CHOICE, + CHECKBOX, +} FieldType; + +typedef struct LabelMetadata { + const char *text; +} LabelMetadata; + +typedef struct TextMetadata { + const char *defaultText; + const int multiline; +} TextMetadata; + +typedef enum ChoiceType { + DROPDOWN, + LIST, +} ChoiceType; + +typedef struct ChoiceMetadata { + const char * const * values; + const int valueSize; + const char *defaultValue; + const ChoiceType choiceType; +} ChoiceMetadata; + +typedef struct FieldMetadata { + const char * id; + FieldType fieldType; + const void * specific; +} FieldMetadata; + +typedef struct RowMetadata { + const FieldMetadata *fields; + const int fieldSize; +} RowMetadata; + +typedef struct FormMetadata { + const char *windowTitle; + const char *iconPath; + const FieldMetadata *fields; + const int fieldSize; +} FormMetadata; + +typedef struct ValuePair { + const char *id; + const char *value; +} ValuePair; + +// SEARCH + +typedef struct SearchItem { + const char *id; + const char *label; + const char *trigger; +} SearchItem; + +typedef struct SearchResults { + const SearchItem * items; + const int itemSize; +} SearchResults; + +typedef struct SearchMetadata { + const char *windowTitle; + const char *iconPath; +} SearchMetadata; \ No newline at end of file diff --git a/espanso-modulo/src/sys/interop/mod.rs b/espanso-modulo/src/sys/interop/mod.rs new file mode 100644 index 0000000..344ec0f --- /dev/null +++ b/espanso-modulo/src/sys/interop/mod.rs @@ -0,0 +1,133 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +use std::ffi::c_void; + +pub(crate) trait Interoperable { + fn as_ptr(&self) -> *const c_void; +} + +pub const FieldType_ROW: FieldType = 0; +pub const FieldType_LABEL: FieldType = 1; +pub const FieldType_TEXT: FieldType = 2; +pub const FieldType_CHOICE: FieldType = 3; +pub const FieldType_CHECKBOX: FieldType = 4; +pub type FieldType = i32; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct LabelMetadata { + pub text: *const ::std::os::raw::c_char, +} +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct TextMetadata { + pub defaultText: *const ::std::os::raw::c_char, + pub multiline: ::std::os::raw::c_int, +} + +pub const ChoiceType_DROPDOWN: ChoiceType = 0; +pub const ChoiceType_LIST: ChoiceType = 1; +pub type ChoiceType = i32; +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct ChoiceMetadata { + pub values: *const *const ::std::os::raw::c_char, + pub valueSize: ::std::os::raw::c_int, + pub defaultValue: *const ::std::os::raw::c_char, + pub choiceType: ChoiceType, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct FieldMetadata { + pub id: *const ::std::os::raw::c_char, + pub fieldType: FieldType, + pub specific: *const ::std::os::raw::c_void, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct RowMetadata { + pub fields: *const FieldMetadata, + pub fieldSize: ::std::os::raw::c_int, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct FormMetadata { + pub windowTitle: *const ::std::os::raw::c_char, + pub iconPath: *const ::std::os::raw::c_char, + pub fields: *const FieldMetadata, + pub fieldSize: ::std::os::raw::c_int, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct ValuePair { + pub id: *const ::std::os::raw::c_char, + pub value: *const ::std::os::raw::c_char, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct SearchItem { + pub id: *const ::std::os::raw::c_char, + pub label: *const ::std::os::raw::c_char, + pub trigger: *const ::std::os::raw::c_char, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct SearchResults { + pub items: *const SearchItem, + pub itemSize: ::std::os::raw::c_int, +} + +#[repr(C)] +#[derive(Debug, Copy, Clone)] +pub struct SearchMetadata { + pub windowTitle: *const ::std::os::raw::c_char, + pub iconPath: *const ::std::os::raw::c_char, +} + +use std::os::raw::{c_char, c_int}; + +// Native bindings + +#[allow(improper_ctypes)] +#[link(name = "espansomodulosys", kind = "static")] +extern "C" { + // FORM + pub(crate) fn interop_show_form( + metadata: *const FormMetadata, + callback: extern "C" fn(values: *const ValuePair, size: c_int, map: *mut c_void), + map: *mut c_void, + ); + + // SEARCH + pub(crate) fn interop_show_search( + metadata: *const SearchMetadata, + search_callback: extern "C" fn(query: *const c_char, app: *const c_void, data: *const c_void), + items: *const c_void, + result_callback: extern "C" fn(id: *const c_char, result: *mut c_void), + result: *mut c_void, + ); + + pub(crate) fn update_items(app: *const c_void, items: *const SearchItem, itemCount: c_int); +} diff --git a/espanso-modulo/src/sys/mod.rs b/espanso-modulo/src/sys/mod.rs new file mode 100644 index 0000000..91e5b41 --- /dev/null +++ b/espanso-modulo/src/sys/mod.rs @@ -0,0 +1,26 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +pub mod form; +pub mod search; + +#[allow(non_upper_case_globals)] +#[allow(dead_code)] +#[allow(non_snake_case)] +pub mod interop; \ No newline at end of file diff --git a/espanso-modulo/src/sys/search/mod.rs b/espanso-modulo/src/sys/search/mod.rs new file mode 100644 index 0000000..cdbed5e --- /dev/null +++ b/espanso-modulo/src/sys/search/mod.rs @@ -0,0 +1,191 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +use std::ffi::CStr; +use std::os::raw::{c_char, c_int, c_void}; + +pub mod types { + #[derive(Debug)] + pub struct SearchItem { + pub id: String, + pub label: String, + pub trigger: Option, + } + + #[derive(Debug)] + pub struct Search { + pub title: String, + pub icon: Option, + pub items: Vec, + } +} + +#[allow(dead_code)] +mod interop { + use super::types; + use super::super::interop::*; + use std::ffi::{c_void, CString}; + + pub(crate) struct OwnedSearch { + title: CString, + icon_path: CString, + items: Vec, + pub(crate) interop_items: Vec, + _interop: Box, + } + + impl Interoperable for OwnedSearch { + fn as_ptr(&self) -> *const c_void { + &(*self._interop) as *const SearchMetadata as *const c_void + } + } + + impl From<&types::Search> for OwnedSearch { + fn from(search: &types::Search) -> Self { + let title = + CString::new(search.title.clone()).expect("unable to convert search title to CString"); + + let items: Vec = search.items.iter().map(|item| item.into()).collect(); + + let interop_items: Vec = items.iter().map(|item| item.to_search_item()).collect(); + + let icon_path = if let Some(icon_path) = search.icon.as_ref() { + icon_path.clone() + } else { + "".to_owned() + }; + + let icon_path = CString::new(icon_path).expect("unable to convert search icon to CString"); + + let icon_path_ptr = if search.icon.is_some() { + icon_path.as_ptr() + } else { + std::ptr::null() + }; + + let _interop = Box::new(SearchMetadata { + iconPath: icon_path_ptr, + windowTitle: title.as_ptr(), + }); + + Self { + title, + items, + icon_path, + interop_items, + _interop, + } + } + } + + pub(crate) struct OwnedSearchItem { + id: CString, + label: CString, + trigger: CString, + } + + impl OwnedSearchItem { + fn to_search_item(&self) -> SearchItem { + SearchItem { + id: self.id.as_ptr(), + label: self.label.as_ptr(), + trigger: self.trigger.as_ptr(), + } + } + } + + impl From<&types::SearchItem> for OwnedSearchItem { + fn from(item: &types::SearchItem) -> Self { + let id = CString::new(item.id.clone()).expect("unable to convert item id to CString"); + let label = + CString::new(item.label.clone()).expect("unable to convert item label to CString"); + + let trigger = if let Some(trigger) = item.trigger.as_deref() { + CString::new(trigger.to_string()).expect("unable to convert item trigger to CString") + } else { + CString::new("".to_string()).expect("unable to convert item trigger to CString") + }; + + Self { id, label, trigger } + } + } +} + +struct SearchData { + owned_search: interop::OwnedSearch, + items: Vec, + algorithm: Box Vec>, +} + +pub fn show( + search: types::Search, + algorithm: Box Vec>, +) -> Option { + use super::interop::*; + + let owned_search: interop::OwnedSearch = (&search).into(); + let metadata: *const SearchMetadata = owned_search.as_ptr() as *const SearchMetadata; + + let search_data = SearchData { + owned_search, + items: search.items, + algorithm, + }; + + extern "C" fn search_callback(query: *const c_char, app: *const c_void, data: *const c_void) { + let query = unsafe { CStr::from_ptr(query) }; + let query = query.to_string_lossy().to_string(); + + let search_data = data as *const SearchData; + let search_data = unsafe { &*search_data }; + + let indexes = (*search_data.algorithm)(&query, &search_data.items); + let items: Vec = indexes + .into_iter() + .map(|index| search_data.owned_search.interop_items[index]) + .collect(); + + unsafe { + update_items(app, items.as_ptr(), items.len() as c_int); + } + } + + let mut result: Option = None; + + extern "C" fn result_callback(id: *const c_char, result: *mut c_void) { + let id = unsafe { CStr::from_ptr(id) }; + let id = id.to_string_lossy().to_string(); + let result: *mut Option = result as *mut Option; + unsafe { + *result = Some(id); + } + } + + unsafe { + interop_show_search( + metadata, + search_callback, + &search_data as *const SearchData as *const c_void, + result_callback, + &mut result as *mut Option as *mut c_void, + ); + } + + result +} diff --git a/espanso-modulo/src/sys/search/search.cpp b/espanso-modulo/src/sys/search/search.cpp new file mode 100644 index 0000000..3f86cf3 --- /dev/null +++ b/espanso-modulo/src/sys/search/search.cpp @@ -0,0 +1,471 @@ +/* + * This file is part of modulo. + * + * Copyright (C) 2020-2021 Federico Terzi + * + * modulo is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * modulo is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with modulo. If not, see . + */ + +// Mouse dragging mechanism greatly inspired by: https://developpaper.com/wxwidgets-implementing-the-drag-effect-of-titleless-bar-window/ + +#define _UNICODE + +#include "../common/common.h" +#include "../interop/interop.h" + +#include "wx/htmllbox.h" + +#include +#include +#include + +// Platform-specific styles +#ifdef __WXMSW__ +const int SEARCH_BAR_FONT_SIZE = 16; +const long DEFAULT_STYLE = wxSTAY_ON_TOP | wxFRAME_TOOL_WINDOW; +#endif +#ifdef __WXOSX__ +const int SEARCH_BAR_FONT_SIZE = 20; +const long DEFAULT_STYLE = wxSTAY_ON_TOP | wxFRAME_TOOL_WINDOW | wxRESIZE_BORDER; +#endif +#ifdef __LINUX__ +const int SEARCH_BAR_FONT_SIZE = 20; +const long DEFAULT_STYLE = wxSTAY_ON_TOP | wxFRAME_TOOL_WINDOW | wxBORDER_NONE; +#endif + +const wxColour SELECTION_LIGHT_BG = wxColour(164, 210, 253); +const wxColour SELECTION_DARK_BG = wxColour(49, 88, 126); + +// https://docs.wxwidgets.org/stable/classwx_frame.html +const int MIN_WIDTH = 500; +const int MIN_HEIGHT = 80; + +typedef void (*QueryCallback)(const char *query, void *app, void *data); +typedef void (*ResultCallback)(const char *id, void *data); + +SearchMetadata *searchMetadata = nullptr; +QueryCallback queryCallback = nullptr; +ResultCallback resultCallback = nullptr; +void *data = nullptr; +void *resultData = nullptr; +wxArrayString wxItems; +wxArrayString wxTriggers; +wxArrayString wxIds; + +// App Code + +class SearchApp : public wxApp +{ +public: + virtual bool OnInit(); +}; + +class ResultListBox : public wxHtmlListBox +{ +public: + ResultListBox() {} + ResultListBox(wxWindow *parent, bool isDark, const wxWindowID id, const wxPoint &pos, const wxSize &size); + +protected: + // override this method to return data to be shown in the listbox (this is + // mandatory) + virtual wxString OnGetItem(size_t n) const; + + // change the appearance by overriding these functions (this is optional) + virtual void OnDrawBackground(wxDC &dc, const wxRect &rect, size_t n) const; + + bool isDark; + +public: + wxDECLARE_NO_COPY_CLASS(ResultListBox); + wxDECLARE_DYNAMIC_CLASS(ResultListBox); +}; + +wxIMPLEMENT_DYNAMIC_CLASS(ResultListBox, wxHtmlListBox); + +ResultListBox::ResultListBox(wxWindow *parent, bool isDark, const wxWindowID id, const wxPoint &pos, const wxSize &size) + : wxHtmlListBox(parent, id, pos, size, 0) +{ + this->isDark = isDark; + SetMargins(5, 5); + Refresh(); +} + +void ResultListBox::OnDrawBackground(wxDC &dc, const wxRect &rect, size_t n) const +{ + if (IsSelected(n)) + { + if (isDark) + { + dc.SetBrush(wxBrush(SELECTION_DARK_BG)); + } + else + { + dc.SetBrush(wxBrush(SELECTION_LIGHT_BG)); + } + } + else + { + dc.SetBrush(*wxTRANSPARENT_BRUSH); + } + dc.SetPen(*wxTRANSPARENT_PEN); + dc.DrawRectangle(0, 0, rect.GetRight(), rect.GetBottom()); +} + +wxString ResultListBox::OnGetItem(size_t n) const +{ + wxString textColor = isDark ? "white" : ""; + wxString shortcut = (n < 8) ? wxString::Format(wxT("Alt+%i"), (int)n + 1) : " "; + return wxString::Format(wxT("
%s%s %s
"), textColor, wxItems[n], wxTriggers[n], shortcut); +} + +class SearchFrame : public wxFrame +{ +public: + SearchFrame(const wxString &title, const wxPoint &pos, const wxSize &size); + + wxPanel *panel; + wxTextCtrl *searchBar; + wxStaticBitmap *iconPanel; + ResultListBox *resultBox; + void SetItems(SearchItem *items, int itemSize); + +private: + void OnCharEvent(wxKeyEvent &event); + void OnQueryChange(wxCommandEvent &event); + void OnItemClickEvent(wxCommandEvent &event); + void OnActivate(wxActivateEvent &event); + + // Mouse events + void OnMouseCaptureLost(wxMouseCaptureLostEvent &event); + void OnMouseLeave(wxMouseEvent &event); + void OnMouseMove(wxMouseEvent &event); + void OnMouseLUp(wxMouseEvent &event); + void OnMouseLDown(wxMouseEvent &event); + wxPoint mLastPt; + + // Selection + void SelectNext(); + void SelectPrevious(); + void Submit(); +}; + +bool SearchApp::OnInit() +{ + SearchFrame *frame = new SearchFrame(searchMetadata->windowTitle, wxPoint(50, 50), wxSize(450, 340)); + frame->Show(true); + SetupWindowStyle(frame); + Activate(frame); + return true; +} +SearchFrame::SearchFrame(const wxString &title, const wxPoint &pos, const wxSize &size) + : wxFrame(NULL, wxID_ANY, title, pos, size, DEFAULT_STYLE) +{ + wxInitAllImageHandlers(); + +#if wxCHECK_VERSION(3, 1, 3) + bool isDark = wxSystemSettings::GetAppearance().IsDark(); +#else + // Workaround needed for previous versions of wxWidgets + const wxColour bg = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOW); + const wxColour fg = wxSystemSettings::GetColour(wxSYS_COLOUR_WINDOWTEXT); + unsigned int bgSum = (bg.Red() + bg.Blue() + bg.Green()); + unsigned int fgSum = (fg.Red() + fg.Blue() + fg.Green()); + bool isDark = fgSum > bgSum; +#endif + + panel = new wxPanel(this, wxID_ANY); + wxBoxSizer *vbox = new wxBoxSizer(wxVERTICAL); + panel->SetSizer(vbox); + + wxBoxSizer *topBox = new wxBoxSizer(wxHORIZONTAL); + + int iconId = NewControlId(); + iconPanel = nullptr; + if (searchMetadata->iconPath) + { + wxString iconPath = wxString(searchMetadata->iconPath); + if (wxFileExists(iconPath)) + { + wxBitmap bitmap = wxBitmap(iconPath, wxBITMAP_TYPE_PNG); + if (bitmap.IsOk()) + { + wxImage image = bitmap.ConvertToImage(); + image.Rescale(32, 32, wxIMAGE_QUALITY_HIGH); + wxBitmap resizedBitmap = wxBitmap(image, -1); + iconPanel = new wxStaticBitmap(panel, iconId, resizedBitmap, wxDefaultPosition, wxSize(32, 32)); + topBox->Add(iconPanel, 0, wxEXPAND | wxLEFT | wxUP | wxDOWN, 10); + } + } + } + + int textId = NewControlId(); + searchBar = new wxTextCtrl(panel, textId, "", wxDefaultPosition, wxDefaultSize); + wxFont font = searchBar->GetFont(); + font.SetPointSize(SEARCH_BAR_FONT_SIZE); + searchBar->SetFont(font); + topBox->Add(searchBar, 1, wxEXPAND | wxALL, 10); + + vbox->Add(topBox, 1, wxEXPAND); + + wxArrayString choices; + int resultId = NewControlId(); + resultBox = new ResultListBox(panel, isDark, resultId, wxDefaultPosition, wxSize(MIN_WIDTH, MIN_HEIGHT)); + vbox->Add(resultBox, 5, wxEXPAND | wxALL, 0); + + Bind(wxEVT_CHAR_HOOK, &SearchFrame::OnCharEvent, this, wxID_ANY); + Bind(wxEVT_TEXT, &SearchFrame::OnQueryChange, this, textId); + Bind(wxEVT_LISTBOX_DCLICK, &SearchFrame::OnItemClickEvent, this, resultId); + Bind(wxEVT_ACTIVATE, &SearchFrame::OnActivate, this, wxID_ANY); + + // Events to handle the mouse drag + if (iconPanel) + { + iconPanel->Bind(wxEVT_LEFT_UP, &SearchFrame::OnMouseLUp, this); + iconPanel->Bind(wxEVT_LEFT_DOWN, &SearchFrame::OnMouseLDown, this); + Bind(wxEVT_MOTION, &SearchFrame::OnMouseMove, this); + Bind(wxEVT_LEFT_UP, &SearchFrame::OnMouseLUp, this); + Bind(wxEVT_LEFT_DOWN, &SearchFrame::OnMouseLDown, this); + Bind(wxEVT_MOUSE_CAPTURE_LOST, &SearchFrame::OnMouseCaptureLost, this); + Bind(wxEVT_LEAVE_WINDOW, &SearchFrame::OnMouseLeave, this); + } + + this->SetClientSize(panel->GetBestSize()); + this->CentreOnScreen(); + + // Trigger the first data update + queryCallback("", (void *)this, data); +} + +void SearchFrame::OnCharEvent(wxKeyEvent &event) +{ + if (event.GetKeyCode() == WXK_ESCAPE) + { + Close(true); + } + else if (event.GetKeyCode() == WXK_TAB) + { + if (wxGetKeyState(WXK_SHIFT)) + { + SelectPrevious(); + } + else + { + SelectNext(); + } + } + else if (event.GetKeyCode() >= 49 && event.GetKeyCode() <= 56) + { // Alt + num shortcut + int index = event.GetKeyCode() - 49; + if (wxGetKeyState(WXK_ALT)) + { + if (resultBox->GetItemCount() > index) + { + resultBox->SetSelection(index); + Submit(); + } + } else { + event.Skip(); + } + } + else if (event.GetKeyCode() == WXK_DOWN) + { + SelectNext(); + } + else if (event.GetKeyCode() == WXK_UP) + { + SelectPrevious(); + } + else if (event.GetKeyCode() == WXK_RETURN) + { + Submit(); + } + else + { + event.Skip(); + } +} + +void SearchFrame::OnQueryChange(wxCommandEvent &event) +{ + wxString queryString = searchBar->GetValue(); + const char *query = queryString.ToUTF8(); + queryCallback(query, (void *)this, data); +} + +void SearchFrame::OnItemClickEvent(wxCommandEvent &event) +{ + resultBox->SetSelection(event.GetInt()); + Submit(); +} + +void SearchFrame::OnActivate(wxActivateEvent &event) +{ + if (!event.GetActive()) + { + Close(true); + } + event.Skip(); +} + +void SearchFrame::OnMouseMove(wxMouseEvent &event) +{ + if (event.LeftIsDown() && event.Dragging()) + { + wxPoint pt = event.GetPosition(); + wxPoint wndLeftTopPt = GetPosition(); + int distanceX = pt.x - mLastPt.x; + int distanceY = pt.y - mLastPt.y; + + wxPoint desPt; + desPt.x = distanceX + wndLeftTopPt.x - 24; + desPt.y = distanceY + wndLeftTopPt.y - 24; + this->Move(desPt); + } + + if (event.LeftDown()) + { + this->CaptureMouse(); + mLastPt = event.GetPosition(); + } +} + +void SearchFrame::OnMouseLeave(wxMouseEvent &event) +{ + if (event.LeftIsDown() && event.Dragging()) + { + wxPoint pt = event.GetPosition(); + wxPoint wndLeftTopPt = GetPosition(); + int distanceX = pt.x - mLastPt.x; + int distanceY = pt.y - mLastPt.y; + + wxPoint desPt; + desPt.x = distanceX + wndLeftTopPt.x - 24; + desPt.y = distanceY + wndLeftTopPt.y - 24; + this->Move(desPt); + } +} + +void SearchFrame::OnMouseLDown(wxMouseEvent &event) +{ + if (!HasCapture()) + this->CaptureMouse(); +} + +void SearchFrame::OnMouseLUp(wxMouseEvent &event) +{ + if (HasCapture()) + ReleaseMouse(); +} + +void SearchFrame::OnMouseCaptureLost(wxMouseCaptureLostEvent &event) +{ + if (HasCapture()) + ReleaseMouse(); +} + +void SearchFrame::SetItems(SearchItem *items, int itemSize) +{ + wxItems.Clear(); + wxIds.Clear(); + wxTriggers.Clear(); + + for (int i = 0; i < itemSize; i++) + { + wxString item = items[i].label; + wxItems.Add(item); + + wxString id = items[i].id; + wxIds.Add(id); + + wxString trigger = items[i].trigger; + wxTriggers.Add(trigger); + } + + resultBox->SetItemCount(itemSize); + + if (itemSize > 0) + { + resultBox->SetSelection(0); + } + resultBox->RefreshAll(); + resultBox->Refresh(); +} + +void SearchFrame::SelectNext() +{ + if (resultBox->GetItemCount() > 0 && resultBox->GetSelection() != wxNOT_FOUND) + { + int newSelected = 0; + if (resultBox->GetSelection() < (resultBox->GetItemCount() - 1)) + { + newSelected = resultBox->GetSelection() + 1; + } + + resultBox->SetSelection(newSelected); + } +} + +void SearchFrame::SelectPrevious() +{ + if (resultBox->GetItemCount() > 0 && resultBox->GetSelection() != wxNOT_FOUND) + { + int newSelected = resultBox->GetItemCount() - 1; + if (resultBox->GetSelection() > 0) + { + newSelected = resultBox->GetSelection() - 1; + } + + resultBox->SetSelection(newSelected); + } +} + +void SearchFrame::Submit() +{ + if (resultBox->GetItemCount() > 0 && resultBox->GetSelection() != wxNOT_FOUND) + { + long index = resultBox->GetSelection(); + wxString id = wxIds[index]; + if (resultCallback) + { + resultCallback(id.ToUTF8(), resultData); + } + + Close(true); + } +} + +extern "C" void interop_show_search(SearchMetadata *_metadata, QueryCallback _queryCallback, void *_data, ResultCallback _resultCallback, void *_resultData) +{ +// Setup high DPI support on Windows +#ifdef __WXMSW__ + SetProcessDPIAware(); +#endif + + searchMetadata = _metadata; + queryCallback = _queryCallback; + resultCallback = _resultCallback; + data = _data; + resultData = _resultData; + + wxApp::SetInstance(new SearchApp()); + int argc = 0; + wxEntry(argc, (char **)nullptr); +} + +extern "C" void update_items(void *app, SearchItem *items, int itemSize) +{ + SearchFrame *frame = (SearchFrame *)app; + frame->SetItems(items, itemSize); +} \ No newline at end of file diff --git a/espanso-modulo/vendor/wxWidgets-3.1.5.zip b/espanso-modulo/vendor/wxWidgets-3.1.5.zip new file mode 100644 index 0000000..ef81d88 Binary files /dev/null and b/espanso-modulo/vendor/wxWidgets-3.1.5.zip differ