Skip to content

Commit a399383

Browse files
committedJan 21, 2023
feat(whkd): initial commit
0 parents  commit a399383

10 files changed

+902
-0
lines changed
 

‎.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/target
2+
.idea

‎Cargo.lock

+551
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[package]
2+
name = "whkd"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
7+
8+
[dependencies]
9+
chumsky = "0.8"
10+
color-eyre = "0.6"
11+
dirs = "4"
12+
lazy_static = "1"
13+
parking_lot = "0.12"
14+
windows-hotkeys = "0.1.1"

‎LICENSE

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2023 Jade Iqbal
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎README.md

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
# whkd
2+
3+
_whkd_ is a simple hotkey daemon for Windows that reacts to input events by executing commands.
4+
5+
Its configuration file (`~/.config/whkdrc`) is a series of bindings which define the associations between the input events and the commands.
6+
7+
The format of the configuration file (and this project itself) is heavily inspired by `skhd` and `sxhkd`.
8+
9+
## Example
10+
11+
```
12+
.shell pwsh # can be one of cmd | pwsh | powershell
13+
14+
# reload configuration
15+
alt + o : taskkill /f /im whkd.exe && Start-Process whkd -WindowStyle hidden
16+
17+
# app shortcuts
18+
alt + f : if ($wshell.AppActivate('Firefox') -eq $False) { start firefox }
19+
20+
# focus windows with komorebi
21+
alt + h : komorebic focus left
22+
alt + j : komorebic focus down
23+
alt + k : komorebic focus up
24+
alt + l : komorebic focus right
25+
```

‎rust-toolchain.toml

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[toolchain]
2+
channel = "stable"

‎rustfmt.toml

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
imports_granularity = "Item"

‎src/main.rs

