419 lines
16 KiB
Rust
419 lines
16 KiB
Rust
use std::collections::HashSet;
|
|
use std::ops::Range;
|
|
|
|
use galaxyui::elements::{
|
|
Align, ConstrainedBox, Container, CornerRadius, Dismiss, Empty, Fill, Flex, ParentElement,
|
|
Radius, SavePosition, ScrollStateHandle, Scrollable, ScrollableElement, Shrinkable,
|
|
UniformList, UniformListState,
|
|
};
|
|
use galaxyui::presenter::ChildView;
|
|
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
|
use galaxyui::{
|
|
AppContext, Element, Entity, FocusContext, ModelHandle, SingletonEntity, TypedActionView, View,
|
|
ViewContext, ViewHandle, WeakViewHandle,
|
|
};
|
|
use itertools::Itertools;
|
|
use lazy_static::lazy_static;
|
|
|
|
use super::searcher::{EmbeddingSearchItemAction, EmbeddingSearchMixer};
|
|
use crate::appearance::Appearance;
|
|
use crate::cloud_object::Space;
|
|
use crate::search::notebook_embedding::notebooks::CloudNotebooksDataSource;
|
|
use crate::search::notebook_embedding::workflows::CloudWorkflowsDataSource;
|
|
use crate::search::result_renderer::{QueryResultRenderer, QueryResultRendererStyles};
|
|
use crate::search::search_bar::{SearchBar, SearchBarEvent, SearchBarState, SearchResultOrdering};
|
|
|
|
const DEFAULT_PLACEHOLDER_TEXT: &str = "Search for a reference";
|
|
|
|
lazy_static! {
|
|
static ref QUERY_RESULT_RENDERER_STYLES: QueryResultRendererStyles =
|
|
QueryResultRendererStyles {
|
|
result_item_height_fn: |appearance| {
|
|
styles::line_height_sensitive_vertical_padding(appearance)
|
|
+ styles::name_font_size(appearance)
|
|
},
|
|
panel_drop_shadow: styles::panel_drop_shadow(),
|
|
panel_corner_radius: CornerRadius::with_all(Radius::Pixels(styles::CORNER_RADIUS)),
|
|
result_vertical_padding: 4.,
|
|
..Default::default()
|
|
};
|
|
}
|
|
|
|
pub enum EmbeddingSearchEvent {
|
|
ItemSelected {
|
|
payload: Box<EmbeddingSearchItemAction>,
|
|
},
|
|
Close,
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub enum EmbeddingSearchAction {
|
|
ResultClicked {
|
|
result_index: usize,
|
|
result_action: Box<EmbeddingSearchItemAction>,
|
|
},
|
|
Close,
|
|
}
|
|
|
|
pub struct EmbeddingSearchMenu {
|
|
/// The space of the object we're embedding into.
|
|
embedding_space: Space,
|
|
list_state: UniformListState,
|
|
handle: WeakViewHandle<Self>,
|
|
scroll_state: ScrollStateHandle,
|
|
search_bar: ViewHandle<SearchBar<EmbeddingSearchItemAction>>,
|
|
search_bar_state: ModelHandle<SearchBarState<EmbeddingSearchItemAction>>,
|
|
mixer: ModelHandle<EmbeddingSearchMixer>,
|
|
}
|
|
|
|
impl EmbeddingSearchMenu {
|
|
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
|
let search_bar_state = ctx.add_model(|_| {
|
|
SearchBarState::new(SearchResultOrdering::TopDown).run_query_on_buffer_empty()
|
|
});
|
|
|
|
ctx.observe(&search_bar_state, |_, _, ctx| {
|
|
ctx.notify();
|
|
});
|
|
|
|
let mixer = ctx.add_model(|_| EmbeddingSearchMixer::new());
|
|
|
|
let ui_font_family = Appearance::as_ref(ctx).ui_font_family();
|
|
let search_bar = ctx.add_typed_action_view(|ctx| {
|
|
SearchBar::new(
|
|
mixer.clone(),
|
|
search_bar_state.clone(),
|
|
DEFAULT_PLACEHOLDER_TEXT,
|
|
|result_index, result| {
|
|
QueryResultRenderer::new(
|
|
result,
|
|
format!("QueryResultRenderer:{result_index}"),
|
|
|result_index, result_action, event_ctx| {
|
|
event_ctx.dispatch_typed_action(EmbeddingSearchAction::ResultClicked {
|
|
result_index,
|
|
result_action: Box::new(result_action),
|
|
})
|
|
},
|
|
*QUERY_RESULT_RENDERER_STYLES,
|
|
)
|
|
},
|
|
ctx,
|
|
)
|
|
.with_font_family(ui_font_family, ctx)
|
|
});
|
|
|
|
ctx.subscribe_to_view(&search_bar, |me, _handle, event, ctx| {
|
|
me.handle_search_bar_event(event, ctx);
|
|
});
|
|
|
|
ctx.subscribe_to_model(&search_bar_state, |me, _handle, event, ctx| {
|
|
me.handle_search_bar_event(event, ctx);
|
|
});
|
|
|
|
Self {
|
|
embedding_space: Default::default(),
|
|
search_bar,
|
|
search_bar_state,
|
|
handle: ctx.handle(),
|
|
mixer,
|
|
scroll_state: Default::default(),
|
|
list_state: Default::default(),
|
|
}
|
|
}
|
|
|
|
fn reset_embedding_search_mixer(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.mixer.update(ctx, |mixer, ctx| {
|
|
mixer.reset(ctx);
|
|
|
|
mixer.add_sync_source(
|
|
CloudWorkflowsDataSource::new(self.embedding_space, ctx),
|
|
HashSet::new(),
|
|
);
|
|
mixer.add_sync_source(
|
|
CloudNotebooksDataSource::new(self.embedding_space, ctx),
|
|
HashSet::new(),
|
|
);
|
|
ctx.notify();
|
|
})
|
|
}
|
|
|
|
/// Set the space of the object being embedded into. This affects which objects can be
|
|
/// embedded.
|
|
pub fn set_embedding_space(&mut self, space: Space, ctx: &mut ViewContext<Self>) {
|
|
self.embedding_space = space;
|
|
self.reset_embedding_search_mixer(ctx);
|
|
}
|
|
|
|
pub fn reset_state(&mut self, ctx: &mut ViewContext<Self>) {
|
|
self.reset_embedding_search_mixer(ctx);
|
|
|
|
self.search_bar.update(ctx, |search_bar, ctx| {
|
|
search_bar.reset(None, None, SearchResultOrdering::TopDown, ctx);
|
|
ctx.notify();
|
|
});
|
|
}
|
|
|
|
fn close(&self, ctx: &mut ViewContext<Self>) {
|
|
ctx.emit(EmbeddingSearchEvent::Close);
|
|
}
|
|
|
|
/// Handles events emitted by the search bar.
|
|
fn handle_search_bar_event(
|
|
&mut self,
|
|
event: &SearchBarEvent<EmbeddingSearchItemAction>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
SearchBarEvent::Close => {
|
|
self.close(ctx);
|
|
}
|
|
SearchBarEvent::BufferCleared { .. } => {}
|
|
SearchBarEvent::ResultAccepted { action, .. } => {
|
|
self.handle_result_selected(action.clone(), ctx);
|
|
}
|
|
SearchBarEvent::ResultSelected { index } => {
|
|
self.list_state.scroll_to(*index);
|
|
ctx.notify();
|
|
}
|
|
SearchBarEvent::QueryFilterChanged { .. } => {}
|
|
SearchBarEvent::SelectionUpdateInZeroState { .. } => {}
|
|
SearchBarEvent::EnterInZeroState { .. } => {}
|
|
}
|
|
}
|
|
|
|
fn handle_result_selected(
|
|
&self,
|
|
result_action: EmbeddingSearchItemAction,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
ctx.emit(EmbeddingSearchEvent::ItemSelected {
|
|
payload: Box::new(result_action),
|
|
});
|
|
self.close(ctx);
|
|
}
|
|
|
|
fn render_results(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
|
let query_result_renderers = self.search_bar_state.as_ref(app).query_result_renderers();
|
|
let selected_index = self.search_bar_state.as_ref(app).selected_index();
|
|
match (&query_result_renderers, selected_index) {
|
|
(Some(query_result_renderers), _) if query_result_renderers.is_empty() => {
|
|
// There are no results to display, so notify the user of that fact.
|
|
let text = appearance
|
|
.ui_builder()
|
|
.span("No results found.")
|
|
.with_style(UiComponentStyles {
|
|
font_size: Some(appearance.monospace_font_size()),
|
|
font_family_id: Some(appearance.ui_font_family()),
|
|
font_color: Some(appearance.theme().nonactive_ui_text_color().into()),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish();
|
|
|
|
let vertical_padding = styles::line_height_sensitive_vertical_padding(appearance);
|
|
Container::new(
|
|
ConstrainedBox::new(Align::new(text).finish())
|
|
// Make the height the same as a single item, but adjust by the panel padding so
|
|
// the text is centered within the panel.
|
|
.with_height(
|
|
appearance.monospace_font_size() + vertical_padding
|
|
- styles::TOP_PADDING,
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_margin_bottom(styles::TOP_PADDING)
|
|
.finish()
|
|
}
|
|
(Some(query_result_renderers), Some(selected_index)) => {
|
|
let view_handle = self.handle.clone();
|
|
let build_items = move |range: Range<usize>, app: &AppContext| {
|
|
let embedding_view = view_handle
|
|
.upgrade(app)
|
|
.expect("View handle should upgradeable.")
|
|
.as_ref(app);
|
|
let query_result_renderers = embedding_view
|
|
.search_bar_state
|
|
.as_ref(app)
|
|
.query_result_renderers();
|
|
match query_result_renderers {
|
|
Some(query_result_renderers) => {
|
|
let query_result_iter = if range.end == 1 {
|
|
// Despite being upper-bound exclusive, taking a slice where
|
|
// the end of the range is out of bounds results in a panic.
|
|
query_result_renderers[range.start..].iter()
|
|
} else {
|
|
query_result_renderers[range.start..range.end].iter()
|
|
};
|
|
query_result_iter
|
|
.enumerate()
|
|
.map(|(result_index, result_renderer)| {
|
|
let result_index = result_index + range.start;
|
|
SavePosition::new(
|
|
result_renderer.render(
|
|
result_index,
|
|
result_index == selected_index,
|
|
app,
|
|
),
|
|
result_renderer.position_id.as_str(),
|
|
)
|
|
.finish()
|
|
})
|
|
.collect_vec()
|
|
.into_iter()
|
|
}
|
|
None => Vec::new().into_iter(),
|
|
}
|
|
};
|
|
|
|
let scrollable_results = Scrollable::vertical(
|
|
self.scroll_state.clone(),
|
|
UniformList::new(
|
|
self.list_state.clone(),
|
|
query_result_renderers.len(),
|
|
build_items,
|
|
)
|
|
.finish_scrollable(),
|
|
styles::SCROLLBAR_WIDTH,
|
|
appearance.theme().nonactive_ui_detail().into(),
|
|
appearance.theme().active_ui_detail().into(),
|
|
Fill::None, // Leave the background transparent
|
|
)
|
|
.finish();
|
|
|
|
ConstrainedBox::new(scrollable_results)
|
|
.with_max_height(styles::VIEW_HEIGHT)
|
|
.finish()
|
|
}
|
|
_ => Empty::new().finish(),
|
|
}
|
|
}
|
|
|
|
fn render_input_area(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
Container::new(ChildView::new(&self.search_bar).finish())
|
|
.with_background(styles::search_bar_overlay(appearance))
|
|
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(
|
|
styles::CORNER_RADIUS,
|
|
)))
|
|
.with_border(styles::panel_border(appearance).with_sides(true, true, false, true))
|
|
.with_uniform_padding(12.)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Entity for EmbeddingSearchMenu {
|
|
type Event = EmbeddingSearchEvent;
|
|
}
|
|
|
|
impl TypedActionView for EmbeddingSearchMenu {
|
|
type Action = EmbeddingSearchAction;
|
|
|
|
fn handle_action(&mut self, action: &EmbeddingSearchAction, ctx: &mut ViewContext<Self>) {
|
|
use EmbeddingSearchAction::*;
|
|
|
|
match action {
|
|
Close => self.close(ctx),
|
|
ResultClicked { result_action, .. } => {
|
|
self.handle_result_selected(*result_action.clone(), ctx)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl View for EmbeddingSearchMenu {
|
|
fn ui_name() -> &'static str {
|
|
"EmbeddingSearchMenu"
|
|
}
|
|
|
|
fn on_focus(&mut self, focus_ctx: &FocusContext, ctx: &mut ViewContext<Self>) {
|
|
if focus_ctx.is_self_focused() {
|
|
ctx.focus(&self.search_bar);
|
|
}
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn galaxyui::Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
|
|
// So that everything lines up correctly, we apply the corner radius and border on the
|
|
// panel contents, not the panel itself. This is necessary because the input area and
|
|
// results have different background fills.
|
|
|
|
let panel_children = vec![
|
|
self.render_input_area(appearance),
|
|
Shrinkable::new(
|
|
1.,
|
|
Container::new(self.render_results(appearance, app))
|
|
.with_padding_top(styles::TOP_PADDING)
|
|
.with_background(styles::panel_background_fill(appearance))
|
|
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(
|
|
styles::CORNER_RADIUS,
|
|
)))
|
|
.with_border(
|
|
styles::panel_border(appearance).with_sides(false, true, true, true),
|
|
)
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
];
|
|
|
|
let panel_contents =
|
|
ConstrainedBox::new(Flex::column().with_children(panel_children).finish())
|
|
.with_max_width(styles::VIEW_WIDTH)
|
|
.finish();
|
|
|
|
Dismiss::new(
|
|
Container::new(panel_contents)
|
|
.with_drop_shadow(styles::panel_drop_shadow())
|
|
.finish(),
|
|
)
|
|
.on_dismiss(|ctx, _app| {
|
|
ctx.dispatch_typed_action(EmbeddingSearchAction::Close);
|
|
})
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
pub mod styles {
|
|
use galaxyui::elements::{Border, DropShadow, ScrollbarWidth};
|
|
use pathfinder_color::ColorU;
|
|
|
|
use crate::appearance::Appearance;
|
|
use crate::themes::theme::Fill;
|
|
|
|
pub const CORNER_RADIUS: f32 = 6.;
|
|
pub const VIEW_WIDTH: f32 = 450.;
|
|
pub const VIEW_HEIGHT: f32 = 450.;
|
|
pub const TOP_PADDING: f32 = CORNER_RADIUS;
|
|
pub const SCROLLBAR_WIDTH: ScrollbarWidth = ScrollbarWidth::Auto;
|
|
|
|
/// Returns the `Fill` to be used as the background of the search results panel and details
|
|
/// panel.
|
|
pub fn panel_background_fill(appearance: &Appearance) -> Fill {
|
|
appearance.theme().surface_2()
|
|
}
|
|
|
|
pub fn search_bar_overlay(appearance: &Appearance) -> Fill {
|
|
appearance.theme().surface_1()
|
|
}
|
|
|
|
/// Returns the `DropShadow` for both the search results panel and details panel.
|
|
pub fn panel_drop_shadow() -> DropShadow {
|
|
DropShadow::new_with_standard_offset_and_spread(ColorU::new(0, 0, 0, 64))
|
|
}
|
|
|
|
/// Returns the baseline `Border` settings (not applied to any sides)
|
|
pub fn panel_border(appearance: &Appearance) -> Border {
|
|
Border::new(1.).with_border_fill(appearance.theme().surface_3())
|
|
}
|
|
|
|
/// Returns a vertical padding value that is sensitive to the user's line height setting. This
|
|
/// value is used to determine the height of each result in the panel.
|
|
pub fn line_height_sensitive_vertical_padding(appearance: &Appearance) -> f32 {
|
|
appearance.line_height_ratio() * name_font_size(appearance) * 1.5
|
|
}
|
|
|
|
/// The font size for the object name in search results.
|
|
pub fn name_font_size(appearance: &Appearance) -> f32 {
|
|
appearance.ui_font_size() + 2.
|
|
}
|
|
}
|