Compare commits

..

1 Commits
main ... main

Author SHA1 Message Date
3bcb6eb0f0 input group notice 2025-03-18 16:13:10 +13:00
13 changed files with 345 additions and 133 deletions

2
.vscode/launch.json vendored
View File

@ -8,7 +8,7 @@
"type": "lldb",
"request": "launch",
"name": "Debug",
"program": "${workspaceFolder}/src-tauri/target/debug/copybot",
"program": "${workspaceFolder}/src-tauri/target/debug/keydisplay",
"args": [],
"cwd": "${workspaceFolder}"
}

View File

@ -1,13 +1,6 @@
# copybot
# keydisplay
you may need to install libxdo-dev, check https://github.com/Enigo-rs/Enigo for instructions
if it still doesnt work, you may need to add yourself to the input group on linux
ligma balls
you may need to add yourself to the input group for the key listener to work
`usermod -a -G input $(whoami)`
## config
- default: remove default config notice
- theme: choose between grey / night / day / catppuccin_mocha
- bind: a key from https://docs.rs/rdev/latest/rdev/enum.Key.html
- shift_enter_newline: if true, hold shift while pressing enter for a new line. this is useful for preparing a whole message for things like chat apps (also, when false, delay/spam timeouts may cut off some messages/characters)
config is stored in a folder in your os config directory (found using https://crates.io/crates/dirs)

View File

@ -1,5 +1,5 @@
{
"name": "copybot",
"name": "keydisplay",
"version": "0.1.0",
"description": "",
"type": "module",

View File

@ -1,10 +1,9 @@
[package]
name = "copybot"
name = "keydisplay"
version = "0.1.0"
edition = "2021"
repository = "https://git.stardust.wtf/starlight/copybot"
license = "GPL-2.0-only"
description = "A Tauri App"
authors = ["starlight"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -12,7 +11,7 @@ authors = ["starlight"]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "copybot_lib"
name = "keydisplay_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
@ -28,5 +27,3 @@ dirs = "5.0"
thiserror = "2.0.11"
tauri-plugin-fs = { version = "2.0.0", features = ["watch"] }
rdev = { version = "0.5.3", features = ["serde", "serialize"] }
fastrand = "2.3.0"
enigo = "0.3.0"

View File

@ -11,7 +11,7 @@
"fs:default",
{
"identifier": "fs:allow-app-read-recursive",
"allow": [{"path": "$CONFIG/copybot"}]
"allow": [{"path": "$CONFIG/keydisplay"}]
}
]
}

View File

@ -1,6 +1,7 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::collections::HashMap;
use std::path::PathBuf;
use std::fs;
use thiserror::Error;
#[derive(Error, Debug)]
@ -21,9 +22,8 @@ pub struct Config {
// theme = grey / night / day / catppuccin_mocha
pub default: bool,
pub theme: String,
// bind = "Delete"
pub bind: String,
pub shift_enter_newline: bool,
pub listen: ListenConfig,
pub display: DisplayConfig,
}
// this is hack as fvck
@ -38,14 +38,44 @@ impl Default for Config {
}
}
// [listen]
#[derive(Debug, Serialize, Deserialize)]
pub struct ListenConfig {
// keys = ["KeyZ", "KeyX"]
pub keys: Vec<String>,
// mouse = ["LeftButton", "RightButton"]
pub mouse: Vec<String>,
}
// [display]
#[derive(Debug, Serialize, Deserialize)]
pub struct DisplayConfig {
// "instant" or "ease"
pub press: String,
// display mouse buttons as a key or as an svg of a mouse
pub mouse: String,
// multiply default key length for the key by the float value
pub size: Vec<HashMap<String, f64>>,
// which keys to linebreak after
pub r#break: Vec<String>,
}
impl Config {
// Provide default values
pub fn default() -> Self {
Self {
default: true,
theme: "grey".to_string(),
bind: "Delete".to_string(),
shift_enter_newline: true,
listen: ListenConfig {
keys: vec!["KeyZ".to_string(), "KeyX".to_string(), "MetaLeft".to_string()],
mouse: vec!["Left".to_string(), "Right".to_string()],
},
display: DisplayConfig {
press: "ease".to_string(),
mouse: "key".to_string(),
size: vec![HashMap::new()],
r#break: vec![],
},
}
}

View File

@ -1,13 +1,9 @@
use config::Config;
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use enigo::{
Direction::{Press, Release},
Enigo, Key, Keyboard,
};
use rdev::{listen, Event, EventType};
use std::sync::Mutex;
use std::thread;
use std::{collections::HashMap, sync::Mutex};
use tauri::{ipc::Channel, Manager, State};
use rdev::{Event, EventType, listen};
use std::thread;
mod config;
@ -25,9 +21,14 @@ pub fn run() {
update_config,
get_default,
get_theme,
get_bind,
get_keys,
get_mouse_buttons,
get_mouse_display,
get_press_display,
get_size_display,
get_breaks,
start_listener,
paste_text
label_from_keycode
])
.setup(|app| {
let config = config::Config::load_or_create(get_config_path()).unwrap();
@ -38,7 +39,9 @@ pub fn run() {
let themes = vec!["grey", "night", "day", "catppuccin_mocha"];
if themes.contains(&theme.as_str()) {
println!("Setting theme to: {}", theme);
app.manage(Mutex::new(AppState { config: config }));
app.manage(Mutex::new(AppState {
config: config,
}));
} else {
println!(
"{}",
@ -46,6 +49,7 @@ pub fn run() {
);
}
Ok(())
})
.run(tauri::generate_context!())
@ -58,7 +62,7 @@ pub fn run() {
#[tauri::command]
fn get_config_path() -> std::path::PathBuf {
let config_dir = dirs::config_dir().expect("Config directory not found");
config_dir.join("copybot")
config_dir.join("keydisplay")
}
// update the app state with new config
#[tauri::command]
@ -68,7 +72,7 @@ fn update_config(state: State<'_, Mutex<AppState>>) {
}
#[tauri::command]
fn get_default(state: State<'_, Mutex<AppState>>) -> Result<bool, bool> {
let default = state.lock().unwrap().config.default;
let default = state.lock().unwrap().config.default.clone();
Ok(default)
}
#[tauri::command]
@ -77,48 +81,126 @@ fn get_theme(state: State<'_, Mutex<AppState>>) -> Result<String, String> {
Ok(theme)
}
#[tauri::command]
fn get_bind(state: State<'_, Mutex<AppState>>) -> Result<String, String> {
let bind = state.lock().unwrap().config.bind.clone();
Ok(bind)
fn get_keys(state: State<'_, Mutex<AppState>>) -> Result<Vec<String>, Vec<String>> {
let keys = state.lock().unwrap().config.listen.keys.clone();
Ok(keys)
}
#[tauri::command]
fn get_mouse_buttons(state: State<'_, Mutex<AppState>>) -> Result<Vec<String>, Vec<String>> {
let mouse_buttons = state.lock().unwrap().config.listen.mouse.clone();
Ok(mouse_buttons)
}
#[tauri::command]
fn get_press_display(state: State<'_, Mutex<AppState>>) -> Result<String, String> {
let press_display = state.lock().unwrap().config.display.press.clone();
Ok(press_display)
}
#[tauri::command]
fn get_size_display(state: State<'_, Mutex<AppState>>) -> Result<Vec<HashMap<String, f64>>, Vec<HashMap<String, f64>>> {
let size_display = state.lock().unwrap().config.display.size.clone();
Ok(size_display)
}
#[tauri::command]
fn get_mouse_display(state: State<'_, Mutex<AppState>>) -> Result<String, String> {
let mouse_display = state.lock().unwrap().config.display.mouse.clone();
Ok(mouse_display)
}
#[tauri::command]
fn get_breaks(state: State<'_, Mutex<AppState>>) -> Result<Vec<String>, Vec<String>> {
let breaks = state.lock().unwrap().config.display.r#break.clone();
Ok(breaks)
}
// Input events
#[tauri::command]
fn start_listener(bind: String, channel: Channel<Event>) {
fn start_listener(keys: Vec<String>, m_buttons: Vec<String>, channel: Channel<Event>) {
thread::spawn(move || {
println!("Started listening for bind: {:?}", bind);
listen(move |event| match event.event_type {
println!("Started listening for keys: {:?} and buttons: {:?}", keys, m_buttons);
listen(move |event| {
match event.event_type {
EventType::KeyPress(key) | EventType::KeyRelease(key) => {
if bind == serde_json::to_string(&key).unwrap().replace("\"", "") {
channel.send(event).unwrap()
};
if keys.contains(&serde_json::to_string(&key).unwrap().replace("\"", "")) { channel.send(event).unwrap() };
},
EventType::ButtonPress(button) | EventType::ButtonRelease(button) => {
if m_buttons.contains(&serde_json::to_string(&button).unwrap().replace("\"", "")) { channel.send(event).unwrap() };
},
EventType::MouseMove { x, y } => (),
EventType::Wheel { delta_x, delta_y } => (),
}
_ => (),
})
});
}
// Other rust
#[tauri::command]
fn paste_text(state: State<'_, Mutex<AppState>>, text: String) {
println!("{}", text);
let mut enigo = Enigo::new(&enigo::Settings::default()).unwrap();
fn label_from_keycode(code: &str) -> &str {
match code {
// Keyboard
// Fine as-is
"Alt"|"AltGr"|"End"|"Home"|"Pause" => code,
// All alphabetical keys
c if c.starts_with("Key")
&& c.len() == 4
&& c.chars().nth(3).map_or(false, |c| c.is_ascii_alphabetic()) => &c[3..],
// Number row
c if c.starts_with("Num")
&& c.len() == 4
&& c.chars().nth(3).map_or(false, |c| c.is_numeric()) => &c[3..],
// All F keys
c if c.starts_with("F")
&& c.len() == 2
&& c.chars().nth(1).map_or(false, |c| c.is_numeric()) => c,
// Numpad numbers
c if c.starts_with("Kp")
&& c.len() == 3
&& c.chars().nth(2).map_or(false, |c| c.is_numeric()) => &c[2..],
// Individual mappings
"Backspace" => "🡐",
"CapsLock" => "Caps",
"ControlLeft"|"ControlRight" => "Ctrl",
"Delete"|"KpDelete" => "Del",
"DownArrow" => "",
"Escape" => "Esc",
"LeftArrow" => "",
"MetaLeft"|"MetaRight" => "Super",
"PageDown" => "PgDn",
"PageUp" => "PgUp",
"Return" => "",
"RightArrow" => "",
"ShiftLeft"|"ShiftRight" => "Shift",
// needs an obscure blank character (U+E002D) - css acts weird if its a space or anything the program interprets as one
"Space" => "󠀯",
"Tab" => "",
"UpArrow" => "",
"PrintScreen" => "PrtSc",
"ScrollLock" => "ScrLk",
"NumLock" => "Num",
"BackQuote" => "`",
"Minus" => "-",
"Equal" => "=",
"LeftBracket" => "(",
"RightBracket" => ")",
"SemiColon" => ";",
"Quote" => "\"",
"BackSlash"|"IntlBackslash" => "\\",
"Comma" => ",",
"Dot" => ".",
"Slash"|"KpDivide" => "/",
"Insert" => "Ins",
"KpReturn" => "",
"KpPlus" => "+",
"KpMultiply" => "*",
"Function" => "Fn",
if state.lock().unwrap().config.shift_enter_newline {
for c in text.chars() {
if c == '\n' {
// Press Shift+Enter combination
enigo.key(Key::Shift, Press).unwrap();
enigo.key(Key::Return, Press).unwrap();
enigo.key(Key::Return, Release).unwrap();
enigo.key(Key::Shift, Release).unwrap();
} else {
// Type regular characters normally
enigo.text(&c.to_string()).unwrap();
}
}
} else {
enigo.text(text.as_str()).unwrap();
// Mouse
"Left" => "M1",
"Right" => "M2",
"Middle" => "M3",
&_ => {
println!("Error creating frontend label for keycode: {}, displaying as Unknown", code);
"Unknown"
},
}
}

View File

@ -4,5 +4,5 @@
mod config;
fn main() {
copybot_lib::run()
keydisplay_lib::run()
}

View File

@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "COPYBOT",
"productName": "keydisplay",
"version": "0.1.0",
"identifier": "whatisthisanandroidapp.copybot.starlight",
"identifier": "whatisthisanandroidapp.keydisplay.starlight",
"build": {
"beforeDevCommand": "yarn dev",
"devUrl": "http://localhost:1420",
@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "copybot",
"title": "keydisplay",
"width": 800,
"height": 600
}

View File

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>copybot</title>
<title>keydisplay</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { invoke, Channel } from "@tauri-apps/api/core";
import Key from "./Key.svelte";
import { onMount } from "svelte";
import { listen } from "@tauri-apps/api/event";
import { watchImmediate } from "@tauri-apps/plugin-fs";
@ -19,18 +20,22 @@
| { Wheel: { delta_x: number, delta_y: number } }
}
// app state
let content_tmp: string = $state("");
let content: string = $state("");
// type with props for the Key component
type KeyElement = {
label: string,
code: string,
pressed: boolean
}
// scoping
let bind: string = $state("");
let bind_pressed: boolean;
let keys: Array<string>, mouse_buttons: Array<string>;
let key_elements: Array<KeyElement>, mouse_button_elements: Array<KeyElement>;
let default_config: boolean;
let config_path: string;
let breaks: Set<string>;
onMount(async () => {
// on launch
await handle_config();
@ -55,6 +60,7 @@
// create listener for events
const KeyListener = new Channel<KeyEvent>();
// handle key event by modifying the key_elements and mouse_button_elements arrays
KeyListener.onmessage = (message) => {
switch (Object.keys(message.event_type)[0]) {
case 'KeyPress':
@ -65,6 +71,14 @@
handleKeyEvent(Object.values(message.event_type)[0], false);
break;
case 'ButtonPress':
handleMouseEvent(Object.values(message.event_type)[0], true);
break;
case 'ButtonRelease':
handleMouseEvent(Object.values(message.event_type)[0], false);
break;
default:
console.warn("Unhandled event: ", message);
}
@ -72,18 +86,28 @@
};
async function handleKeyEvent(code: string, isPressed: boolean) {
if(isPressed && !bind_pressed) {
bind_pressed = true;
console.log(`${code} was pressed, pasting..`)
await invoke('paste_text', { text: content });
} else if(!isPressed) {
bind_pressed = false;
key_elements = key_elements.map(key => {
if (key.code === code) {
return { ...key, pressed: isPressed}
}
return key;
});
}
async function handleMouseEvent(code: string, isPressed: boolean) {
mouse_button_elements = mouse_button_elements.map(button => {
if (button.code === code) {
return { ...button, pressed: isPressed}
}
return button;
});
}
// invoke backend key listener that will send the events
await invoke('start_listener', {
bind: bind,
keys: keys,
// named mButtons and not m_buttons because weird naming """conventions""" (WHO CARES)
mButtons: mouse_buttons,
channel: KeyListener
});
});
@ -110,26 +134,83 @@
console.error(`Failed to run get_default from the rust backend: ${error}`);
}
// load the bind
// set display for keypresses
try {
bind = await invoke<string>('get_bind');
} catch (error) {
console.error("Failed to process bind: ", error)
let press_display = await invoke('get_press_display');
switch (press_display) {
case "instant":
case "ease":
root?.setAttribute("data-press-display", press_display);
break;
default:
console.warn('"press" field in [display] of the config is an invalid value, falling back to "ease"');
root?.setAttribute("data-press-display", "ease");
}
} catch (error) {
console.error(`Failed to run get_press_display from the rust backend: ${error}`);
}
async function set_text() {
content = content_tmp
// get linebreaks
try {
breaks = new Set(await invoke<Array<string>>("get_breaks"));
} catch (error) {
console.error(`Failed to get "break" config field from rust backend: ${error}`);
}
// load the keys/mousebuttons
try {
keys = await invoke<Array<string>>('get_keys');
mouse_buttons = await invoke<Array<string>>('get_mouse_buttons');
key_elements = await Promise.all(keys.map(async key => {
return {
label: await invoke<string>("label_from_keycode", { code: key }),
code: key,
pressed: false
}
}));
mouse_button_elements = await Promise.all(mouse_buttons.map(async button => {
return {
label: await invoke<string>("label_from_keycode", { code: button }),
code: button,
pressed: false
};
}));
} catch (error) {
console.error("Failed to process keys: ", error)
}
}
</script>
<link rel="stylesheet" href="/global.css">
<main class="container">
{#if default_config}
<div id="default_config">Docs for configuring this program are at <a href="https://git.stardust.wtf/starlight/copybot/src/branch/main/README.md">https://git.stardust.wtf/starlight/copybot/src/branch/main/README.md</a><br>You can disable this annoying notice by removing the <code>default = true</code> line from <code>{config_path}</code> :)</div>
<div id="default_config">Docs for configuring this program are at <a href="https://git.stardust.wtf/starlight/keydisplay/src/branch/main/README.md">https://git.stardust.wtf/starlight/keydisplay/src/branch/main/README.md</a><br>You can disable this annoying notice by removing the <code>default = true</code> line from <code>{config_path}</code> :)</div>
{/if}
<h1>copybot</h1>
<textarea autocorrect="off" rows="6" bind:value={content_tmp}></textarea>
<button id="set_text" onclick={() => content = content_tmp}>Set</button>
<p>Current bind: <b>{bind}</b></p>
<p>Set to current textarea content: <b>{content == content_tmp}</b></p>
<div id="keys">
{#each key_elements as key (key.code)}
<Key
label={key.label}
code={key.code}
pressed={key.pressed}
>
</Key>
{#if breaks.has(key.code)}
<br>
{/if}
{/each}
</div>
<div id="mouse_buttons">
{#each mouse_button_elements as button (button.code)}
<Key
label={button.label}
code={button.code}
pressed={button.pressed}
>
</Key>
{#if breaks.has(button.code)}
<br>
{/if}
{/each}
</div>
</main>

14
src/routes/Key.svelte Normal file
View File

@ -0,0 +1,14 @@
<!-- generic "key" -->
<script>
export let label = "";
export let code = "";
export let pressed = false;
// Optional: Add press animation
import { quintOut } from "svelte/easing";
import { crossfade } from "svelte/transition";
</script>
<link rel="stylesheet" href="/global.css">
<div class="key" class:pressed={pressed} data-keycode={code}>
{label}
</div>

View File

@ -32,30 +32,30 @@ img {
:root,
[data-selected-theme="grey"] {
--color-app-background: #2F2F2F;
--color-app-background-2: #4D4D4D;
--color-key-background: #363636;
--color-key-background-pressed: #212121;
--color-text: #fff;
--color-button-pressed: #212121;
--color-accent: #737373;
}
[data-selected-theme="night"] {
--color-app-background: #000;
--color-app-background-2: #1B1B1B;
--color-key-background: #0f0f0f;
--color-key-background-pressed: #212121;
--color-text: #eee;
--color-button-pressed: #212121;
--color-accent: #525151;
}
[data-selected-theme="day"] {
--color-app-background: #e0e0e0;
--color-app-background-2: #f0f0f0;
--color-key-background: #fff;
--color-key-background-pressed: #eee;
--color-text: #000;
--color-button-pressed: #000;
--color-accent: #fff;
}
[data-selected-theme="catppuccin_mocha"] {
--color-app-background: #181825;
--color-app-background-2: #1e1e2e;
--color-key-background: #1e1e2e;
--color-key-background-pressed: #313244;
--color-text: #cdd6f4;
--color-button-pressed: #313244;
--color-accent: #f5e0dc;
}
@ -87,28 +87,22 @@ button {
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 400;
font-weight: 500;
font-family: inherit;
color: var(--color-text);
background-color: var(--color-app-background-2);
color: #0f0f0f;
background-color: #ffffff;
transition: border-color 0.25s;
box-shadow: 0 2px 2px rgba(0, 0, 0, 0.2);
cursor: pointer;
outline: none;
}
button#set_text {
margin-top: 5px;
width: 25vw;
margin-inline: auto;
}
button:hover {
border-color: #396cd8;
}
button:active {
border-color: #396cd8;
background-color: var(--color-button-pressed);
background-color: #e8e8e8;
}
a {
@ -131,13 +125,34 @@ div#default_config {
overflow-wrap: break-word;
}
textarea {
background-color: var(--color-app-background-2);
border: 3px solid var(--color-accent);
border-radius: 5px;
font-size: 16px;
width: 80vw;
margin-top: 5px;
margin-inline: auto;
color: var(--color-text);
div#keys,
div#mouse_buttons {
/* horizontal spacing between keys is dependent on font-size of the parent div? */
font-size: 0;
}
.key {
width: 100px;
height: 100px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 20px;
margin: 5px;
border: 5px solid var(--color-accent);
border-radius: 5px;
transition: var(--pressed-transition);
background-color: var(--color-key-background);
cursor: pointer;
user-select: none;
}
[data-press-display="ease"] {
--pressed-transition: background-color 0.1s ease, transform 0.1s ease;
}
.pressed {
background-color: var(--color-key-background-pressed) !important;
transform: scale(0.95);
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}