diff options
| author | Nathan Reiner <nathan@nathanreiner.xyz> | 2023-07-07 00:26:54 +0200 |
|---|---|---|
| committer | Nathan Reiner <nathan@nathanreiner.xyz> | 2023-07-07 00:26:54 +0200 |
| commit | 179920fa402b81a3ae8cc3c4b415172f71eb8d11 (patch) | |
| tree | 33bd4cb4cceb58ef65ae19a6c8d0f3e4e0ea53ec /src/gui | |
| parent | 9b4aa4e9643b0a5b4a554e455eac269a2472b590 (diff) | |
add gui
Diffstat (limited to 'src/gui')
| -rw-r--r-- | src/gui/circular.rs | 421 | ||||
| -rw-r--r-- | src/gui/easing.rs | 133 | ||||
| -rw-r--r-- | src/gui/mod.rs | 264 |
3 files changed, 818 insertions, 0 deletions
diff --git a/src/gui/circular.rs b/src/gui/circular.rs new file mode 100644 index 0000000..3a35e02 --- /dev/null +++ b/src/gui/circular.rs @@ -0,0 +1,421 @@ +//! Show a circular progress indicator. +use iced::advanced::layout; +use iced::advanced::renderer; +use iced::advanced::widget::tree::{self, Tree}; +use iced::advanced::{Clipboard, Layout, Renderer, Shell, Widget}; +use iced::event; +use iced::mouse; +use iced::time::Instant; +use iced::widget::canvas; +use iced::window::{self, RedrawRequest}; +use iced::{ + Background, Color, Element, Event, Length, Rectangle, Size, Vector, +}; + +use super::easing::{self, Easing}; + +use std::f32::consts::PI; +use std::time::Duration; + +const MIN_RADIANS: f32 = PI / 8.0; +const WRAP_RADIANS: f32 = 2.0 * PI - PI / 4.0; +const BASE_ROTATION_SPEED: u32 = u32::MAX / 80; + +#[allow(missing_debug_implementations)] +pub struct Circular<'a, Theme> +where + Theme: StyleSheet, +{ + size: f32, + bar_height: f32, + style: <Theme as StyleSheet>::Style, + easing: &'a Easing, + cycle_duration: Duration, + rotation_duration: Duration, +} + +impl<'a, Theme> Circular<'a, Theme> +where + Theme: StyleSheet, +{ + /// Creates a new [`Circular`] with the given content. + pub fn new() -> Self { + Circular { + size: 40.0, + bar_height: 4.0, + style: <Theme as StyleSheet>::Style::default(), + easing: &easing::STANDARD, + cycle_duration: Duration::from_millis(600), + rotation_duration: Duration::from_secs(2), + } + } + + /// Sets the size of the [`Circular`]. + pub fn size(mut self, size: f32) -> Self { + self.size = size; + self + } + + /// Sets the bar height of the [`Circular`]. + pub fn bar_height(mut self, bar_height: f32) -> Self { + self.bar_height = bar_height; + self + } + + /// Sets the style variant of this [`Circular`]. + pub fn style(mut self, style: <Theme as StyleSheet>::Style) -> Self { + self.style = style; + self + } + + /// Sets the easing of this [`Circular`]. + pub fn easing(mut self, easing: &'a Easing) -> Self { + self.easing = easing; + self + } + + /// Sets the cycle duration of this [`Circular`]. + pub fn cycle_duration(mut self, duration: Duration) -> Self { + self.cycle_duration = duration / 2; + self + } + + /// Sets the base rotation duration of this [`Circular`]. This is the duration that a full + /// rotation would take if the cycle rotation were set to 0.0 (no expanding or contracting) + pub fn rotation_duration(mut self, duration: Duration) -> Self { + self.rotation_duration = duration; + self + } +} + +impl<'a, Theme> Default for Circular<'a, Theme> +where + Theme: StyleSheet, +{ + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone, Copy)] +enum Animation { + Expanding { + start: Instant, + progress: f32, + rotation: u32, + last: Instant, + }, + Contracting { + start: Instant, + progress: f32, + rotation: u32, + last: Instant, + }, +} + +impl Default for Animation { + fn default() -> Self { + Self::Expanding { + start: Instant::now(), + progress: 0.0, + rotation: 0, + last: Instant::now(), + } + } +} + +impl Animation { + fn next(&self, additional_rotation: u32, now: Instant) -> Self { + match self { + Self::Expanding { rotation, .. } => Self::Contracting { + start: now, + progress: 0.0, + rotation: rotation.wrapping_add(additional_rotation), + last: now, + }, + Self::Contracting { rotation, .. } => Self::Expanding { + start: now, + progress: 0.0, + rotation: rotation.wrapping_add( + BASE_ROTATION_SPEED.wrapping_add( + ((WRAP_RADIANS / (2.0 * PI)) * u32::MAX as f32) as u32, + ), + ), + last: now, + }, + } + } + + fn start(&self) -> Instant { + match self { + Self::Expanding { start, .. } | Self::Contracting { start, .. } => { + *start + } + } + } + + fn last(&self) -> Instant { + match self { + Self::Expanding { last, .. } | Self::Contracting { last, .. } => { + *last + } + } + } + + fn timed_transition( + &self, + cycle_duration: Duration, + rotation_duration: Duration, + now: Instant, + ) -> Self { + let elapsed = now.duration_since(self.start()); + let additional_rotation = ((now - self.last()).as_secs_f32() + / rotation_duration.as_secs_f32() + * (u32::MAX) as f32) as u32; + + match elapsed { + elapsed if elapsed > cycle_duration => { + self.next(additional_rotation, now) + } + _ => self.with_elapsed( + cycle_duration, + additional_rotation, + elapsed, + now, + ), + } + } + + fn with_elapsed( + &self, + cycle_duration: Duration, + additional_rotation: u32, + elapsed: Duration, + now: Instant, + ) -> Self { + let progress = elapsed.as_secs_f32() / cycle_duration.as_secs_f32(); + match self { + Self::Expanding { + start, rotation, .. + } => Self::Expanding { + start: *start, + progress, + rotation: rotation.wrapping_add(additional_rotation), + last: now, + }, + Self::Contracting { + start, rotation, .. + } => Self::Contracting { + start: *start, + progress, + rotation: rotation.wrapping_add(additional_rotation), + last: now, + }, + } + } + + fn rotation(&self) -> f32 { + match self { + Self::Expanding { rotation, .. } + | Self::Contracting { rotation, .. } => { + *rotation as f32 / u32::MAX as f32 + } + } + } +} + +#[derive(Default)] +struct State { + animation: Animation, + cache: canvas::Cache, +} + +impl<'a, Message, Theme> Widget<Message, iced::Renderer<Theme>> + for Circular<'a, Theme> +where + Message: 'a + Clone, + Theme: StyleSheet, +{ + fn tag(&self) -> tree::Tag { + tree::Tag::of::<State>() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn width(&self) -> Length { + Length::Fixed(self.size) + } + + fn height(&self) -> Length { + Length::Fixed(self.size) + } + + fn layout( + &self, + _renderer: &iced::Renderer<Theme>, + limits: &layout::Limits, + ) -> layout::Node { + let limits = limits.width(self.size).height(self.size); + let size = limits.resolve(Size::ZERO); + + layout::Node::new(size) + } + + fn on_event( + &mut self, + tree: &mut Tree, + event: Event, + _layout: Layout<'_>, + _cursor: mouse::Cursor, + _renderer: &iced::Renderer<Theme>, + _clipboard: &mut dyn Clipboard, + shell: &mut Shell<'_, Message>, + ) -> event::Status { + const FRAME_RATE: u64 = 60; + + let state = tree.state.downcast_mut::<State>(); + + if let Event::Window(window::Event::RedrawRequested(now)) = event { + state.animation = state.animation.timed_transition( + self.cycle_duration, + self.rotation_duration, + now, + ); + + state.cache.clear(); + shell.request_redraw(RedrawRequest::At( + now + Duration::from_millis(1000 / FRAME_RATE), + )); + } + + event::Status::Ignored + } + + fn draw( + &self, + tree: &Tree, + renderer: &mut iced::Renderer<Theme>, + theme: &Theme, + _style: &renderer::Style, + layout: Layout<'_>, + _cursor: mouse::Cursor, + _viewport: &Rectangle, + ) { + let state = tree.state.downcast_ref::<State>(); + let bounds = layout.bounds(); + let custom_style = + <Theme as StyleSheet>::appearance(theme, &self.style); + + let geometry = state.cache.draw(renderer, bounds.size(), |frame| { + let track_radius = frame.width() / 2.0 - self.bar_height; + let track_path = canvas::Path::circle(frame.center(), track_radius); + + frame.stroke( + &track_path, + canvas::Stroke::default() + .with_color(custom_style.track_color) + .with_width(self.bar_height), + ); + + let mut builder = canvas::path::Builder::new(); + + let start = state.animation.rotation() * 2.0 * PI; + + match state.animation { + Animation::Expanding { progress, .. } => { + builder.arc(canvas::path::Arc { + center: frame.center(), + radius: track_radius, + start_angle: start, + end_angle: start + + MIN_RADIANS + + WRAP_RADIANS * (self.easing.y_at_x(progress)), + }); + } + Animation::Contracting { progress, .. } => { + builder.arc(canvas::path::Arc { + center: frame.center(), + radius: track_radius, + start_angle: start + + WRAP_RADIANS * (self.easing.y_at_x(progress)), + end_angle: start + MIN_RADIANS + WRAP_RADIANS, + }); + } + } + + let bar_path = builder.build(); + + frame.stroke( + &bar_path, + canvas::Stroke::default() + .with_color(custom_style.bar_color) + .with_width(self.bar_height), + ); + }); + + renderer.with_translation( + Vector::new(bounds.x, bounds.y), + |renderer| { + use iced::advanced::graphics::geometry::Renderer as _; + + renderer.draw(vec![geometry]); + }, + ); + } +} + +impl<'a, Message, Theme> From<Circular<'a, Theme>> + for Element<'a, Message, iced::Renderer<Theme>> +where + Message: Clone + 'a, + Theme: StyleSheet + 'a, +{ + fn from(circular: Circular<'a, Theme>) -> Self { + Self::new(circular) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct Appearance { + /// The [`Background`] of the progress indicator. + pub background: Option<Background>, + /// The track [`Color`] of the progress indicator. + pub track_color: Color, + /// The bar [`Color`] of the progress indicator. + pub bar_color: Color, +} + +impl std::default::Default for Appearance { + fn default() -> Self { + Self { + background: None, + track_color: Color::TRANSPARENT, + bar_color: Color::BLACK, + } + } +} + +/// A set of rules that dictate the style of an indicator. +pub trait StyleSheet { + /// The supported style of the [`StyleSheet`]. + type Style: Default; + + /// Produces the active [`Appearance`] of a indicator. + fn appearance(&self, style: &Self::Style) -> Appearance; +} + +impl StyleSheet for iced::Theme { + type Style = (); + + fn appearance(&self, _style: &Self::Style) -> Appearance { + let palette = self.extended_palette(); + + Appearance { + background: None, + track_color: palette.background.weak.color, + bar_color: palette.primary.base.color, + } + } +} diff --git a/src/gui/easing.rs b/src/gui/easing.rs new file mode 100644 index 0000000..665b332 --- /dev/null +++ b/src/gui/easing.rs @@ -0,0 +1,133 @@ +use iced::Point; + +use lyon_algorithms::measure::PathMeasurements; +use lyon_algorithms::path::{builder::NoAttributes, path::BuilderImpl, Path}; +use once_cell::sync::Lazy; + +pub static EMPHASIZED: Lazy<Easing> = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.05, 0.0], [0.133333, 0.06], [0.166666, 0.4]) + .cubic_bezier_to([0.208333, 0.82], [0.25, 1.0], [1.0, 1.0]) + .build() +}); + +pub static EMPHASIZED_DECELERATE: Lazy<Easing> = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.05, 0.7], [0.1, 1.0], [1.0, 1.0]) + .build() +}); + +pub static EMPHASIZED_ACCELERATE: Lazy<Easing> = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.3, 0.0], [0.8, 0.15], [1.0, 1.0]) + .build() +}); + +pub static STANDARD: Lazy<Easing> = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.2, 0.0], [0.0, 1.0], [1.0, 1.0]) + .build() +}); + +pub static STANDARD_DECELERATE: Lazy<Easing> = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.0, 0.0], [0.0, 1.0], [1.0, 1.0]) + .build() +}); + +pub static STANDARD_ACCELERATE: Lazy<Easing> = Lazy::new(|| { + Easing::builder() + .cubic_bezier_to([0.3, 0.0], [1.0, 1.0], [1.0, 1.0]) + .build() +}); + +pub struct Easing { + path: Path, + measurements: PathMeasurements, +} + +impl Easing { + pub fn builder() -> Builder { + Builder::new() + } + + pub fn y_at_x(&self, x: f32) -> f32 { + let mut sampler = self.measurements.create_sampler( + &self.path, + lyon_algorithms::measure::SampleType::Normalized, + ); + let sample = sampler.sample(x); + + sample.position().y + } +} + +pub struct Builder(NoAttributes<BuilderImpl>); + +impl Builder { + pub fn new() -> Self { + let mut builder = Path::builder(); + builder.begin(lyon_algorithms::geom::point(0.0, 0.0)); + + Self(builder) + } + + /// Adds a line segment. Points must be between 0,0 and 1,1 + pub fn line_to(mut self, to: impl Into<Point>) -> Self { + self.0.line_to(Self::point(to)); + + self + } + + /// Adds a quadratic bézier curve. Points must be between 0,0 and 1,1 + pub fn quadratic_bezier_to( + mut self, + ctrl: impl Into<Point>, + to: impl Into<Point>, + ) -> Self { + self.0 + .quadratic_bezier_to(Self::point(ctrl), Self::point(to)); + + self + } + + /// Adds a cubic bézier curve. Points must be between 0,0 and 1,1 + pub fn cubic_bezier_to( + mut self, + ctrl1: impl Into<Point>, + ctrl2: impl Into<Point>, + to: impl Into<Point>, + ) -> Self { + self.0.cubic_bezier_to( + Self::point(ctrl1), + Self::point(ctrl2), + Self::point(to), + ); + + self + } + + pub fn build(mut self) -> Easing { + self.0.line_to(lyon_algorithms::geom::point(1.0, 1.0)); + self.0.end(false); + + let path = self.0.build(); + let measurements = PathMeasurements::from_path(&path, 0.0); + + Easing { path, measurements } + } + + fn point(p: impl Into<Point>) -> lyon_algorithms::geom::Point<f32> { + let p: Point = p.into(); + lyon_algorithms::geom::point( + p.x.min(1.0).max(0.0), + p.y.min(1.0).max(0.0), + ) + } +} + +impl Default for Builder { + fn default() -> Self { + Self::new() + } +} diff --git a/src/gui/mod.rs b/src/gui/mod.rs new file mode 100644 index 0000000..0c1b9cc --- /dev/null +++ b/src/gui/mod.rs @@ -0,0 +1,264 @@ +use std::io::Write; +use std::thread; +use std::time::Duration; + +use iced::theme::Theme; +use iced::widget::{text, scrollable, container, column, button, text_input, progress_bar}; +use iced::{window, Length}; +use iced::{Application, Element}; +use iced::{Command, Settings}; +use once_cell::sync::Lazy; +use std::sync::Mutex; + +use crate::index::Index; +use crate::searchresult::SearchResult; +use crate::splitter; + +mod easing; +mod circular; + +static SEARCH_ID: Lazy<text_input::Id> = Lazy::new(text_input::Id::unique); +static GENERATE_PROGRESS : Lazy<Mutex<u8>> = Lazy::new(|| { Mutex::new(0) }); + +pub fn run() -> iced::Result { + App::run(Settings { + window: window::Settings { + size: (500, 800), + ..window::Settings::default() + }, + ..Settings::default() + }) +} + +#[derive(Debug)] +enum App { + StartMenu, + Generating(GenState), + Loading, + Search(State), +} + +#[derive(Debug, Default)] +struct GenState { + progress: u8, +} + +#[derive(Debug, Default)] +struct State { + input_value: String, + index : Index, + results : Vec<SearchResult> +} + +struct SearchState { + search : String, + index : Index, +} + +impl SearchState { + pub async fn search(self) -> Vec<SearchResult> { + let search_args = splitter::split_to_words(self.search.clone()); + self.index.search(search_args) + } +} + +#[derive(Clone, Debug)] +enum Message { + Load, + Generate, + Loaded(Index), + GeneratingUpdate(u8), + InputChanged(String), + SearchSubmit, + SearchFinished(Vec<SearchResult>) +} + +async fn load_file() -> Index { + let file = rfd::FileDialog::new().add_filter("index", &["idxs"]) + .set_directory(".") + .set_title("Choose Index File") + .pick_file(); + let file = file.unwrap(); + let file = file.to_str(); + let file = file.unwrap(); + Index::from_file(file) +} + +async fn generate() -> Index { + let file = rfd::FileDialog::new().add_filter("index", &["idxs"]) + .set_directory(".") + .set_title("Choose Index File") + .save_file(); + let file = file.unwrap(); + let file = file.to_str(); + let file = file.unwrap(); + + let input = rfd::FileDialog::new() + .set_directory(".") + .set_title("Choose Directory to Index") + .pick_folder(); + let input = input.unwrap(); + let input = input.to_str(); + let input = input.unwrap(); + Index::generate(input, file, |counter, nof| { + let p = ((counter * 100) / nof) as u8; + *GENERATE_PROGRESS.lock().unwrap() = p; + eprint!("\r\x1b[2K{} of {} files indexed ({}%)", counter, nof, p); + std::io::stdout().flush().ok(); + }) +} + +async fn generate_update_timer() -> u8 { + thread::sleep(Duration::from_millis(100)); + let p; + { + p = *GENERATE_PROGRESS.lock().unwrap(); + } + p +} + +impl Application for App { + type Message = Message; + type Theme = Theme; + type Executor = iced::executor::Default; + type Flags = (); + + fn new(_flags: ()) -> (App, Command<Message>) { + ( + App::StartMenu, + Command::none() + ) + } + + fn title(&self) -> String { + "Index Search".to_string() + } + + fn update(&mut self, message: Message) -> Command<Message> { + match self { + App::StartMenu => { + match message { + Message::Load => { + *self = App::Loading; + Command::batch(vec![ + Command::perform(load_file(), Message::Loaded) + ]) + } + Message::Generate => { + *self = App::Generating(Default::default()); + Command::batch(vec![ + Command::perform(generate(), Message::Loaded), + Command::perform(generate_update_timer(), Message::GeneratingUpdate) + ]) + } + _ => { + Command::none() + } + } + } + App::Loading => { + match message { + Message::Loaded(index) => { + *self = App::Search(State { index, ..Default::default() }); + Command::none() + } + _ => { + Command::none() + } + } + } + App::Generating(state) => { + match message { + Message::Loaded(index) => { + *self = App::Search(State { index, ..Default::default() }); + Command::none() + } + Message::GeneratingUpdate(p) => { + state.progress = p; + Command::perform(generate_update_timer(), Message::GeneratingUpdate) + } + _ => { + Command::none() + } + } + } + App::Search(state) => { + match message { + Message::InputChanged(value) => { + state.input_value = value; + Command::none() + } + Message::SearchSubmit => { + Command::perform( + SearchState { + search: state.input_value.clone(), + index : state.index.clone() + }.search(), + Message::SearchFinished + ) + } + Message::SearchFinished(results) => { + state.results = results.clone(); + Command::none() + } + _ => { + Command::none() + } + } + } + } + } + + fn view(&self) -> Element<Message> { + let container = container(match self { + App::StartMenu => { + column![ + button("Open").on_press(Message::Load), + button("Generate").on_press(Message::Generate), + ].spacing(10).align_items(iced::Alignment::Center) + } + App::Loading => { + column![ + text("Loading"), + circular::Circular::new() + ].spacing(10).align_items(iced::Alignment::Center) + } + App::Generating(state) => { + column![ + text("Generating"), + progress_bar(0.0..=100.0, state.progress as f32) + ].spacing(10).align_items(iced::Alignment::Center) + } + App::Search(State { input_value, results, ..}) => { + let res : Element<Message> = if results.is_empty() { + column![text("There are no results")].spacing(10).into() + } else { + let lines : Vec<Element<_>> = results.iter().map(|r| text(r.path.clone()).into()).collect(); + column(lines).spacing(10).into() + }; + + column![ + text_input("Search", input_value) + .id(SEARCH_ID.clone()) + .on_submit(Message::SearchSubmit) + .on_input(Message::InputChanged) + .padding(5), + scrollable(res).width(Length::Fill) + ].spacing(10) + } + }).center_x() + .padding(10) + .width(Length::Fill) + .height(Length::Fill); + + match self { + App::Search(_) => { + container.into() + } + _ => { + container.center_y().into() + } + } + } +} + |