make keybinds configurable

This commit is contained in:
Joseph Montanaro 2023-09-10 14:04:09 -07:00
parent 5685948608
commit 8d7b01629d
12 changed files with 220 additions and 121 deletions

View File

@ -6,7 +6,6 @@ use is_terminal::IsTerminal;
use serde::{Serialize, Deserialize}; use serde::{Serialize, Deserialize};
use sqlx::SqlitePool; use sqlx::SqlitePool;
use tauri::{ use tauri::{
AppHandle,
Manager, Manager,
GlobalShortcutManager, GlobalShortcutManager,
async_runtime as rt, async_runtime as rt,
@ -27,10 +26,17 @@ pub struct TermConfig {
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
pub struct HotkeyConfig { pub struct HotkeysConfig {
// tauri uses strings to represent keybinds, so we will as well // tauri uses strings to represent keybinds, so we will as well
pub show_window: String, pub show_window: Hotkey,
pub launch_terminal: String, pub launch_terminal: Hotkey,
}
#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
pub struct Hotkey {
pub keys: String,
pub enabled: bool,
} }
@ -49,7 +55,7 @@ pub struct AppConfig {
#[serde(default = "default_term_config")] #[serde(default = "default_term_config")]
pub terminal: TermConfig, pub terminal: TermConfig,
#[serde(default = "default_hotkey_config")] #[serde(default = "default_hotkey_config")]
pub hotkeys: HotkeyConfig, pub hotkeys: HotkeysConfig,
} }
@ -187,42 +193,46 @@ fn default_term_config() -> TermConfig {
} }
fn default_hotkey_config() -> HotkeyConfig { fn default_hotkey_config() -> HotkeysConfig {
HotkeyConfig { HotkeysConfig {
show_window: "alt+shift+C".into(), show_window: Hotkey {keys: "alt+shift+C".into(), enabled: true},
launch_terminal: "alt+shift+T".into(), launch_terminal: Hotkey {keys: "alt+shift+T".into(), enabled: true},
} }
} }
// note: will panic if called before APP is set // note: will panic if called before APP is set
pub fn register_hotkeys(hotkeys: &HotkeyConfig) -> tauri::Result<()> { pub fn register_hotkeys(hotkeys: &HotkeysConfig) -> tauri::Result<()> {
let app = crate::app::APP.get().unwrap(); let app = crate::app::APP.get().unwrap();
let mut manager = app.global_shortcut_manager(); let mut manager = app.global_shortcut_manager();
manager.unregister_all()?; manager.unregister_all()?;
let h = app.app_handle(); if hotkeys.show_window.enabled {
manager.register( let handle = app.app_handle();
&hotkeys.show_window, manager.register(
move || { &hotkeys.show_window.keys,
h.get_window("main") move || {
.map(|w| w.show().error_popup("Failed to show")) handle.get_window("main")
.ok_or(HandlerError::NoMainWindow) .map(|w| w.show().error_popup("Failed to show"))
.error_popup("No main window"); .ok_or(HandlerError::NoMainWindow)
}, .error_popup("No main window");
)?; },
)?;
}
// register() doesn't take an async fn, so we have to use spawn if hotkeys.launch_terminal.enabled {
manager.register( // register() doesn't take an async fn, so we have to use spawn
&hotkeys.launch_terminal, manager.register(
|| { &hotkeys.launch_terminal.keys,
rt::spawn(async { || {
crate::terminal::launch(false) rt::spawn(async {
.await crate::terminal::launch(false)
.error_popup("Failed to launch"); .await
}); .error_popup("Failed to launch");
} });
)?; }
)?;
}
Ok(()) Ok(())
} }

View File

@ -71,7 +71,9 @@ impl AppState {
sv.rebind(new_config.listen_addr, new_config.listen_port).await?; sv.rebind(new_config.listen_addr, new_config.listen_port).await?;
} }
// re-register hotkeys if necessary // re-register hotkeys if necessary
if new_config.hotkeys != live_config.hotkeys { if new_config.hotkeys.show_window != live_config.hotkeys.show_window
|| new_config.hotkeys.launch_terminal != live_config.hotkeys.launch_terminal
{
config::register_hotkeys(&new_config.hotkeys)?; config::register_hotkeys(&new_config.hotkeys)?;
} }

