make the thing

This commit is contained in:
starlight 2025-03-18 16:00:57 +13:00
parent b7a9cdbd71
commit 995dedbe82
13 changed files with 122 additions and 341 deletions

2
.vscode/launch.json vendored
View File

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

View File

@ -1,3 +1,3 @@
# keydisplay
# copybot
ligma balls
you may need to install libxdo-dev, check https://github.com/Enigo-rs/Enigo for instructions

View File

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

View File

@ -1,9 +1,10 @@
[package]
name = "keydisplay"
name = "copybot"
version = "0.1.0"
description = "A Tauri App"
authors = ["starlight"]
edition = "2021"
repository = "https://git.stardust.wtf/starlight/copybot"
license = "GPL-2.0-only"
authors = ["starlight"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -11,7 +12,7 @@ edition = "2021"
# 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 = "keydisplay_lib"
name = "copybot_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
@ -27,3 +28,5 @@ 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/keydisplay"}]
"allow": [{"path": "$CONFIG/copybot"}]
}
]
}

View File

@ -1,7 +1,6 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::fs;
use std::path::PathBuf;
use thiserror::Error;
#[derive(Error, Debug)]
@ -22,8 +21,9 @@ pub struct Config {
// theme = grey / night / day / catppuccin_mocha
pub default: bool,
pub theme: String,
pub listen: ListenConfig,
pub display: DisplayConfig,
// bind = "Delete"
pub bind: String,
pub shift_enter_newline: bool,
}
// this is hack as fvck
@ -38,44 +38,14 @@ 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(),
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![],
},
bind: "Delete".to_string(),
shift_enter_newline: true,
}
}

View File

@ -1,9 +1,13 @@
use config::Config;
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
use std::{collections::HashMap, sync::Mutex};
use tauri::{ipc::Channel, Manager, State};
use rdev::{Event, EventType, listen};
use enigo::{
Direction::{Press, Release},
Enigo, Key, Keyboard,
};
use rdev::{listen, Event, EventType};
use std::sync::Mutex;
use std::thread;
use tauri::{ipc::Channel, Manager, State};
mod config;
@ -21,14 +25,9 @@ pub fn run() {
update_config,
get_default,
get_theme,
get_keys,
get_mouse_buttons,
get_mouse_display,
get_press_display,
get_size_display,
get_breaks,
get_bind,
start_listener,
label_from_keycode
paste_text
])
.setup(|app| {
let config = config::Config::load_or_create(get_config_path()).unwrap();
@ -39,9 +38,7 @@ 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!(
"{}",
@ -49,7 +46,6 @@ pub fn run() {
);
}
Ok(())
})
.run(tauri::generate_context!())
@ -62,7 +58,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("keydisplay")
config_dir.join("copybot")
}
// update the app state with new config
#[tauri::command]
@ -72,7 +68,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.clone();
let default = state.lock().unwrap().config.default;
Ok(default)
}
#[tauri::command]
@ -81,126 +77,48 @@ fn get_theme(state: State<'_, Mutex<AppState>>) -> Result<String, String> {
Ok(theme)
}
#[tauri::command]
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)
fn get_bind(state: State<'_, Mutex<AppState>>) -> Result<String, String> {
let bind = state.lock().unwrap().config.bind.clone();
Ok(bind)
}
// Input events
#[tauri::command]
fn start_listener(keys: Vec<String>, m_buttons: Vec<String>, channel: Channel<Event>) {
fn start_listener(bind: String, channel: Channel<Event>) {
thread::spawn(move || {
println!("Started listening for keys: {:?} and buttons: {:?}", keys, m_buttons);
listen(move |event| {
match event.event_type {
EventType::KeyPress(key) | EventType::KeyRelease(key) => {
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 } => (),
println!("Started listening for bind: {:?}", bind);
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()
};
}
_ => (),
})
});
}
// Other rust
#[tauri::command]
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",
fn paste_text(state: State<'_, Mutex<AppState>>, text: String) {
println!("{}", text);
let mut enigo = Enigo::new(&enigo::Settings::default()).unwrap();
// Mouse
"Left" => "M1",
"Right" => "M2",
"Middle" => "M3",
&_ => {
println!("Error creating frontend label for keycode: {}, displaying as Unknown", code);
"Unknown"
},
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();
}
}

