mirror of https://github.com/wayvr-org/wayvr.git
whisper transcription working
This commit is contained in:
parent
e032363501
commit
341ee333dc
|
|
@ -5447,6 +5447,17 @@ dependencies = [
|
|||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-clipboard"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"smithay-client-toolkit 0.20.0",
|
||||
"wayland-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol"
|
||||
version = "2.0.2"
|
||||
|
|
@ -6633,6 +6644,7 @@ dependencies = [
|
|||
"slotmap",
|
||||
"smallvec",
|
||||
"smithay",
|
||||
"smithay-clipboard",
|
||||
"smol",
|
||||
"strum",
|
||||
"sysinfo",
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ trait SettingsTab {
|
|||
&mut self,
|
||||
_action: Rc<str>,
|
||||
_config: &mut GeneralConfig,
|
||||
_change_kind: &mut Option<ConfigChangeKind>,
|
||||
_layout: &mut Layout,
|
||||
_state: &mut ParserState,
|
||||
) -> anyhow::Result<()> {
|
||||
|
|
@ -250,7 +251,11 @@ impl<T> Tab<T> for TabSettings<T> {
|
|||
&& let Some(tab) = self.current_tab.as_mut()
|
||||
{
|
||||
let config = frontend.interface.general_config(data);
|
||||
tab.context_menu_custom(name, config, &mut frontend.layout, &mut self.state)?;
|
||||
let mut change_kind : Option<ConfigChangeKind> = None;
|
||||
tab.context_menu_custom(name, config, &mut change_kind, &mut frontend.layout, &mut self.state)?;
|
||||
if let Some(change_kind) = change_kind {
|
||||
frontend.interface.config_changed(data, change_kind);
|
||||
}
|
||||
} else if let (Some(setting), Some(id), Some(value), Some(text), Some(translated)) = {
|
||||
let mut s = name.splitn(5, ';');
|
||||
(s.next(), s.next(), s.next(), s.next(), s.next())
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ use wgui::{
|
|||
widget::label::WidgetLabel,
|
||||
windowing::context_menu::{self},
|
||||
};
|
||||
use wlx_common::{async_executor::AsyncExecutor, config::GeneralConfig};
|
||||
use wlx_common::{async_executor::AsyncExecutor, config::GeneralConfig, dash_interface::ConfigChangeKind};
|
||||
|
||||
use crate::{
|
||||
frontend::FrontendTasks,
|
||||
|
|
@ -61,6 +61,7 @@ impl SettingsTab for State {
|
|||
if !whisper_model_path(model.file_name).exists() {
|
||||
// download failed, set to selection to none
|
||||
par.general_config.whisper_model = "".into();
|
||||
par.config_change_kind.replace(ConfigChangeKind::Other);
|
||||
// reload the tab
|
||||
self.parent_tasks.push(ParentTask::SetTab(TabNameEnum::Features));
|
||||
}
|
||||
|
|
@ -76,6 +77,7 @@ impl SettingsTab for State {
|
|||
Task::WhisperDownloadDone => {
|
||||
if let Some(model) = self.pending_download.take() {
|
||||
par.general_config.whisper_model = model.file_name.into();
|
||||
par.config_change_kind.replace(ConfigChangeKind::Other);
|
||||
|
||||
// reload tab so that the downloaded checkmarks get populated
|
||||
self.parent_tasks.push(ParentTask::SetTab(TabNameEnum::Features));
|
||||
|
|
@ -98,6 +100,7 @@ impl SettingsTab for State {
|
|||
&mut self,
|
||||
action: Rc<str>,
|
||||
config: &mut GeneralConfig,
|
||||
change_kind: &mut Option<ConfigChangeKind>,
|
||||
layout: &mut wgui::layout::Layout,
|
||||
state: &mut wgui::parser::ParserState,
|
||||
) -> anyhow::Result<()> {
|
||||
|
|
@ -115,9 +118,12 @@ impl SettingsTab for State {
|
|||
config.whisper_model = model.file_name.into();
|
||||
if !whisper_model_path(model.file_name).exists() {
|
||||
self.show_whisper_model_dialog_box_download(model)?;
|
||||
} else {
|
||||
change_kind.replace(ConfigChangeKind::Other);
|
||||
}
|
||||
} else {
|
||||
config.whisper_model = "".into();
|
||||
change_kind.replace(ConfigChangeKind::Other);
|
||||
// re-init the whole tab
|
||||
if whisper_any_models_downloaded().unwrap_or_default() {
|
||||
self.show_whisper_model_dialog_box_cleanup()?;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ pub struct WhisperModel {
|
|||
pub file_name: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub url: &'static str,
|
||||
pub hash: &'static str,
|
||||
}
|
||||
|
||||
pub const WHISPER_MODELS: &[WhisperModel] = &[
|
||||
|
|
@ -14,31 +13,26 @@ pub const WHISPER_MODELS: &[WhisperModel] = &[
|
|||
file_name: "ggml-base-q8_0.bin",
|
||||
display_name: "Base Q8 (78MiB)",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base-q8_0.bin",
|
||||
hash: "7bb89bb49ed6955013b166f1b6a6c04584a20fbe",
|
||||
},
|
||||
WhisperModel {
|
||||
file_name: "ggml-small-q8_0.bin",
|
||||
display_name: "Small Q8 (252MiB)",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q8_0.bin",
|
||||
hash: "bcad8a2083f4e53d648d586b7dbc0cd673d8afad",
|
||||
},
|
||||
WhisperModel {
|
||||
file_name: "ggml-large-v3-turbo-q5_0.bin",
|
||||
display_name: "Turbo Q5 (574MiB)",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin",
|
||||
hash: "e050f7970618a659205450ad97eb95a18d69c9ee",
|
||||
},
|
||||
WhisperModel {
|
||||
file_name: "ggml-large-v3-turbo-q8_0.bin",
|
||||
display_name: "Turbo Q8 (874MiB)",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q8_0.bin",
|
||||
hash: "01bf15bedffe9f39d65c1b6ff9b687ea91f59e0e",
|
||||
},
|
||||
WhisperModel {
|
||||
file_name: "ggml-large-v3-turbo.bin",
|
||||
display_name: "Turbo (1.5GiB)",
|
||||
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin",
|
||||
hash: "4af2b29d7ec73d781377bfd1758ca957a807e941",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ xcb = { version = "1.6.0", features = [
|
|||
], optional = true }
|
||||
xkbcommon = { version = "0.8.0" } # 0.9.0 breaks keymap import on some distros
|
||||
whisper-rs = { version = "0.16.0", features = ["vulkan"], optional = true }
|
||||
smithay-clipboard = "0.7.3"
|
||||
|
||||
[build-dependencies]
|
||||
regex.workspace = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
<layout>
|
||||
<macro name="button_style" border="2" border_color="~color_accent_translucent" color="~color_bg" round="6"
|
||||
align_items="center" justify_content="center" padding="6" width="60" height="60" overflow="visible"/>
|
||||
|
||||
<macro name="bg_rect" width="100%" color="~color_bg" round="10" border="2" border_color="~color_accent" />
|
||||
|
||||
<elements>
|
||||
<div flex_direction="column" interactable="0" width="600" height="200">
|
||||
<rectangle macro="bg_rect" padding="10" align_items="center" justify_content="space_between">
|
||||
<div gap="10">
|
||||
<Button macro="button_style" _press="::WhisperTranscribeStart" _release="::WhisperTranscribeStop" tooltip="WHISPER.TRANSCRIBE">
|
||||
<sprite width="38" height="38" color="~color_text" src="icons/mic.svg" />
|
||||
</Button>
|
||||
<Button macro="button_style" _press="::WhisperPaste" tooltip="WHISPER.PASTE">
|
||||
<sprite width="38" height="38" color="~color_text" src_builtin="icons/paste-go.svg" />
|
||||
</Button>
|
||||
</div>
|
||||
<div gap="10">
|
||||
<Button macro="button_style" _press="::WhisperUnload" tooltip="WHISPER.UNLOAD">
|
||||
<sprite width="38" height="38" color="~color_text" src_builtin="watch/settings.svg" />
|
||||
</Button>
|
||||
<Button macro="button_style" _press="::OverlayToggle whisper">
|
||||
<sprite width="38" height="38" color="~color_text" src_builtin="edit/close.svg" />
|
||||
</Button>
|
||||
</div>
|
||||
</rectangle>
|
||||
<div width="100%" height="13" interactable="0" />
|
||||
<rectangle macro="bg_rect" flex_direction="column" padding="10">
|
||||
<div>
|
||||
<label id="transcription" wrap="1" size="20" padding_left="16" padding_right="16" text="Press and hold the microphone button to start transcribing..." />
|
||||
</div>
|
||||
</rectangle>
|
||||
</div>
|
||||
</elements>
|
||||
|
||||
</layout>
|
||||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="white" d="M12 14q-1.25 0-2.125-.875T9 11V5q0-1.25.875-2.125T12 2t2.125.875T15 5v6q0 1.25-.875 2.125T12 14m-1 7v-3.075q-2.6-.35-4.3-2.325T5 11h2q0 2.075 1.463 3.538T12 16t3.538-1.463T17 11h2q0 2.625-1.7 4.6T13 17.925V21z"/></svg>
|
||||
|
After Width: | Height: | Size: 439 B |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="white" d="m18 21l-1.4-1.425L18.175 18H12v-2h6.175L16.6 14.4L18 13l4 4zm3-10h-2V5h-2v3H7V5H5v14h5v2H5q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h4.175q.275-.875 1.075-1.437T12 1q1 0 1.788.563T14.85 3H19q.825 0 1.413.588T21 5zm-9-6q.425 0 .713-.288T13 4t-.288-.712T12 3t-.712.288T11 4t.288.713T12 5"/></svg>
|
||||
|
After Width: | Height: | Size: 520 B |
|
|
@ -112,5 +112,12 @@
|
|||
"RECENTER": "Recenter playspace",
|
||||
"SWITCH_TO_SET": "Switch to set",
|
||||
"TOGGLE_FOR_CURRENT_SET": "Toggle for current set"
|
||||
},
|
||||
"WHISPER": {
|
||||
"TRANSCRIBE": "Press and hold to transcribe text",
|
||||
"PASTE": "Paste transcription to focused overlay",
|
||||
"UNLOAD": "Unload model from VRAM",
|
||||
"MODEL_NOT_DOWNLOADED": "Speech-to-text not set up!",
|
||||
"DOWNLOAD_GUIDANCE": "Set up speech-to-text under settings, features!"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ use smithay::wayland::compositor::{self, BufferAssignment, SurfaceAttributes, se
|
|||
|
||||
use smithay::wayland::selection::data_device::{
|
||||
ClientDndGrabHandler, DataDeviceHandler, DataDeviceState, ServerDndGrabHandler,
|
||||
set_data_device_focus,
|
||||
set_data_device_focus, set_data_device_selection,
|
||||
};
|
||||
use smithay::wayland::selection::{self, SelectionHandler};
|
||||
use smithay::wayland::shell::xdg::{
|
||||
|
|
@ -63,6 +63,7 @@ pub struct Application {
|
|||
pub dmabuf_state: (DmabufState, DmabufGlobal, Option<DmabufFeedback>),
|
||||
pub compositor: compositor::CompositorState,
|
||||
pub xdg_shell: XdgShellState,
|
||||
pub seat: Seat<Application>,
|
||||
pub seat_state: SeatState<Application>,
|
||||
pub shm: ShmState,
|
||||
pub data_device: DataDeviceState,
|
||||
|
|
@ -81,6 +82,15 @@ impl Application {
|
|||
self.image_importer.cleanup();
|
||||
}
|
||||
|
||||
pub fn set_clipboard_text(&mut self, content: &str) {
|
||||
let mime_types = vec![
|
||||
"text/plain;charset=utf-8".to_string(),
|
||||
"text/plain".to_string(),
|
||||
];
|
||||
let user_data = Arc::from(content.as_bytes());
|
||||
set_data_device_selection(&self.display_handle, &self.seat, mime_types, user_data);
|
||||
}
|
||||
|
||||
fn popups_commit(&mut self, surface: &WlSurface) {
|
||||
self.popup_manager.commit(surface);
|
||||
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ impl WvrServerState {
|
|||
display_handle: dh,
|
||||
compositor,
|
||||
xdg_shell,
|
||||
seat,
|
||||
seat_state,
|
||||
shm,
|
||||
data_device,
|
||||
|
|
@ -626,6 +627,10 @@ impl WvrServerState {
|
|||
self.cur_modifiers = modifiers;
|
||||
}
|
||||
|
||||
pub fn set_clipboard(&mut self, content: &str) {
|
||||
self.manager.state.set_clipboard_text(content);
|
||||
}
|
||||
|
||||
pub fn add_external_process(&mut self, pid: u32) -> process::ProcessHandle {
|
||||
self.processes
|
||||
.add(process::Process::External(process::ExternalProcess { pid }))
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ impl OverlayList {
|
|||
);
|
||||
("Mirror", panels_root)
|
||||
}
|
||||
OverlayCategory::Panel => {
|
||||
OverlayCategory::Panel | OverlayCategory::BuiltInPanel => {
|
||||
let icon: Rc<str> = if let Some(icon) = meta.icon.as_ref() {
|
||||
icon.to_string().into()
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -7,3 +7,5 @@ pub mod screen;
|
|||
pub mod toast;
|
||||
pub mod watch;
|
||||
pub mod wayvr;
|
||||
#[cfg(feature = "whisper")]
|
||||
pub mod whisper;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,256 @@
|
|||
use std::{path::Path, rc::Rc, time::Duration};
|
||||
|
||||
use glam::{Affine3A, Quat, Vec3, vec3};
|
||||
use wgui::{
|
||||
components::button::ComponentButton,
|
||||
event::EventCallback,
|
||||
i18n::Translation,
|
||||
log::LogErr,
|
||||
parser::Fetchable,
|
||||
widget::{EventResult, label::WidgetLabel},
|
||||
};
|
||||
use wlx_common::{
|
||||
data_dir,
|
||||
overlays::{BackendAttrib, BackendAttribValue, ToastTopic},
|
||||
windowing::{OverlayWindowState, Positioning},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
gui::{
|
||||
panel::{
|
||||
GuiPanel, NewGuiPanelParams, OnCustomAttribFunc,
|
||||
button::{BUTTON_EVENT_SUFFIX, BUTTON_EVENTS},
|
||||
},
|
||||
timer::GuiTimer,
|
||||
},
|
||||
overlays::toast::Toast,
|
||||
state::AppState,
|
||||
subsystem::{
|
||||
clipboard::{ClipboardProvider, wl::WlClipboardProvider, x11::X11ClipboardProvider},
|
||||
hid::VirtualKey,
|
||||
input::KeyboardFocus,
|
||||
whisper_stt::WhisperStt,
|
||||
},
|
||||
windowing::window::{OverlayCategory, OverlayWindowConfig},
|
||||
};
|
||||
|
||||
pub const WHISPER_NAME: &str = "Speech-to-Text";
|
||||
|
||||
#[derive(Default)]
|
||||
struct WhisperState {
|
||||
whisper_sst: Option<WhisperStt>,
|
||||
clipboard_provider: Option<Box<dyn ClipboardProvider>>,
|
||||
last_transcription: Option<Rc<str>>,
|
||||
}
|
||||
|
||||
pub fn create_whisper(
|
||||
app: &mut AppState,
|
||||
headless: bool,
|
||||
wayland: bool,
|
||||
) -> anyhow::Result<OverlayWindowConfig> {
|
||||
// let clipboard_provider: Option<Box<dyn ClipboardProvider>> = match (headless, wayland) {
|
||||
// (true, _) => None,
|
||||
// (false, true) => WlClipboardProvider::new()
|
||||
// .log_err("Could not create Wayland clipboard provider")
|
||||
// .ok()
|
||||
// .map(|p| Box::new(p) as Box<dyn ClipboardProvider>),
|
||||
// (false, false) => X11ClipboardProvider::new()
|
||||
// .log_err("Could not create X11 clipboard provider")
|
||||
// .ok()
|
||||
// .map(|p| Box::new(p) as Box<dyn ClipboardProvider>),
|
||||
// };
|
||||
|
||||
let state = WhisperState {
|
||||
clipboard_provider: None,
|
||||
..Default::default()
|
||||
};
|
||||
let xml = "gui/whisper.xml";
|
||||
|
||||
let on_custom_attrib: OnCustomAttribFunc = Box::new(move |layout, parser, attribs, _app| {
|
||||
let Ok(button) =
|
||||
parser.fetch_component_from_widget_id_as::<ComponentButton>(attribs.widget_id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
for (name, kind, test_button, test_duration) in &BUTTON_EVENTS {
|
||||
for suffix in BUTTON_EVENT_SUFFIX {
|
||||
let name = &format!("{name}{suffix}");
|
||||
let Some(action) = attribs.get_value(name) else {
|
||||
break;
|
||||
};
|
||||
|
||||
let mut args = action.split_whitespace();
|
||||
let Some(command) = args.next() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let button = button.clone();
|
||||
|
||||
let callback: EventCallback<AppState, WhisperState> = match command {
|
||||
"::WhisperTranscribeStart" => Box::new(move |_common, data, app, state| {
|
||||
if !test_button(data) || !test_duration(&button, app) {
|
||||
return Ok(EventResult::Pass);
|
||||
}
|
||||
|
||||
if let Some(whisper) = state.whisper_sst.as_mut() {
|
||||
let _ = whisper
|
||||
.ptt_start()
|
||||
.log_err("Could not start Whisper transcription");
|
||||
|
||||
return Ok(EventResult::Consumed);
|
||||
}
|
||||
|
||||
let model_path = data_dir::get_path("whisper")
|
||||
.join(app.session.config.whisper_model.as_ref());
|
||||
if model_path.is_file() {
|
||||
state.whisper_sst = WhisperStt::new(model_path)
|
||||
.log_err("Could not create STT provider")
|
||||
.ok();
|
||||
} else {
|
||||
Toast::new(
|
||||
ToastTopic::System,
|
||||
"WHISPER.MODEL_NOT_DOWNLOADED".into(),
|
||||
"WHISPER.DOWNLOAD_GUIDANCE".into(),
|
||||
)
|
||||
.with_timeout(5.)
|
||||
.with_sound(true)
|
||||
.submit(app);
|
||||
}
|
||||
|
||||
Ok(EventResult::Consumed)
|
||||
}),
|
||||
"::WhisperTranscribeStop" => Box::new(move |_common, data, app, state| {
|
||||
if !test_button(data) || !test_duration(&button, app) {
|
||||
return Ok(EventResult::Pass);
|
||||
}
|
||||
|
||||
if let Some(whisper) = state.whisper_sst.as_mut() {
|
||||
let _ = whisper
|
||||
.ptt_end()
|
||||
.log_err("Could not stop Whisper transcription");
|
||||
}
|
||||
Ok(EventResult::Consumed)
|
||||
}),
|
||||
"::WhisperPaste" => Box::new(move |_common, data, app, state| {
|
||||
if !test_button(data) || !test_duration(&button, app) {
|
||||
return Ok(EventResult::Pass);
|
||||
}
|
||||
|
||||
let Some(transcription) = state.last_transcription.as_ref() else {
|
||||
return Ok(EventResult::Consumed);
|
||||
};
|
||||
|
||||
let mut success = false;
|
||||
|
||||
match app.hid_provider.keyboard_focus {
|
||||
KeyboardFocus::WayVR => {
|
||||
if let Some(wvr) = app.wvr_server.as_mut() {
|
||||
wvr.set_clipboard(transcription.as_ref());
|
||||
success = true;
|
||||
}
|
||||
}
|
||||
KeyboardFocus::PhysicalScreen => {
|
||||
if let Some(clip) = state.clipboard_provider.as_mut() {
|
||||
clip.set_clipboard_utf8(transcription.as_ref());
|
||||
success = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send ctrl-v
|
||||
if success {
|
||||
app.hid_provider.send_key_routed(
|
||||
app.wvr_server.as_mut(),
|
||||
VirtualKey::RCtrl,
|
||||
true,
|
||||
);
|
||||
app.hid_provider.send_key_routed(
|
||||
app.wvr_server.as_mut(),
|
||||
VirtualKey::V,
|
||||
true,
|
||||
);
|
||||
app.hid_provider.send_key_routed(
|
||||
app.wvr_server.as_mut(),
|
||||
VirtualKey::RCtrl,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(EventResult::Consumed)
|
||||
}),
|
||||
"::WhisperUnload" => Box::new(move |_common, data, app, state| {
|
||||
if !test_button(data) || !test_duration(&button, app) {
|
||||
return Ok(EventResult::Pass);
|
||||
}
|
||||
|
||||
state.whisper_sst = None;
|
||||
|
||||
Ok(EventResult::Consumed)
|
||||
}),
|
||||
_ => return,
|
||||
};
|
||||
|
||||
let id = layout.add_event_listener(attribs.widget_id, *kind, callback);
|
||||
log::debug!("Registered {action} on {:?} as {id:?}", attribs.widget_id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let params = NewGuiPanelParams {
|
||||
on_custom_attrib: Some(on_custom_attrib),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut panel = GuiPanel::new_from_template(app, xml, state, params)?;
|
||||
panel.extra_attribs.insert(
|
||||
BackendAttrib::Icon,
|
||||
BackendAttribValue::Icon("icons/mic.svg".into()),
|
||||
);
|
||||
|
||||
let label = panel.parser_state.get_widget_id("transcription")?;
|
||||
|
||||
panel
|
||||
.timers
|
||||
.push(GuiTimer::new(Duration::from_millis(100), 0));
|
||||
|
||||
let on_label_tick: EventCallback<AppState, WhisperState> =
|
||||
Box::new(move |common, data, _app, state| {
|
||||
if let Some(whisper_stt) = state.whisper_sst.as_mut()
|
||||
&& let Some(text) = whisper_stt.take_transcription()
|
||||
{
|
||||
let text: Rc<str> = text.into();
|
||||
state.last_transcription = Some(text.clone());
|
||||
|
||||
let label = data.obj.get_as_mut::<WidgetLabel>().unwrap();
|
||||
label.set_text(common, Translation::from_raw_text_rc(text));
|
||||
}
|
||||
|
||||
Ok(EventResult::Pass)
|
||||
});
|
||||
|
||||
panel.layout.add_event_listener(
|
||||
label,
|
||||
wgui::event::EventListenerKind::InternalStateChange,
|
||||
on_label_tick,
|
||||
);
|
||||
|
||||
panel.update_layout(app)?;
|
||||
|
||||
Ok(OverlayWindowConfig {
|
||||
name: WHISPER_NAME.into(),
|
||||
default_state: OverlayWindowState {
|
||||
interactable: true,
|
||||
grabbable: true,
|
||||
transform: Affine3A::from_scale_rotation_translation(
|
||||
Vec3::ONE * 0.5,
|
||||
Quat::IDENTITY,
|
||||
vec3(0.0, 0.0, -0.6),
|
||||
),
|
||||
positioning: Positioning::Anchored,
|
||||
..OverlayWindowState::default()
|
||||
},
|
||||
category: OverlayCategory::BuiltInPanel,
|
||||
..OverlayWindowConfig::from_backend(Box::new(panel))
|
||||
})
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
#[cfg(feature = "wayland")]
|
||||
pub mod wl;
|
||||
#[cfg(feature = "x11")]
|
||||
pub mod x11;
|
||||
|
||||
pub trait ClipboardProvider {
|
||||
fn set_clipboard_utf8(&mut self, content: &str);
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
use smithay_clipboard::Clipboard;
|
||||
use wlx_capture::wayland::smithay_client_toolkit::reexports::client::{Connection, Proxy};
|
||||
|
||||
use crate::subsystem::clipboard::ClipboardProvider;
|
||||
|
||||
pub struct WlClipboardProvider {
|
||||
clipboard: Clipboard,
|
||||
}
|
||||
|
||||
impl WlClipboardProvider {
|
||||
pub fn new() -> anyhow::Result<Self> {
|
||||
let connection = Connection::connect_to_env()?;
|
||||
let clipboard = unsafe { Clipboard::new(connection.display().id().as_ptr() as *mut _) };
|
||||
|
||||
Ok(Self { clipboard })
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipboardProvider for WlClipboardProvider {
|
||||
fn set_clipboard_utf8(&mut self, content: &str) {
|
||||
self.clipboard.store(content.to_owned());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,329 @@
|
|||
use std::{
|
||||
error::Error,
|
||||
sync::{
|
||||
atomic::Ordering,
|
||||
mpsc::{self, Receiver, RecvTimeoutError, Sender},
|
||||
},
|
||||
thread::{self, JoinHandle},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use xcb::{Xid, x};
|
||||
|
||||
use crate::{RUNNING, subsystem::clipboard::ClipboardProvider};
|
||||
|
||||
pub struct X11ClipboardProvider {
|
||||
tx: Sender<Command>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl X11ClipboardProvider {
|
||||
pub fn new() -> Result<Self, Box<dyn Error + Send + Sync + 'static>> {
|
||||
let (conn, screen_num) = xcb::Connection::connect(None)?;
|
||||
let runtime = ClipboardRuntime::new(conn, screen_num)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
|
||||
let worker = thread::spawn(move || {
|
||||
runtime.run(rx);
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
tx,
|
||||
worker: Some(worker),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipboardProvider for X11ClipboardProvider {
|
||||
fn set_clipboard_utf8(&mut self, content: &str) {
|
||||
let (ack_tx, ack_rx) = mpsc::channel();
|
||||
|
||||
if self
|
||||
.tx
|
||||
.send(Command::Set(content.as_bytes().to_vec(), ack_tx))
|
||||
.is_ok()
|
||||
{
|
||||
// make sure the X server has received the ownership request before returning
|
||||
let _ = ack_rx.recv();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for X11ClipboardProvider {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.tx.send(Command::Shutdown);
|
||||
|
||||
if let Some(worker) = self.worker.take() {
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Command {
|
||||
Set(Vec<u8>, Sender<()>),
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Atoms {
|
||||
clipboard: x::Atom,
|
||||
utf8_string: x::Atom,
|
||||
targets: x::Atom,
|
||||
text: x::Atom,
|
||||
text_plain: x::Atom,
|
||||
text_plain_utf8: x::Atom,
|
||||
}
|
||||
|
||||
struct ClipboardRuntime {
|
||||
conn: xcb::Connection,
|
||||
window: x::Window,
|
||||
atoms: Atoms,
|
||||
content: Vec<u8>,
|
||||
owns_clipboard: bool,
|
||||
}
|
||||
|
||||
impl ClipboardRuntime {
|
||||
fn new(
|
||||
conn: xcb::Connection,
|
||||
screen_num: i32,
|
||||
) -> Result<Self, Box<dyn Error + Send + Sync + 'static>> {
|
||||
let atoms = Atoms {
|
||||
clipboard: intern_atom(&conn, b"CLIPBOARD")?,
|
||||
utf8_string: intern_atom(&conn, b"UTF8_STRING")?,
|
||||
targets: intern_atom(&conn, b"TARGETS")?,
|
||||
text: intern_atom(&conn, b"TEXT")?,
|
||||
text_plain: intern_atom(&conn, b"text/plain")?,
|
||||
text_plain_utf8: intern_atom(&conn, b"text/plain;charset=utf-8")?,
|
||||
};
|
||||
|
||||
let setup = conn.get_setup();
|
||||
let screen = setup.roots().nth(screen_num as usize).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::NotFound, "X11 screen not found")
|
||||
})?;
|
||||
|
||||
let window: x::Window = conn.generate_id();
|
||||
|
||||
conn.send_and_check_request(&x::CreateWindow {
|
||||
depth: x::COPY_FROM_PARENT as u8,
|
||||
wid: window,
|
||||
parent: screen.root(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 1,
|
||||
height: 1,
|
||||
border_width: 0,
|
||||
class: x::WindowClass::InputOutput,
|
||||
visual: screen.root_visual(),
|
||||
value_list: &[],
|
||||
})?;
|
||||
|
||||
conn.flush()?;
|
||||
|
||||
Ok(Self {
|
||||
conn,
|
||||
window,
|
||||
atoms,
|
||||
content: Vec::new(),
|
||||
owns_clipboard: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn run(mut self, rx: Receiver<Command>) {
|
||||
while RUNNING.load(Ordering::Relaxed) {
|
||||
self.drain_x_events();
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(10)) {
|
||||
Ok(command) => {
|
||||
if !self.handle_command(command) {
|
||||
break;
|
||||
}
|
||||
|
||||
while let Ok(command) = rx.try_recv() {
|
||||
if !self.handle_command(command) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
Err(RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_command(&mut self, command: Command) -> bool {
|
||||
match command {
|
||||
Command::Set(content, ack) => {
|
||||
self.content = content;
|
||||
self.become_clipboard_owner();
|
||||
let _ = ack.send(());
|
||||
true
|
||||
}
|
||||
Command::Shutdown => {
|
||||
self.release_clipboard_if_owned();
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn become_clipboard_owner(&mut self) {
|
||||
self.conn.send_request(&x::SetSelectionOwner {
|
||||
owner: self.window,
|
||||
selection: self.atoms.clipboard,
|
||||
time: x::CURRENT_TIME,
|
||||
});
|
||||
|
||||
self.owns_clipboard = true;
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
|
||||
fn release_clipboard_if_owned(&mut self) {
|
||||
if !self.owns_clipboard {
|
||||
return;
|
||||
}
|
||||
|
||||
self.conn.send_request(&x::SetSelectionOwner {
|
||||
owner: x::Window::none(),
|
||||
selection: self.atoms.clipboard,
|
||||
time: x::CURRENT_TIME,
|
||||
});
|
||||
|
||||
self.owns_clipboard = false;
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
|
||||
fn drain_x_events(&mut self) {
|
||||
loop {
|
||||
match self.conn.poll_for_event() {
|
||||
Ok(Some(event)) => self.handle_x_event(event),
|
||||
Ok(None) => break,
|
||||
Err(xcb::Error::Protocol(_)) => continue,
|
||||
Err(xcb::Error::Connection(_)) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_x_event(&mut self, event: xcb::Event) {
|
||||
match event {
|
||||
xcb::Event::X(x::Event::SelectionRequest(req)) => {
|
||||
self.handle_selection_request(&req);
|
||||
}
|
||||
xcb::Event::X(x::Event::SelectionClear(ev)) => {
|
||||
if ev.selection() == self.atoms.clipboard {
|
||||
self.owns_clipboard = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_selection_request(&self, req: &x::SelectionRequestEvent) {
|
||||
if !self.owns_clipboard || req.selection() != self.atoms.clipboard {
|
||||
self.send_selection_notify(req, x::Atom::none());
|
||||
return;
|
||||
}
|
||||
|
||||
let property = if req.property().is_none() {
|
||||
// compatibility with old clients that use XCB_NONE for property
|
||||
req.target()
|
||||
} else {
|
||||
req.property()
|
||||
};
|
||||
|
||||
let ok = if req.target() == self.atoms.targets {
|
||||
self.write_targets(req.requestor(), property)
|
||||
} else if let Some(type_atom) = self.type_for_text_target(req.target()) {
|
||||
self.write_text(req.requestor(), property, type_atom)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
self.send_selection_notify(req, if ok { property } else { x::Atom::none() });
|
||||
}
|
||||
|
||||
fn supported_targets(&self) -> Vec<x::Atom> {
|
||||
let mut targets = vec![
|
||||
self.atoms.targets,
|
||||
self.atoms.utf8_string,
|
||||
self.atoms.text_plain_utf8,
|
||||
self.atoms.text_plain,
|
||||
];
|
||||
|
||||
// STRING/TEXT are not UTF-8 targets. Only advertise them when the
|
||||
// content is representable as plain ASCII.
|
||||
if self.content.is_ascii() {
|
||||
targets.push(self.atoms.text);
|
||||
targets.push(x::ATOM_STRING);
|
||||
}
|
||||
|
||||
targets
|
||||
}
|
||||
|
||||
fn type_for_text_target(&self, target: x::Atom) -> Option<x::Atom> {
|
||||
if target == self.atoms.utf8_string
|
||||
|| target == self.atoms.text_plain_utf8
|
||||
|| target == self.atoms.text_plain
|
||||
{
|
||||
Some(target)
|
||||
} else if self.content.is_ascii() && (target == self.atoms.text || target == x::ATOM_STRING)
|
||||
{
|
||||
Some(target)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn write_targets(&self, requestor: x::Window, property: x::Atom) -> bool {
|
||||
let targets = self.supported_targets();
|
||||
|
||||
self.conn
|
||||
.send_and_check_request(&x::ChangeProperty {
|
||||
mode: x::PropMode::Replace,
|
||||
window: requestor,
|
||||
property,
|
||||
r#type: x::ATOM_ATOM,
|
||||
data: targets.as_slice(),
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn write_text(&self, requestor: x::Window, property: x::Atom, type_atom: x::Atom) -> bool {
|
||||
self.conn
|
||||
.send_and_check_request(&x::ChangeProperty {
|
||||
mode: x::PropMode::Replace,
|
||||
window: requestor,
|
||||
property,
|
||||
r#type: type_atom,
|
||||
data: self.content.as_slice(),
|
||||
})
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
fn send_selection_notify(&self, req: &x::SelectionRequestEvent, property: x::Atom) {
|
||||
let event = x::SelectionNotifyEvent::new(
|
||||
req.time(),
|
||||
req.requestor(),
|
||||
req.selection(),
|
||||
req.target(),
|
||||
property,
|
||||
);
|
||||
|
||||
let _ = self.conn.send_and_check_request(&x::SendEvent {
|
||||
propagate: false,
|
||||
destination: x::SendEventDest::Window(req.requestor()),
|
||||
event_mask: x::EventMask::NO_EVENT,
|
||||
event: &event,
|
||||
});
|
||||
|
||||
let _ = self.conn.flush();
|
||||
}
|
||||
}
|
||||
|
||||
fn intern_atom(conn: &xcb::Connection, name: &[u8]) -> xcb::Result<x::Atom> {
|
||||
let cookie = conn.send_request(&x::InternAtom {
|
||||
only_if_exists: false,
|
||||
name,
|
||||
});
|
||||
|
||||
Ok(conn.wait_for_reply(cookie)?.atom())
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ pub mod hid;
|
|||
pub mod input;
|
||||
pub mod notifications;
|
||||
|
||||
#[cfg(feature = "whisper")]
|
||||
pub mod clipboard;
|
||||
|
||||
#[cfg(feature = "whisper")]
|
||||
pub mod whisper_stt;
|
||||
|
||||
|
|
|
|||
|
|
@ -136,6 +136,13 @@ where
|
|||
let grab_help = OverlayWindowData::from_config(create_grab_help(app)?);
|
||||
me.add(grab_help, app);
|
||||
|
||||
#[cfg(feature = "whisper")]
|
||||
{
|
||||
use crate::overlays::whisper::create_whisper;
|
||||
let whisper = OverlayWindowData::from_config(create_whisper(app, headless, wayland)?);
|
||||
me.add(whisper, app);
|
||||
}
|
||||
|
||||
let custom_panels = app.session.config.custom_panels.clone();
|
||||
for name in custom_panels {
|
||||
let Some(panel) = create_custom(app, name) else {
|
||||
|
|
|
|||
|
|
@ -57,7 +57,10 @@ pub enum OverlayCategory {
|
|||
Internal,
|
||||
Keyboard,
|
||||
Dashboard,
|
||||
/// User-provided custom panels
|
||||
Panel,
|
||||
/// Same as Panel but does not support Modify and Reload
|
||||
BuiltInPanel,
|
||||
Screen,
|
||||
Mirror,
|
||||
WayVR,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ use smithay_client_toolkit::reexports::{
|
|||
},
|
||||
};
|
||||
|
||||
pub use smithay_client_toolkit;
|
||||
pub use wayland_client;
|
||||
use wayland_client::{
|
||||
Connection, Dispatch, EventQueue, Proxy, QueueHandle,
|
||||
|
|
|
|||
Loading…
Reference in New Issue