From 5d382494026d3af88d6d33d51b2bfb1d6d39a7a1 Mon Sep 17 00:00:00 2001 From: Xavier Lau Date: Wed, 10 Jul 2024 02:00:17 +0800 Subject: [PATCH] Optimize combo box implementation --- src/component/audio.rs | 4 +- src/component/function.rs | 31 ++++++------ src/component/openai.rs | 34 ++++++------- src/component/quote.rs | 1 + src/component/setting.rs | 25 ++++------ src/main.rs | 1 + src/service/audio.rs | 2 +- src/service/keyboard.rs | 2 +- src/ui/panel/setting.rs | 102 ++++++-------------------------------- src/widget.rs | 70 ++++++++++++++++++++++++++ 10 files changed, 133 insertions(+), 139 deletions(-) create mode 100644 src/widget.rs diff --git a/src/component/audio.rs b/src/component/audio.rs index 12e454e..6bc4449 100644 --- a/src/component/audio.rs +++ b/src/component/audio.rs @@ -31,8 +31,8 @@ impl Audio { Ok(Audio { notification, sink, _stream }) } - pub fn play(&self, audio_src: Source) { - self.sink.append(audio_src); + pub fn play_notification(&self) { + self.sink.append(self.notification.clone()); self.sink.sleep_until_end(); } } diff --git a/src/component/function.rs b/src/component/function.rs index 1c7da39..be532ca 100644 --- a/src/component/function.rs +++ b/src/component/function.rs @@ -1,10 +1,10 @@ // std use std::borrow::Cow; // crates.io -use eframe::egui::WidgetText; use serde::{Deserialize, Serialize}; // self use super::setting::Chat; +use crate::widget::ComboBoxItem; #[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] #[serde(rename_all = "kebab-case")] @@ -23,17 +23,6 @@ impl Function { } } - pub fn basic_all() -> [Self; 2] { - [Self::Rewrite, Self::Translate] - } - - pub fn basic_as_str(&self) -> &'static str { - match self { - Self::Rewrite | Self::RewriteDirectly => "Rewrite", - Self::Translate | Self::TranslateDirectly => "Translate", - } - } - pub fn is_directly(&self) -> bool { matches!(self, Self::RewriteDirectly | Self::TranslateDirectly) } @@ -50,9 +39,19 @@ impl Default for Function { Self::Rewrite } } -#[allow(clippy::from_over_into)] -impl Into for &Function { - fn into(self) -> WidgetText { - self.basic_as_str().into() +impl ComboBoxItem for Function { + type Array = [Self; Self::COUNT]; + + const COUNT: usize = 2; + + fn all() -> Self::Array { + [Self::Rewrite, Self::Translate] + } + + fn as_str(&self) -> &'static str { + match self { + Self::Rewrite | Self::RewriteDirectly => "Rewrite", + Self::Translate | Self::TranslateDirectly => "Translate", + } } } diff --git a/src/component/openai.rs b/src/component/openai.rs index ccda020..5777704 100644 --- a/src/component/openai.rs +++ b/src/component/openai.rs @@ -7,11 +7,10 @@ use async_openai::{ }, Client, }; -use eframe::egui::WidgetText; use serde::{Deserialize, Serialize}; // self use super::setting::Ai; -use crate::prelude::*; +use crate::{prelude::*, widget::ComboBoxItem}; #[derive(Debug)] pub struct OpenAi { @@ -59,35 +58,34 @@ pub enum Model { Gpt35Turbo, } impl Model { - pub const MODEL_URI: &'static str = "https://platform.openai.com/docs/models"; + // pub const MODEL_URI: &'static str = "https://platform.openai.com/docs/models"; pub const PRICE_URI: &'static str = "https://openai.com/pricing"; - pub fn as_str(&self) -> &'static str { - match self { - Self::Gpt4o => "gpt-4o", - Self::Gpt35Turbo => "gpt-3.5-turbo", - } - } - pub fn prices(&self) -> (f32, f32) { match self { Self::Gpt4o => (0.000005, 0.000015), Self::Gpt35Turbo => (0.0000005, 0.0000015), } } - - pub fn all() -> [Self; 2] { - [Self::Gpt4o, Self::Gpt35Turbo] - } } impl Default for Model { fn default() -> Self { Self::Gpt4o } } -#[allow(clippy::from_over_into)] -impl Into for &Model { - fn into(self) -> WidgetText { - self.as_str().into() +impl ComboBoxItem for Model { + type Array = [Self; Self::COUNT]; + + const COUNT: usize = 2; + + fn all() -> Self::Array { + [Self::Gpt4o, Self::Gpt35Turbo] + } + + fn as_str(&self) -> &'static str { + match self { + Self::Gpt4o => "gpt-4o", + Self::Gpt35Turbo => "gpt-3.5-turbo", + } } } diff --git a/src/component/quote.rs b/src/component/quote.rs index deb464d..ac8e666 100644 --- a/src/component/quote.rs +++ b/src/component/quote.rs @@ -4,6 +4,7 @@ use serde::Deserialize; use super::net::{Http, Response, HTTP_CLIENT}; use crate::prelude::*; +// TODO?: actually, we could request this from AI. #[derive(Debug)] pub struct Quoter; impl Quoter { diff --git a/src/component/setting.rs b/src/component/setting.rs index 6885dac..2198ac2 100644 --- a/src/component/setting.rs +++ b/src/component/setting.rs @@ -3,11 +3,10 @@ use std::{borrow::Cow, fs, path::PathBuf}; // crates.io use app_dirs2::AppDataType; use async_openai::config::OPENAI_API_BASE; -use eframe::egui::WidgetText; use serde::{Deserialize, Serialize}; // self use super::{function::Function, openai::Model}; -use crate::{prelude::*, APP_INFO}; +use crate::{prelude::*, widget::ComboBoxItem, APP_INFO}; #[derive(Debug, Default, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -153,23 +152,21 @@ pub enum Language { // English (United Kingdom). EnGb, } -impl Language { - pub fn as_str(&self) -> &'static str { +impl ComboBoxItem for Language { + type Array = [Self; Self::COUNT]; + + const COUNT: usize = 2; + + fn all() -> Self::Array { + [Self::ZhCn, Self::EnGb] + } + + fn as_str(&self) -> &'static str { match self { Self::ZhCn => "zh-CN", Self::EnGb => "en-GB", } } - - pub fn all() -> [Self; 2] { - [Self::ZhCn, Self::EnGb] - } -} -#[allow(clippy::from_over_into)] -impl Into for &Language { - fn into(self) -> WidgetText { - self.as_str().into() - } } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src/main.rs b/src/main.rs index 209b1e9..aed8e46 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,7 @@ mod os; mod service; mod state; mod ui; +mod widget; mod prelude { pub type Result = std::result::Result; diff --git a/src/service/audio.rs b/src/service/audio.rs index 954ad45..43cf388 100644 --- a/src/service/audio.rs +++ b/src/service/audio.rs @@ -18,7 +18,7 @@ impl Audio { loop { match rx.recv().expect("receive must succeed") { - Effect::Notification => audio.play(audio.notification.clone()), + Effect::Notification => audio.play_notification(), Effect::Abort => return, } } diff --git a/src/service/keyboard.rs b/src/service/keyboard.rs index 4e3a3be..2a7c6e7 100644 --- a/src/service/keyboard.rs +++ b/src/service/keyboard.rs @@ -20,7 +20,7 @@ impl Keyboard { loop { let act = rx.recv().expect("receive must succeed"); - tracing::info!("receive action: {act:?}"); + tracing::debug!("receive action: {act:?}"); match act { Action::Copy => kb.copy().expect("keyboard action must succeed"), diff --git a/src/ui/panel/setting.rs b/src/ui/panel/setting.rs index b2abaf0..e29bc96 100644 --- a/src/ui/panel/setting.rs +++ b/src/ui/panel/setting.rs @@ -1,13 +1,10 @@ // std use std::sync::atomic::Ordering; // crates.io -use eframe::egui::{self, *}; +use eframe::egui::*; // self use super::super::UiT; -use crate::{ - air::AiRContext, - component::{function::Function, openai::Model, setting::Language}, -}; +use crate::{air::AiRContext, widget}; #[derive(Debug, Default)] pub struct Setting { @@ -44,7 +41,9 @@ impl UiT for Setting { ui.end_row(); ui.label("Hide on Lost Focus"); - if ui.add(toggle(&mut ctx.components.setting.general.hide_on_lost_focus)).changed() + 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, @@ -53,18 +52,10 @@ impl UiT for Setting { }; ui.end_row(); - ui.label("Active Function"); - ComboBox::from_id_source("Active Function") - .selected_text(&ctx.components.setting.general.active_func) - .show_ui(ui, |ui| { - Function::basic_all().iter().for_each(|f| { - ui.selectable_value( - &mut ctx.components.setting.general.active_func, - f.to_owned(), - f, - ); - }); - }); + ui.add(widget::combo_box( + "Active Function", + &mut ctx.components.setting.general.active_func, + )); ui.end_row(); }); }); @@ -109,20 +100,9 @@ impl UiT for Setting { ui.end_row(); // TODO: we might not need to renew the client if only the model changed. - ui.label("Model"); - ComboBox::from_id_source("Model") - .selected_text(&ctx.components.setting.ai.model) - .show_ui(ui, |ui| { - Model::all().iter().for_each(|m| { - changed |= ui - .selectable_value( - &mut ctx.components.setting.ai.model, - m.to_owned(), - m, - ) - .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. @@ -146,32 +126,11 @@ impl UiT for Setting { // TODO: [`crate::component::setting::Chat`]. ui.collapsing("Translation", |ui| { Grid::new("Translation").num_columns(2).striped(true).show(ui, |ui| { - ui.label("A"); - ComboBox::from_id_source("A") - .selected_text(&ctx.components.setting.chat.translation.a) - .show_ui(ui, |ui| { - Language::all().iter().for_each(|l| { - ui.selectable_value( - &mut ctx.components.setting.chat.translation.a, - l.to_owned(), - l, - ); - }); - }); + // 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.label("B"); - ComboBox::from_id_source("B") - .selected_text(&ctx.components.setting.chat.translation.b) - .show_ui(ui, |ui| { - Language::all().iter().for_each(|l| { - ui.selectable_value( - &mut ctx.components.setting.chat.translation.b, - l.to_owned(), - l, - ); - }); - }); + ui.add(widget::combo_box("B", &mut ctx.components.setting.chat.translation.b)); ui.end_row(); }); }); @@ -198,34 +157,3 @@ impl Default for ApiKeyWidget { Self { label: "show".into(), visibility: true } } } - -// https://github.com/emilk/egui/blob/aa96b257460a07b30489d104fae08d095a9e3a4e/crates/egui_demo_lib/src/demo/toggle_switch.rs#L109. -fn toggle(on: &mut bool) -> impl Widget + '_ { - fn toggle_ui(ui: &mut Ui, on: &mut bool) -> Response { - let desired_size = ui.spacing().interact_size.y * vec2(2.0, 1.0); - let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click()); - - if response.clicked() { - *on = !*on; - - response.mark_changed(); - } - if ui.is_rect_visible(rect) { - let how_on = ui.ctx().animate_bool_responsive(response.id, *on); - let visuals = ui.style().interact_selectable(&response, *on); - let rect = rect.expand(visuals.expansion); - let radius = 0.5 * rect.height(); - - ui.painter().rect(rect, radius, visuals.bg_fill, visuals.bg_stroke); - - let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on); - let center = egui::pos2(circle_x, rect.center().y); - - ui.painter().circle(center, 0.75 * radius, visuals.bg_fill, visuals.fg_stroke); - } - - response - } - - move |ui: &mut Ui| toggle_ui(ui, on) -} diff --git a/src/widget.rs b/src/widget.rs new file mode 100644 index 0000000..63565ac --- /dev/null +++ b/src/widget.rs @@ -0,0 +1,70 @@ +// crates.io +use eframe::egui::{self, *}; + +pub trait ComboBoxItem +where + Self: Sized + Clone + PartialEq, +{ + const COUNT: usize; + + type Array: AsRef<[Self]>; + + // TODO: Rust doesn't support generic const yet. + // `[Self; Self::COUNT]` is not allowed. + fn all() -> Self::Array; + + fn as_str(&self) -> &'static str; +} + +pub fn combo_box<'a, I>(label: &'a str, current: &'a mut I) -> impl Widget + 'a +where + I: Clone + PartialEq + ComboBoxItem, +{ + move |ui: &mut Ui| { + ui.label(label); + + let mut resp = + ComboBox::from_id_source(label).selected_text(current.as_str()).show_ui(ui, |ui| { + I::all().as_ref().iter().fold(false, |changed, i| { + changed | ui.selectable_value(current, i.to_owned(), i.as_str()).changed() + }) + }); + + if let Some(changed) = resp.inner { + if changed { + resp.response.mark_changed(); + } + } + + resp.response + } +} + +// https://github.com/emilk/egui/blob/aa96b257460a07b30489d104fae08d095a9e3a4e/crates/egui_demo_lib/src/demo/toggle_switch.rs#L109. +pub fn toggle(on: &mut bool) -> impl Widget + '_ { + move |ui: &mut Ui| { + let desired_size = ui.spacing().interact_size.y * vec2(2.0, 1.0); + let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click()); + + if response.clicked() { + *on = !*on; + + response.mark_changed(); + } + if ui.is_rect_visible(rect) { + let how_on = ui.ctx().animate_bool_responsive(response.id, *on); + let visuals = ui.style().interact_selectable(&response, *on); + let rect = rect.expand(visuals.expansion); + let radius = 0.5 * rect.height(); + + ui.painter().rect(rect, radius, visuals.bg_fill, visuals.bg_stroke); + + let circle_x = egui::lerp((rect.left() + radius)..=(rect.right() - radius), how_on); + let center = egui::pos2(circle_x, rect.center().y); + + ui.painter().circle(center, 0.75 * radius, visuals.bg_fill, visuals.fg_stroke); + } + + response + } +}