View File

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

View File

@ -1,8 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "keydisplay",
"productName": "COPYBOT",
"version": "0.1.0",
"identifier": "whatisthisanandroidapp.keydisplay.starlight",
"identifier": "whatisthisanandroidapp.copybot.starlight",
"build": {
"beforeDevCommand": "yarn dev",
"devUrl": "http://localhost:1420",
@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "keydisplay",
"title": "copybot",
"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>keydisplay</title>
<title>copybot</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">

View File

@ -1,6 +1,5 @@
<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";
@ -20,22 +19,18 @@
| { Wheel: { delta_x: number, delta_y: number } }
}
// type with props for the Key component
type KeyElement = {
label: string,
code: string,
pressed: boolean
}
// app state
let content_tmp: string = $state("");
let content: string = $state("");
// scoping
let keys: Array<string>, mouse_buttons: Array<string>;
let key_elements: Array<KeyElement>, mouse_button_elements: Array<KeyElement>;
let bind: string = $state("");
let bind_pressed: boolean;
let default_config: boolean;
let config_path: string;
let breaks: Set<string>;
onMount(async () => {
// on launch
await handle_config();
@ -60,7 +55,6 @@
// 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':
@ -71,14 +65,6 @@
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);
}
@ -86,28 +72,18 @@
};
async function handleKeyEvent(code: string, isPressed: boolean) {
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;
});
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;
}
}
// invoke backend key listener that will send the events
await invoke('start_listener', {
keys: keys,
// named mButtons and not m_buttons because weird naming """conventions""" (WHO CARES)
mButtons: mouse_buttons,
bind: bind,
channel: KeyListener
});
});
@ -134,83 +110,26 @@
console.error(`Failed to run get_default from the rust backend: ${error}`);
}
// set display for keypresses
// load the bind
try {
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");
}
bind = await invoke<string>('get_bind');
} catch (error) {
console.error(`Failed to run get_press_display from the rust backend: ${error}`);
console.error("Failed to process bind: ", error)
}
}
// 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)
}
async function set_text() {
content = content_tmp
}
</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/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>
<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>
{/if}
<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>
<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>
</main>

View File

@ -1,14 +0,0 @@
<!-- 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-key-background: #363636;
--color-key-background-pressed: #212121;
--color-app-background-2: #4D4D4D;
--color-text: #fff;
--color-button-pressed: #212121;
--color-accent: #737373;
}
[data-selected-theme="night"] {
--color-app-background: #000;
--color-key-background: #0f0f0f;
--color-key-background-pressed: #212121;
--color-app-background-2: #1B1B1B;
--color-text: #eee;
--color-button-pressed: #212121;
--color-accent: #525151;
}
[data-selected-theme="day"] {
--color-app-background: #e0e0e0;
--color-key-background: #fff;
--color-key-background-pressed: #eee;
--color-app-background-2: #f0f0f0;
--color-text: #000;
--color-button-pressed: #000;
--color-accent: #fff;
}
[data-selected-theme="catppuccin_mocha"] {
--color-app-background: #181825;
--color-key-background: #1e1e2e;
--color-key-background-pressed: #313244;
--color-app-background-2: #1e1e2e;
--color-text: #cdd6f4;
--color-button-pressed: #313244;
--color-accent: #f5e0dc;
}
@ -87,22 +87,28 @@ button {
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-weight: 400;
font-family: inherit;
color: #0f0f0f;
background-color: #ffffff;
color: var(--color-text);
background-color: var(--color-app-background-2);
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: #e8e8e8;
background-color: var(--color-button-pressed);
}
a {
@ -125,34 +131,13 @@ div#default_config {
overflow-wrap: break-word;
}
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);
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);
}