11
src/ui/KeyCombo.svelte Normal file
View File

@ -0,0 +1,11 @@
<script>
export let keys;
</script>
<div class="flex gap-x-[0.2em] items-center">
{#each keys as key, i}
{#if i > 0} + {/if}
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">{key}</kbd>
{/each}
</div>

View File

@ -4,14 +4,13 @@
import Setting from './Setting.svelte'; import Setting from './Setting.svelte';
export let title; export let title;
export let divider = true;
export let value; export let value;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
</script> </script>
<Setting {title} {divider}> <Setting {title}>
<div slot="input"> <div slot="input">
<input <input
type="text" type="text"

View File

@ -0,0 +1,61 @@
<script>
import { createEventDispatcher } from 'svelte';
import KeyCombo from '../KeyCombo.svelte';
export let description;
export let value;
const id = Math.random().toString().slice(2);
const dispatch = createEventDispatcher();
let listening = false;
function listen() {
// don't re-listen if we already are
if (listening) return;
listening = true;
window.addEventListener('keyup', setKeybind, {once: true});
// setTimeout avoids reacting to the click event that we are currently processing
setTimeout(() => window.addEventListener('click', cancel, {once: true}), 0);
}
function setKeybind(event) {
console.log(event);
let keys = [];
if (event.ctrlKey) keys.push('ctrl');
if (event.altKey) keys.push('alt');
if (event.metaKey) keys.push('meta');
if (event.shiftKey) keys.push('shift');
keys.push(event.key);
value.keys = keys.join('+');
dispatch('update', {value});
listening = false;
window.removeEventListener('click', cancel, {once: true});
event.preventDefault();
event.stopPropagation();
}
function cancel() {
listening = false;
window.removeEventListener('keyup', setKeybind, {once: true});
}
</script>
<input
{id}
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={value.enabled}
on:change={() => dispatch('update', {value})}
>
<label for={id} class="cursor-pointer ml-4 text-lg">{description}</label>
<button class="h-12 p-2 rounded border border-neutral cursor-pointer text-center" on:click={listen}>
{#if listening}
Click to cancel
{:else}
<KeyCombo keys={value.keys.split('+')} />
{/if}
</button>

View File

@ -4,8 +4,8 @@
import Setting from './Setting.svelte'; import Setting from './Setting.svelte';
export let title; export let title;
export let divider = true;
export let value; export let value;
export let unit = ''; export let unit = '';
export let min = null; export let min = null;
export let max = null; export let max = null;
@ -60,7 +60,7 @@
</script> </script>
<Setting {title} {divider}> <Setting {title}>
<div slot="input"> <div slot="input">
{#if unit} {#if unit}
<span class="mr-2">{unit}:</span> <span class="mr-2">{unit}:</span>

View File

@ -3,20 +3,20 @@
import ErrorAlert from '../ErrorAlert.svelte'; import ErrorAlert from '../ErrorAlert.svelte';
export let title; export let title;
export let divider = true;
</script> </script>
{#if divider} <div>
<div class="divider"></div> <div class="flex flex-wrap justify-between gap-y-4">
{/if} <h3 class="text-lg font-bold shrink-0">{title}</h3>
<div class="flex flex-wrap justify-between gap-y-4"> {#if $$slots.input}
<h3 class="text-lg font-bold shrink-0">{title}</h3> <slot name="input"></slot>
<slot name="input"></slot> {/if}
</div> </div>
{#if $$slots.description} {#if $$slots.description}
<p class="mt-3"> <p class="mt-3">
<slot name="description"></slot> <slot name="description"></slot>
</p> </p>
{/if} {/if}
</div>

View File

@ -0,0 +1,14 @@
<script>
export let name;
</script>
<div>
<div class="divider mt-0 mb-8">
<h2 class="text-xl font-bold">{name}</h2>
</div>
<div class="space-y-12">
<slot></slot>
</div>
</div>

View File

@ -3,14 +3,13 @@
import Setting from './Setting.svelte'; import Setting from './Setting.svelte';
export let title; export let title;
export let divider = true;
export let value; export let value;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
</script> </script>
<Setting {title} {divider}> <Setting {title}>
<div slot="input"> <div slot="input">
<input <input
type="text" type="text"

View File

@ -4,14 +4,13 @@
import Setting from './Setting.svelte'; import Setting from './Setting.svelte';
export let title; export let title;
export let divider = true; // passed through to Setting
export let value; export let value;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
</script> </script>
<Setting {title} {divider}> <Setting {title}>
<input <input
slot="input" slot="input"
type="checkbox" type="checkbox"

View File

@ -6,6 +6,7 @@
import { appState, completeRequest } from '../lib/state.js'; import { appState, completeRequest } from '../lib/state.js';
import ErrorAlert from '../ui/ErrorAlert.svelte'; import ErrorAlert from '../ui/ErrorAlert.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import KeyCombo from '../ui/KeyCombo.svelte';
// Send response to backend, display error if applicable // Send response to backend, display error if applicable
@ -108,17 +109,15 @@
<div class="w-full flex justify-between"> <div class="w-full flex justify-between">
<Link target={deny} hotkey="Escape"> <Link target={deny} hotkey="Escape">
<button class="btn btn-error justify-self-start"> <button class="btn btn-error justify-self-start">
Deny <span class="mr-2">Deny</span>
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Esc</kbd> <KeyCombo keys={['Esc']} />
</button> </button>
</Link> </Link>
<Link target={approve} hotkey="Enter" shift="{true}"> <Link target={approve} hotkey="Enter" shift="{true}">
<button class="btn btn-success justify-self-end"> <button class="btn btn-success justify-self-end">
Approve <span class="mr-2">Approve</span>
<kbd class="ml-2 normal-case px-1 py-0.5 rounded border border-neutral">Shift</kbd> <KeyCombo keys={['Shift', 'Enter']} />
<span class="mx-0.5">+</span>
<kbd class="normal-case px-1 py-0.5 rounded border border-neutral">Enter</kbd>
</button> </button>
</Link> </Link>
</div> </div>

View File

@ -1,11 +1,18 @@
<script context="module">
import { type } from '@tauri-apps/api/os';
const osType = await type();
</script>
<script> <script>
import { invoke } from '@tauri-apps/api/tauri'; import { invoke } from '@tauri-apps/api/tauri';
import { type } from '@tauri-apps/api/os';
import { appState } from '../lib/state.js'; import { appState } from '../lib/state.js';
import Nav from '../ui/Nav.svelte'; import Nav from '../ui/Nav.svelte';
import Link from '../ui/Link.svelte'; import Link from '../ui/Link.svelte';
import ErrorAlert from '../ui/ErrorAlert.svelte'; import ErrorAlert from '../ui/ErrorAlert.svelte';
import SettingsGroup from '../ui/settings/SettingsGroup.svelte';
import Keybind from '../ui/settings/Keybind.svelte';
import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting } from '../ui/settings'; import { Setting, ToggleSetting, NumericSetting, FileSetting, TextSetting } from '../ui/settings';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
@ -14,6 +21,7 @@
let error = null; let error = null;
async function save() { async function save() {
console.log('updating config');
try { try {
await invoke('save_config', {config: $appState.config}); await invoke('save_config', {config: $appState.config});
} }
@ -22,12 +30,6 @@
$appState.config = await invoke('get_config'); $appState.config = await invoke('get_config');
} }
} }
let osType = '';
type().then(t => osType = t);
console.log($appState.config.terminal);
window.term = $appState.config.terminal;
</script> </script>
@ -36,68 +38,71 @@
</Nav> </Nav>
{#await invoke('get_config') then config} {#await invoke('get_config') then config}
<div class="max-w-lg mx-auto mt-1.5 p-4"> <div class="max-w-lg mx-auto mt-1.5 p-4 space-y-16">
<!-- <h2 class="text-2xl font-bold text-center">Settings</h2> --> <SettingsGroup name="General">
<ToggleSetting title="Start on login" bind:value={$appState.config.start_on_login} on:update={save}>
<svelte:fragment slot="description">
Start Creddy when you log in to your computer.
</svelte:fragment>
</ToggleSetting>
<div class="divider mt-0 mb-8"> <ToggleSetting title="Start minimized" bind:value={$appState.config.start_minimized} on:update={save}>
<h2 class="text-xl font-bold">General</h2> <svelte:fragment slot="description">
</div> Minimize to the system tray at startup.
</svelte:fragment>
<ToggleSetting title="Start on login" divider={false} bind:value={$appState.config.start_on_login} on:update={save}> </ToggleSetting>
<svelte:fragment slot="description">
Start Creddy when you log in to your computer.
</svelte:fragment>
</ToggleSetting>
<ToggleSetting title="Start minimized" bind:value={$appState.config.start_minimized} on:update={save}> <NumericSetting title="Re-hide delay" bind:value={$appState.config.rehide_ms} min={0} unit="Milliseconds" on:update={save}>
<svelte:fragment slot="description"> <svelte:fragment slot="description">
Minimize to the system tray at startup. How long to wait after a request is approved/denied before minimizing
</svelte:fragment> the window to tray. Only applicable if the window was minimized
</ToggleSetting> to tray before the request was received.
</svelte:fragment>
</NumericSetting>
<NumericSetting title="Re-hide delay" bind:value={$appState.config.rehide_ms} min={0} unit="Milliseconds" on:update={save}> <NumericSetting
<svelte:fragment slot="description"> title="Listen port"
How long to wait after a request is approved/denied before minimizing bind:value={$appState.config.listen_port}
the window to tray. Only applicable if the window was minimized min={osType === 'Windows_NT' ? 1 : 0}
to tray before the request was received. on:update={save}
</svelte:fragment> >
</NumericSetting> <svelte:fragment slot="description">
Listen for credentials requests on this port.
(Should be used with <code>$AWS_CONTAINER_CREDENTIALS_FULL_URI</code>)
</svelte:fragment>
</NumericSetting>
<NumericSetting <Setting title="Update credentials">
title="Listen port" <Link slot="input" target="EnterCredentials">
bind:value={$appState.config.listen_port} <button class="btn btn-sm btn-primary">Update</button>
min={osType === 'Windows_NT' ? 1 : 0} </Link>
on:update={save} <svelte:fragment slot="description">
> Update or re-enter your encrypted credentials.
<svelte:fragment slot="description"> </svelte:fragment>
Listen for credentials requests on this port. </Setting>
(Should be used with <code>$AWS_CONTAINER_CREDENTIALS_FULL_URI</code>)
</svelte:fragment>
</NumericSetting>
<Setting title="Update credentials"> <FileSetting
<Link slot="input" target="EnterCredentials"> title="Terminal emulator"
<button class="btn btn-sm btn-primary">Update</button> bind:value={$appState.config.terminal.exec}
</Link> on:update={save}
<svelte:fragment slot="description"> >
Update or re-enter your encrypted credentials. <svelte:fragment slot="description">
</svelte:fragment> Choose your preferred terminal emulator (e.g. <code>gnome-terminal</code> or <code>wt.exe</code>.) May be an absolute path or an executable discoverable on <code>$PATH</code>.
</Setting> </svelte:fragment>
</FileSetting>
</SettingsGroup>
<div class="divider mt-10 mb-8"> <SettingsGroup name="Hotkeys">
<h2 class="text-xl font-bold">Terminal</h2> <div class="space-y-4">
</div> <p>Click on a keybinding to modify it. Use the checkbox to enable or disable a keybinding entirely.</p>
<div class="grid grid-cols-[auto_1fr_auto] gap-y-3 items-center">
<Keybind description="Show Creddy" value={$appState.config.hotkeys.show_window} on:update={save} />
<Keybind description="Launch terminal" value={$appState.config.hotkeys.launch_terminal} on:update={save} />
</div>
</div>
</SettingsGroup>
<FileSetting
title="Emulator"
divider={false}
bind:value={$appState.config.terminal.exec}
on:update={save}
>
<svelte:fragment slot="description">
Choose your preferred terminal emulator (e.g. <code>gnome-terminal</code>, <code>wt.exe</code>.) May be an absolute path or an executable discoverable on <code>$PATH</code>.
</svelte:fragment>
</FileSetting>
</div> </div>
{/await} {/await}