+127
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
#![warn(clippy::all, clippy::nursery, clippy::pedantic)]
2+
#![allow(clippy::missing_errors_doc, clippy::redundant_pub_crate)]
3+
4+
use crate::parser::HotkeyBinding;
5+
use crate::whkdrc::Shell;
6+
use crate::whkdrc::Whkdrc;
7+
use color_eyre::eyre::eyre;
8+
use color_eyre::eyre::Result;
9+
use lazy_static::lazy_static;
10+
use parking_lot::Mutex;
11+
use std::io::Write;
12+
use std::process::ChildStdin;
13+
use std::process::Command;
14+
use std::process::Stdio;
15+
use windows_hotkeys::error::HkError;
16+
use windows_hotkeys::keys::ModKey;
17+
use windows_hotkeys::keys::VKey;
18+
use windows_hotkeys::HotkeyManager;
19+
20+
mod parser;
21+
mod whkdrc;
22+
23+
lazy_static! {
24+
static ref WHKDRC: Whkdrc = {
25+
let mut home = dirs::home_dir().expect("no home directory found");
26+
home.push(".config");
27+
home.push("whkdrc");
28+
29+
Whkdrc::load(&home).expect("could not load whkdrc")
30+
};
31+
static ref SESSION_STDIN: Mutex<Option<ChildStdin>> = Mutex::new(None);
32+
}
33+
34+
#[derive(Debug)]
35+
pub struct HkmData {
36+
pub mod_keys: Vec<ModKey>,
37+
pub vkey: VKey,
38+
pub command: String,
39+
}
40+
41+
impl HkmData {
42+
pub fn register(&self, hkm: &mut HotkeyManager<()>) -> Result<()> {
43+
let cmd = self.command.clone();
44+
45+
hkm.register(self.vkey, self.mod_keys.as_slice(), move || {
46+
if let Some(session_stdin) = SESSION_STDIN.lock().as_mut() {
47+
if matches!(WHKDRC.shell, Shell::Pwsh | Shell::Powershell) {
48+
println!("{cmd}");
49+
}
50+
51+
writeln!(session_stdin, "{cmd}").expect("failed to execute command");
52+
}
53+
})?;
54+
55+
Ok(())
56+
}
57+
}
58+
59+
impl TryFrom<&HotkeyBinding> for HkmData {
60+
type Error = HkError;
61+
62+
fn try_from(value: &HotkeyBinding) -> Result<Self, Self::Error> {
63+
let (trigger, mods) = value.keys.split_last().unwrap();
64+
let mut mod_keys = vec![];
65+
let vkey = VKey::from_keyname(trigger)?;
66+
for m in mods {
67+
mod_keys.push(ModKey::from_keyname(m)?);
68+
}
69+
70+
Ok(Self {
71+
mod_keys,
72+
vkey,
73+
command: value.command.clone(),
74+
})
75+
}
76+
}
77+
78+
fn main() -> Result<()> {
79+
color_eyre::install()?;
80+
81+
let shell_binary = WHKDRC.shell.to_string();
82+
83+
match WHKDRC.shell {
84+
Shell::Powershell | Shell::Pwsh => {
85+
let mut process = Command::new(&shell_binary)
86+
.stdin(Stdio::piped())
87+
.args(["-Command", "-"])
88+
.spawn()?;
89+
90+
let mut stdin = process
91+
.stdin
92+
.take()
93+
.ok_or_else(|| eyre!("could not take stdin from powershell session"))?;
94+
95+
writeln!(stdin, "$wshell = New-Object -ComObject wscript.shell")?;
96+
97+
let mut session_stdin = SESSION_STDIN.lock();
98+
*session_stdin = Option::from(stdin);
99+
}
100+
Shell::Cmd => {
101+
let mut process = Command::new(&shell_binary)
102+
.stdin(Stdio::piped())
103+
.args(["-"])
104+
.spawn()?;
105+
106+
let mut stdin = process
107+
.stdin
108+
.take()
109+
.ok_or_else(|| eyre!("could not take stdin from cmd session"))?;
110+
111+
writeln!(stdin, "prompt $S")?;
112+
113+
let mut session_stdin = SESSION_STDIN.lock();
114+
*session_stdin = Option::from(stdin);
115+
}
116+
}
117+
118+
let mut hkm = HotkeyManager::new();
119+
120+
for binding in &WHKDRC.bindings {
121+
HkmData::try_from(binding)?.register(&mut hkm)?;
122+
}
123+
124+
hkm.event_loop();
125+
126+
Ok(())
127+
}

‎src/parser.rs

