From c1c9cd7cf171b2f4d98e0abbb6d32bcdb2cc78bc Mon Sep 17 00:00:00 2001 From: Xavier Lau Date: Thu, 11 Jul 2024 05:26:22 +0800 Subject: [PATCH] Hot reload hotkey --- src/air.rs | 47 +++----- src/main.rs | 1 + src/os/unix.rs | 1 + src/os/windows.rs | 1 + src/service.rs | 10 +- src/service/chat.rs | 18 +-- src/service/hotkey.rs | 69 +++++++---- src/ui.rs | 29 ++++- src/ui/panel/setting.rs | 260 +++++++++++++++++++++++----------------- src/ui/util.rs | 8 -- src/util.rs | 27 +++++ src/widget.rs | 51 ++++++++ 12 files changed, 331 insertions(+), 191 deletions(-) delete mode 100644 src/ui/util.rs create mode 100644 src/util.rs diff --git a/src/air.rs b/src/air.rs index ff3488d..d2c52bd 100644 --- a/src/air.rs +++ b/src/air.rs @@ -5,7 +5,12 @@ use eframe::{egui::*, glow::Context as GlowContext, Frame, *}; use tracing_subscriber::{reload::Handle, EnvFilter, Registry}; // self use crate::{ - component::Components, os::Os, prelude::Result, service::Services, state::State, ui::Uis, + component::Components, + os::Os, + prelude::Result, + service::Services, + state::State, + ui::{self, Uis}, }; #[derive(Debug)] @@ -18,55 +23,31 @@ struct AiR { } impl AiR { fn new(log_filter_handle: Handle, ctx: &Context) -> Result { - Self::set_fonts(ctx); + ui::set_fonts(ctx); // To enable SVG. egui_extras::install_image_loaders(ctx); let once = Once::new(); let components = Components::new()?; + + ui::set_font_size(ctx, components.setting.general.font_size); + let state = State::new(log_filter_handle, &components.setting)?; let services = Services::new(ctx, &components, &state)?; let uis = Uis::new(); Ok(Self { once, components, state, services, uis }) } - - fn set_fonts(ctx: &Context) { - let mut fonts = FontDefinitions::default(); - - // Cascadia Code. - fonts.font_data.insert( - "Cascadia Code".into(), - FontData::from_static(include_bytes!("../asset/CascadiaCode.ttf")), - ); - fonts - .families - .entry(FontFamily::Proportional) - .or_default() - .insert(0, "Cascadia Code".into()); - fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "Cascadia Code".into()); - // NotoSerifSC. - fonts.font_data.insert( - "NotoSerifSC".into(), - FontData::from_static(include_bytes!("../asset/NotoSerifSC-VariableFont_wght.ttf")), - ); - fonts.families.entry(FontFamily::Proportional).or_default().insert(1, "NotoSerifSC".into()); - fonts.families.entry(FontFamily::Monospace).or_default().insert(1, "NotoSerifSC".into()); - - ctx.set_fonts(fonts); - } } impl App for AiR { fn update(&mut self, ctx: &Context, _: &mut Frame) { - let air_ctx = AiRContext { + self.uis.draw(AiRContext { egui_ctx: ctx, components: &mut self.components, state: &self.state, services: &mut self.services, - }; - - self.uis.draw(air_ctx); + }); } fn save(&mut self, _: &mut dyn Storage) { @@ -123,8 +104,8 @@ pub fn launch(log_filter_handle: Handle) -> Result<()> { icon_data::from_png_bytes(include_bytes!("../asset/icon.png").as_slice()) .unwrap(), ) - .with_inner_size((720., 360.)) - .with_min_inner_size((720., 360.)), + .with_inner_size((760., 360.)) + .with_min_inner_size((760., 360.)), // TODO?: transparent window. // .with_transparent(true), follow_system_theme: true, diff --git a/src/main.rs b/src/main.rs index f3c849e..a1a14da 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod os; mod service; mod state; mod ui; +mod util; mod widget; mod prelude { diff --git a/src/os/unix.rs b/src/os/unix.rs index e69de29..8b13789 100644 --- a/src/os/unix.rs +++ b/src/os/unix.rs @@ -0,0 +1 @@ + diff --git a/src/os/windows.rs b/src/os/windows.rs index e69de29..8b13789 100644 --- a/src/os/windows.rs +++ b/src/os/windows.rs @@ -0,0 +1 @@ + diff --git a/src/service.rs b/src/service.rs index 58ae238..b202793 100644 --- a/src/service.rs +++ b/src/service.rs @@ -40,8 +40,14 @@ impl Services { let rt = Runtime::new()?; let quoter = Quoter::new(&rt, state.chat.quote.clone()); let is_chatting = Arc::new(AtomicBool::new(false)); - let chat = - Chat::new(keyboard.clone(), &rt, is_chatting.clone(), &components.setting, &state.chat); + let chat = Chat::new( + keyboard.clone(), + &rt, + is_chatting.clone(), + &components.setting.ai, + &components.setting.chat, + &state.chat, + ); let audio = Audio::new()?; let hotkey = Hotkey::new( ctx, diff --git a/src/service/chat.rs b/src/service/chat.rs index 8ea6bb0..000c51d 100644 --- a/src/service/chat.rs +++ b/src/service/chat.rs @@ -16,7 +16,7 @@ use crate::{ component::{ function::Function, openai::OpenAi, - setting::{Chat as ChatSetting, Setting}, + setting::{Ai, Chat as ChatSetting}, }, state::Chat as ChatState, }; @@ -36,12 +36,13 @@ impl Chat { keyboard: Keyboard, rt: &Runtime, is_chatting: Arc, - setting: &Setting, + ai_setting: &Ai, + chat_setting: &ChatSetting, state: &ChatState, ) -> Self { - let openai = Arc::new(Mutex::new(OpenAi::new(setting.ai.clone()))); + let openai = Arc::new(Mutex::new(OpenAi::new(ai_setting.to_owned()))); let openai_ = openai.clone(); - let chat_setting = Arc::new(Mutex::new(setting.chat.clone())); + let chat_setting = Arc::new(Mutex::new(chat_setting.to_owned())); let chat_setting_ = chat_setting.clone(); let input = state.input.clone(); let output = state.output.clone(); @@ -100,11 +101,12 @@ impl Chat { self.tx.send(args).expect("send must succeed"); } - pub fn renew(&mut self, setting: &Setting) { - tracing::info!("renewing chat service"); + pub fn renew(&self, ai_setting: &Ai, chat_setting: &ChatSetting) { + tracing::info!("renewing openai client"); - *self.openai.blocking_lock() = OpenAi::new(setting.ai.clone()); - *self.chat_setting.blocking_lock() = setting.chat.clone(); + *self.openai.blocking_lock() = OpenAi::new(ai_setting.to_owned()); + + chat_setting.clone_into(&mut self.chat_setting.blocking_lock()); } pub fn abort(&self) { diff --git a/src/service/hotkey.rs b/src/service/hotkey.rs index 8f7639f..8689bd4 100644 --- a/src/service/hotkey.rs +++ b/src/service/hotkey.rs @@ -1,5 +1,6 @@ // std use std::{ + fmt::{Debug, Formatter, Result as FmtResult}, sync::{ atomic::{AtomicBool, Ordering}, mpsc::Sender, @@ -11,7 +12,8 @@ use std::{ // crates.io use arboard::Clipboard; use eframe::egui::{Context, ViewportCommand}; -use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; +use global_hotkey::{hotkey::HotKey, GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState}; +use parking_lot::RwLock; // self use super::{audio::Audio, chat::ChatArgs, keyboard::Keyboard}; use crate::{ @@ -20,8 +22,12 @@ use crate::{ prelude::*, }; -#[derive(Debug)] -pub struct Hotkey(Arc); +pub struct Hotkey { + // The manager need to be kept alive during the whole program life. + _manager: GlobalHotKeyManager, + manager: Arc>, + abort: Arc, +} impl Hotkey { pub fn new( ctx: &Context, @@ -31,8 +37,10 @@ impl Hotkey { audio: Audio, tx: Sender, ) -> Result { + let _manager = GlobalHotKeyManager::new().map_err(GlobalHotKeyError::Main)?; let ctx = ctx.to_owned(); - let manager = Manager::new(hotkeys)?; + let manager = Arc::new(RwLock::new(Manager::new(&_manager, hotkeys)?)); + let manager_ = manager.clone(); let abort = Arc::new(AtomicBool::new(false)); let abort_ = abort.clone(); let hk_rx = GlobalHotKeyEvent::receiver(); @@ -40,9 +48,6 @@ impl Hotkey { // TODO: handle the error. thread::spawn(move || { - // Manager must be kept alive. - let manager = manager; - while !abort_.load(Ordering::Relaxed) { // Block the thread until a hotkey event is received. let e = hk_rx.recv().unwrap(); @@ -51,7 +56,7 @@ impl Hotkey { if let HotKeyState::Pressed = e.state { audio.play_notification(); - let (func, keys) = manager.match_func(e.id); + let (func, keys) = manager_.read().match_func(e.id); let to_focus = !func.is_directly(); if to_focus && hide_on_lost_focus.load(Ordering::Relaxed) { @@ -84,41 +89,51 @@ impl Hotkey { } }); - Ok(Self(abort)) + Ok(Self { _manager, manager, abort }) } - // TODO: fn renew. + pub fn renew(&self, hotkeys: &Hotkeys) { + tracing::info!("renewing hotkey manager"); + + let mut manager = self.manager.write(); + + self._manager.unregister_all(&manager.hotkeys).expect("unregister must succeed"); + + *manager = Manager::new(&self._manager, hotkeys).expect("renew must succeed"); + } pub fn abort(&self) { - self.0.store(true, Ordering::Release); + self.abort.store(true, Ordering::Release); + } +} +impl Debug for Hotkey { + fn fmt(&self, f: &mut Formatter) -> FmtResult { + f.debug_struct("Hotkey").field("manager", &"..").field("abort", &self.abort).finish() } } struct Manager { - // The manager need to be kept alive during the whole program life. - _inner: GlobalHotKeyManager, - ids: [u32; 4], + hotkeys: [HotKey; 4], hotkeys_keys: [Keys; 4], } impl Manager { - fn new(hotkeys: &Hotkeys) -> Result { - let _inner = GlobalHotKeyManager::new().map_err(GlobalHotKeyError::Main)?; + fn new(_manager: &GlobalHotKeyManager, hotkeys: &Hotkeys) -> Result { let hotkeys_raw = [ &hotkeys.rewrite, &hotkeys.rewrite_directly, &hotkeys.translate, &hotkeys.translate_directly, ]; - let hotkeys = hotkeys_raw + let hotkeys: [HotKey; 4] = hotkeys_raw .iter() .map(|h| h.parse()) .collect::, _>>() - .map_err(GlobalHotKeyError::Parse)?; + .map_err(GlobalHotKeyError::Parse)? + .try_into() + .expect("array must fit"); - _inner.register_all(&hotkeys).map_err(GlobalHotKeyError::Main)?; + _manager.register_all(&hotkeys).map_err(GlobalHotKeyError::Main)?; - let ids = - hotkeys.iter().map(|h| h.id).collect::>().try_into().expect("array must fit"); let hotkeys_keys = hotkeys_raw .iter() .map(|h| h.parse()) @@ -126,15 +141,17 @@ impl Manager { .try_into() .expect("array must fit"); - Ok(Self { _inner, ids, hotkeys_keys }) + Ok(Self { hotkeys, hotkeys_keys }) } fn match_func(&self, id: u32) -> (Function, Keys) { match id { - i if i == self.ids[0] => (Function::Rewrite, self.hotkeys_keys[0].clone()), - i if i == self.ids[1] => (Function::RewriteDirectly, self.hotkeys_keys[1].clone()), - i if i == self.ids[2] => (Function::Translate, self.hotkeys_keys[2].clone()), - i if i == self.ids[3] => (Function::TranslateDirectly, self.hotkeys_keys[3].clone()), + i if i == self.hotkeys[0].id => (Function::Rewrite, self.hotkeys_keys[0].clone()), + i if i == self.hotkeys[1].id => + (Function::RewriteDirectly, self.hotkeys_keys[1].clone()), + i if i == self.hotkeys[2].id => (Function::Translate, self.hotkeys_keys[2].clone()), + i if i == self.hotkeys[3].id => + (Function::TranslateDirectly, self.hotkeys_keys[3].clone()), _ => unreachable!(), } } diff --git a/src/ui.rs b/src/ui.rs index 18acb56..d1ac731 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,8 +1,6 @@ mod panel; use panel::{Chat, Panel, Setting}; -mod util; - // crates.io use eframe::egui::*; // self @@ -48,3 +46,30 @@ impl Uis { }); } } + +pub fn set_fonts(ctx: &Context) { + let mut fonts = FontDefinitions::default(); + + // Cascadia Code. + fonts.font_data.insert( + "Cascadia Code".into(), + FontData::from_static(include_bytes!("../asset/CascadiaCode.ttf")), + ); + fonts.families.entry(FontFamily::Proportional).or_default().insert(0, "Cascadia Code".into()); + fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "Cascadia Code".into()); + // NotoSerifSC. + fonts.font_data.insert( + "NotoSerifSC".into(), + FontData::from_static(include_bytes!("../asset/NotoSerifSC-VariableFont_wght.ttf")), + ); + fonts.families.entry(FontFamily::Proportional).or_default().insert(1, "NotoSerifSC".into()); + fonts.families.entry(FontFamily::Monospace).or_default().insert(1, "NotoSerifSC".into()); + + ctx.set_fonts(fonts); +} + +pub fn set_font_size(ctx: &Context, font_size: f32) { + ctx.style_mut(|s| { + s.text_styles.values_mut().for_each(|s| s.size = font_size); + }); +} diff --git a/src/ui/panel/setting.rs b/src/ui/panel/setting.rs index 5025a60..5e836f2 100644 --- a/src/ui/panel/setting.rs +++ b/src/ui/panel/setting.rs @@ -4,153 +4,189 @@ use std::sync::atomic::Ordering; use eframe::egui::*; // self use super::super::UiT; -use crate::{air::AiRContext, widget}; +use crate::{ + air::AiRContext, + widget::{self, HotkeyListener}, +}; #[derive(Debug, Default)] pub struct Setting { api_key: ApiKeyWidget, -} -impl Setting { - fn set_font_sizes(&self, ctx: &AiRContext) { - ctx.egui_ctx.style_mut(|s| { - s.text_styles - .values_mut() - .for_each(|s| s.size = ctx.components.setting.general.font_size); - }); - } + hotkey_listeners: [HotkeyListener; 4], } impl UiT for Setting { fn draw(&mut self, ui: &mut Ui, ctx: &mut AiRContext) { - ui.collapsing("General", |ui| { - Grid::new("General").num_columns(2).striped(true).show(ui, |ui| { - ui.label("Font Size"); - ui.horizontal(|ui| { - ui.spacing_mut().slider_width = ui.available_width() - 56.; - - if ui - .add( - Slider::new(&mut ctx.components.setting.general.font_size, 9_f32..=16.) + ScrollArea::vertical().id_source("Setting").auto_shrink(false).show(ui, |ui| { + let margin = 36. + ctx.components.setting.general.font_size * 2.; + + ui.collapsing("General", |ui| { + Grid::new("General").num_columns(2).show(ui, |ui| { + ui.label("Font Size"); + ui.horizontal(|ui| { + ui.spacing_mut().slider_width = ui.available_width() - margin; + + if ui + .add( + Slider::new( + &mut ctx.components.setting.general.font_size, + 9_f32..=16., + ) .step_by(1.) .fixed_decimals(0), - ) + ) + .changed() + { + super::super::set_font_size( + ctx.egui_ctx, + ctx.components.setting.general.font_size, + ); + } + }); + ui.end_row(); + + ui.label("Hide on Lost Focus"); + if ui + .add(widget::toggle(&mut ctx.components.setting.general.hide_on_lost_focus)) .changed() { - self.set_font_sizes(ctx); - } + ctx.state.general.hide_on_lost_focus.store( + ctx.components.setting.general.hide_on_lost_focus, + Ordering::Relaxed, + ); + }; + ui.end_row(); + + // TODO?: separate functions into different panels; then we won't need this. + ui.add(widget::combo_box( + "Active Function", + &mut ctx.components.setting.general.active_func, + )); + ui.end_row(); }); - ui.end_row(); - - ui.label("Hide on Lost Focus"); - if ui - .add(widget::toggle(&mut ctx.components.setting.general.hide_on_lost_focus)) - .changed() - { - ctx.state.general.hide_on_lost_focus.store( - ctx.components.setting.general.hide_on_lost_focus, - Ordering::Relaxed, - ); - }; - ui.end_row(); - - ui.add(widget::combo_box( - "Active Function", - &mut ctx.components.setting.general.active_func, - )); - ui.end_row(); }); - }); - - ui.collapsing("AI", |ui| { - let mut changed = false; - - Grid::new("AI").num_columns(2).striped(true).show(ui, |ui| { - ui.label("API Base"); - let size = ui - .horizontal(|ui| { - let mut size = ui.available_size(); - size.x -= 56.; + ui.collapsing("AI", |ui| { + Grid::new("AI").num_columns(2).show(ui, |ui| { + let mut changed = false; + ui.label("API Base"); + // The available size only works after there is an existing element. + let mut size = ui.available_size(); + size.x -= margin; + ui.horizontal(|ui| { changed |= ui .add_sized( size, TextEdit::singleline(&mut ctx.components.setting.ai.api_base), ) .changed(); + }); + ui.end_row(); + + ui.label("API Key"); + ui.horizontal(|ui| { + changed |= ui + .add_sized( + size, + TextEdit::singleline(&mut ctx.components.setting.ai.api_key) + .password(self.api_key.visibility), + ) + .changed(); - size - }) - .inner; - ui.end_row(); + if ui.button(&self.api_key.label).clicked() { + self.api_key.clicked(); + } + }); + ui.end_row(); - ui.label("API Key"); - ui.horizontal(|ui| { changed |= ui - .add_sized( - size, - TextEdit::singleline(&mut ctx.components.setting.ai.api_key) - .password(self.api_key.visibility), + .add(widget::combo_box("Model", &mut ctx.components.setting.ai.model)) + .changed(); + ui.end_row(); + + ui.label("Temperature"); + ui.spacing_mut().slider_width = size.x; + changed |= ui + .add( + Slider::new(&mut ctx.components.setting.ai.temperature, 0_f32..=2.) + .fixed_decimals(1) + .step_by(0.1), ) .changed(); + ui.end_row(); - if ui.button(&self.api_key.label).clicked() { - self.api_key.clicked(); + if changed { + ctx.services + .chat + .renew(&ctx.components.setting.ai, &ctx.components.setting.chat); } }); - ui.end_row(); - - // TODO: we might not need to renew the client if only the model changed. - changed |= ui - .add(widget::combo_box("Model", &mut ctx.components.setting.ai.model)) - .changed(); - ui.end_row(); - - // TODO: we might not need to renew the client if only the temperature changed. - ui.label("Temperature"); - ui.spacing_mut().slider_width = size.x; - changed |= ui - .add( - Slider::new(&mut ctx.components.setting.ai.temperature, 0_f32..=2.) - .fixed_decimals(1) - .step_by(0.1), - ) - .changed(); - ui.end_row(); }); - if changed { - ctx.services.chat.renew(&ctx.components.setting); - } - }); + ui.collapsing("Translation", |ui| { + Grid::new("Translation").num_columns(2).show(ui, |ui| { + // TODO: A and B should be mutually exclusive. + for (l, c) in [ + ("Language A", &mut ctx.components.setting.chat.translation.a), + ("Language B", &mut ctx.components.setting.chat.translation.b), + ] { + ui.add(widget::combo_box(l, c)); + ui.end_row(); + } + ui.end_row(); + }); + }); - ui.collapsing("Translation", |ui| { - Grid::new("Translation").num_columns(2).striped(true).show(ui, |ui| { - // TODO: A and B should be mutually exclusive. - ui.add(widget::combo_box("A", &mut ctx.components.setting.chat.translation.a)); - ui.end_row(); + ui.collapsing("Hotkey", |ui| { + Grid::new("Hotkey").num_columns(2).show(ui, |ui| { + if self + .hotkey_listeners + .iter_mut() + .zip( + [ + ("Rewrite", &mut ctx.components.setting.hotkeys.rewrite), + ( + "Rewrite Directly", + &mut ctx.components.setting.hotkeys.rewrite_directly, + ), + ("Translate", &mut ctx.components.setting.hotkeys.translate), + ( + "Translate Directly", + &mut ctx.components.setting.hotkeys.translate_directly, + ), + ] + .iter_mut(), + ) + .fold(false, |mut changed, (kl, (l, hk))| { + changed |= kl.listen(ui, l, hk); - ui.add(widget::combo_box("B", &mut ctx.components.setting.chat.translation.b)); - ui.end_row(); + ui.end_row(); + + changed + }) { + ctx.services.hotkey.renew(&ctx.components.setting.hotkeys); + } + }); }); - }); - ui.collapsing("Development", |ui| { - Grid::new("Development").num_columns(2).striped(true).show(ui, |ui| { - if ui - .add(widget::combo_box( - "Log Level", - &mut ctx.components.setting.development.log_level, - )) - .changed() - { - ctx.state - .development - .reload_log_filter( - ctx.components.setting.development.log_level.clone().into(), - ) - .expect("reload must succeed"); - } - ui.end_row(); + ui.collapsing("Development", |ui| { + Grid::new("Development").num_columns(2).show(ui, |ui| { + if ui + .add(widget::combo_box( + "Log Level", + &mut ctx.components.setting.development.log_level, + )) + .changed() + { + ctx.state + .development + .reload_log_filter( + ctx.components.setting.development.log_level.clone().into(), + ) + .expect("reload must succeed"); + } + ui.end_row(); + }); }); }); } diff --git a/src/ui/util.rs b/src/ui/util.rs deleted file mode 100644 index 8d616f0..0000000 --- a/src/ui/util.rs +++ /dev/null @@ -1,8 +0,0 @@ -// crates.io -use eframe::egui::*; - -// TODO?: transparent window. -#[allow(unused)] -pub fn transparent_frame(ctx: &Context) -> Frame { - Frame::central_panel(&ctx.style()).fill(Color32::TRANSPARENT) -} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..62d8def --- /dev/null +++ b/src/util.rs @@ -0,0 +1,27 @@ +// crates.io +use eframe::egui::*; + +// TODO?: transparent window. +#[allow(unused)] +pub fn transparent_frame(ctx: &Context) -> Frame { + Frame::central_panel(&ctx.style()).fill(Color32::TRANSPARENT) +} + +pub fn modifiers_to_string(modifiers: &Modifiers) -> String { + let mut s = String::new(); + + if modifiers.ctrl { + s.push_str("CTRL+"); + } + if modifiers.shift { + s.push_str("SHIFT+"); + } + if modifiers.alt { + s.push_str("ALT+"); + } + if modifiers.command { + s.push_str("META+"); + } + + s +} diff --git a/src/widget.rs b/src/widget.rs index bf51231..324925e 100644 --- a/src/widget.rs +++ b/src/widget.rs @@ -1,5 +1,7 @@ // crates.io use eframe::egui::{self, *}; +// self +use crate::util; pub trait ComboBoxItem where @@ -16,6 +18,55 @@ where fn as_str(&self) -> &'static str; } +#[derive(Debug, Default)] +pub struct HotkeyListener { + listening: bool, +} +impl HotkeyListener { + pub fn listen(&mut self, ui: &mut Ui, label: &str, hotkey: &mut String) -> bool { + ui.label(label); + + let text = if self.listening { "Press Desired Key Combination" } else { hotkey.as_str() }; + let resp = ui.add(Label::new(text).selectable(false).sense(Sense::click())); + let mut changed = false; + + if resp.clicked() { + self.listening = true; + } + if self.listening { + ui.input(|i| { + let mut abort = false; + + for e in &i.events { + if let Event::Key { key, pressed, modifiers, .. } = e { + if *pressed { + // TODO?: do we allow a single key here. + *hotkey = format!("{}{key:?}", util::modifiers_to_string(modifiers)); + changed = true; + abort = true; + + break; + } + } + if let Event::PointerButton { pressed, .. } = e { + abort = *pressed; + } + + if matches!(e, Event::WindowFocused(..)) { + abort = true; + } + } + + if abort { + self.listening = false; + } + }); + } + + changed + } +} + pub fn combo_box<'a, I>(label: &'a str, current: &'a mut I) -> impl Widget + 'a where I: Clone + PartialEq + ComboBoxItem,