+106
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
use crate::whkdrc::Shell;
2+
use crate::whkdrc::Whkdrc;
3+
use chumsky::prelude::*;
4+
5+
#[derive(Debug, Clone, PartialEq, Eq)]
6+
pub struct HotkeyBinding {
7+
pub keys: Vec<String>,
8+
pub command: String,
9+
}
10+
11+
#[must_use]
12+
pub fn parser() -> impl Parser<char, Whkdrc, Error = Simple<char>> {
13+
let comment = just::<_, _, Simple<char>>("#")
14+
.then(take_until(text::newline()))
15+
.padded()
16+
.ignored();
17+
18+
let shell = just(".shell")
19+
.padded()
20+
.ignore_then(choice((just("pwsh"), just("powershell"), just("cmd"))))
21+
.repeated()
22+
.exactly(1)
23+
.collect::<String>()
24+
.map(Shell::from);
25+
26+
let hotkeys = choice((text::ident(), text::int(10)))
27+
.padded()
28+
.separated_by(just("+"))
29+
.at_least(2)
30+
.collect::<Vec<String>>();
31+
32+
let delimiter = just(":").padded();
33+
34+
let command = take_until(choice((comment, text::newline())))
35+
.padded()
36+
.map(|c| c.0)
37+
.collect::<String>();
38+
39+
let binding = hotkeys.then_ignore(delimiter).then(command);
40+
41+
shell
42+
.then(
43+
binding
44+
.map(|(keys, command)| HotkeyBinding { keys, command })
45+
.padded()
46+
.padded_by(comment.repeated())
47+
.repeated()
48+
.at_least(1),
49+
)
50+
.map(|(shell, bindings)| Whkdrc { shell, bindings })
51+
}
52+
53+
#[cfg(test)]
54+
mod tests {
55+
use super::*;
56+
57+
#[test]
58+
fn test_parse() {
59+
let src = r#"
60+
.shell cmd
61+
62+
# leading newlines are fine
63+
# line comments should parse and be ignored
64+
alt + h : komorebic focus left # so should comments at the end of a line
65+
alt + j : komorebic focus down
66+
alt + k : komorebic focus up
67+
alt + l : komorebic focus right
68+
69+
# so should empty lines
70+
alt + 1 : komorebic focus-workspace 0 # digits are fine in the hotkeys section
71+
72+
# trailing newlines are fine
73+
74+
75+
"#;
76+
77+
let output = parser().parse(src);
78+
let expected = Whkdrc {
79+
shell: Shell::Cmd,
80+
bindings: vec![
81+
HotkeyBinding {
82+
keys: vec![String::from("alt"), String::from("h")],
83+
command: String::from("komorebic focus left"),
84+
},
85+
HotkeyBinding {
86+
keys: vec![String::from("alt"), String::from("j")],
87+
command: String::from("komorebic focus down"),
88+
},
89+
HotkeyBinding {
90+
keys: vec![String::from("alt"), String::from("k")],
91+
command: String::from("komorebic focus up"),
92+
},
93+
HotkeyBinding {
94+
keys: vec![String::from("alt"), String::from("l")],
95+
command: String::from("komorebic focus right"),
96+
},
97+
HotkeyBinding {
98+
keys: vec![String::from("alt"), String::from("1")],
99+
command: String::from("komorebic focus-workspace 0"),
100+
},
101+
],
102+
};
103+
104+
assert_eq!(output.unwrap(), expected);
105+
}
106+
}

‎src/whkdrc.rs

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
use crate::parser::parser;
2+
use crate::parser::HotkeyBinding;
3+
use chumsky::Parser;
4+
use color_eyre::eyre::eyre;
5+
use color_eyre::eyre::Result;
6+
use std::fmt::Display;
7+
use std::fmt::Formatter;
8+
use std::path::PathBuf;
9+
10+
#[derive(Debug, Clone, PartialEq, Eq)]
11+
pub struct Whkdrc {
12+
pub shell: Shell,
13+
pub bindings: Vec<HotkeyBinding>,
14+
}
15+
16+
#[derive(Debug, Clone, PartialEq, Eq)]
17+
pub enum Shell {
18+
Cmd,
19+
Powershell,
20+
Pwsh,
21+
}
22+
23+
#[allow(clippy::fallible_impl_from)]
24+
impl From<String> for Shell {
25+
fn from(value: String) -> Self {
26+
match value.as_str() {
27+
"pwsh" => Self::Pwsh,
28+
"powershell" => Self::Powershell,
29+
"cmd" => Self::Cmd,
30+
_ => panic!("unsupported shell"),
31+
}
32+
}
33+
}
34+
35+
impl Display for Shell {
36+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
37+
match self {
38+
Self::Cmd => write!(f, "cmd"),
39+
Self::Powershell => write!(f, "powershell"),
40+
Self::Pwsh => write!(f, "pwsh"),
41+
}
42+
}
43+
}
44+
45+
impl Whkdrc {
46+
pub fn load(path: &PathBuf) -> Result<Self> {
47+
let contents = std::fs::read_to_string(path)?;
48+
49+
parser()
50+
.parse(contents)
51+
.map_err(|error| eyre!("could not parse whkdrc: {:?}", error))
52+
}
53+
}

0 commit comments

Comments
 (0)
Please sign in to comment.