390 lines
13 KiB
Rust
390 lines
13 KiB
Rust
use galaxy_core::ui::theme::Fill;
|
|
use galaxyui::elements::{
|
|
Align, Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
|
|
Container, CornerRadius, CrossAxisAlignment, Flex, Highlight, MouseStateHandle,
|
|
OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, ScrollbarWidth,
|
|
Shrinkable, Stack, Text,
|
|
};
|
|
use galaxyui::platform::{FilePickerConfiguration, FileType};
|
|
use galaxyui::presenter::ChildView;
|
|
use galaxyui::ui_components::button::ButtonVariant;
|
|
use galaxyui::ui_components::components::{UiComponent, UiComponentStyles};
|
|
use galaxyui::{
|
|
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
|
|
};
|
|
use pathfinder_geometry::vector::vec2f;
|
|
|
|
use super::modal_body::{ImportModalBody, ImportModalBodyAction, ImportModalBodyEvent};
|
|
use crate::appearance::Appearance;
|
|
use crate::cloud_object::model::persistence::CloudModel;
|
|
use crate::cloud_object::{CloudObject, Owner};
|
|
use crate::local_object_repository::local_owner;
|
|
use crate::server::ids::SyncId;
|
|
use crate::server::sync_queue::SyncQueue;
|
|
use crate::themes::theme::GalaxyTheme;
|
|
use crate::workspaces::user_workspaces::UserWorkspaces;
|
|
|
|
const CLOSE_BUTTON_SIZE: f32 = 24.;
|
|
const HEADER_FONT_SIZE: f32 = 16.;
|
|
const MODAL_CORNER_RADIUS: f32 = 8.;
|
|
pub const BODY_HEIGHT: f32 = 244.;
|
|
|
|
#[derive(Debug)]
|
|
pub enum ImportModalAction {
|
|
Close,
|
|
}
|
|
|
|
pub enum ImportModalEvent {
|
|
OpenTargetWithHashedId(String),
|
|
Close,
|
|
}
|
|
|
|
pub struct ImportModal {
|
|
import_modal: ViewHandle<ImportModalBody>,
|
|
owner: Option<Owner>,
|
|
folder_id: Option<SyncId>,
|
|
|
|
clipped_scroll_state: ClippedScrollStateHandle,
|
|
|
|
close_button_mouse_state: MouseStateHandle,
|
|
footer_button_mouse_state: MouseStateHandle,
|
|
}
|
|
|
|
impl ImportModal {
|
|
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
|
|
let import_modal_body = ctx.add_typed_action_view(ImportModalBody::new);
|
|
|
|
ctx.subscribe_to_view(&import_modal_body, move |me, _, event, ctx| {
|
|
me.handle_import_body_event(event, ctx);
|
|
});
|
|
|
|
Self {
|
|
import_modal: import_modal_body,
|
|
owner: None,
|
|
folder_id: None,
|
|
clipped_scroll_state: Default::default(),
|
|
close_button_mouse_state: Default::default(),
|
|
footer_button_mouse_state: Default::default(),
|
|
}
|
|
}
|
|
|
|
pub fn open_with_target(
|
|
&mut self,
|
|
owner: Owner,
|
|
initial_folder_id: Option<SyncId>,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
// TODO: This should take an owner OR folder.
|
|
self.owner = Some(owner);
|
|
self.folder_id = initial_folder_id;
|
|
|
|
self.import_modal.update(ctx, |import_modal, _ctx| {
|
|
import_modal.set_new_target(owner, initial_folder_id);
|
|
});
|
|
|
|
ctx.notify();
|
|
}
|
|
|
|
pub fn open_file_picker(&mut self, ctx: &mut ViewContext<Self>) {
|
|
let window_id = ctx.window_id();
|
|
let import_body_id = self.import_modal.id();
|
|
|
|
let sync_queue_is_dequeueing =
|
|
self.owner != Some(local_owner()) && SyncQueue::as_ref(ctx).is_dequeueing();
|
|
|
|
let allowed_file_types = vec![FileType::Yaml, FileType::Markdown];
|
|
|
|
let mut file_picker_config = FilePickerConfiguration::new()
|
|
.allow_multi_select()
|
|
.set_allowed_file_types(allowed_file_types);
|
|
|
|
// Files under a folder could only be uploaded when the folder is created on the server.
|
|
// When sync queue is not dequeueing, disable folder upload in the import modal.
|
|
if sync_queue_is_dequeueing || self.owner == Some(local_owner()) {
|
|
file_picker_config = file_picker_config.allow_folder();
|
|
}
|
|
|
|
ctx.open_file_picker(
|
|
move |result, ctx| match result {
|
|
Ok(paths) if !paths.is_empty() => {
|
|
ctx.dispatch_typed_action_for_view(
|
|
window_id,
|
|
import_body_id,
|
|
&ImportModalBodyAction::PathsSelected(paths),
|
|
);
|
|
}
|
|
Ok(_) => {
|
|
ctx.dispatch_typed_action_for_view(
|
|
window_id,
|
|
import_body_id,
|
|
&ImportModalBodyAction::FilePickerCancelled,
|
|
);
|
|
}
|
|
Err(err) => {
|
|
ctx.dispatch_typed_action_for_view(
|
|
window_id,
|
|
import_body_id,
|
|
&ImportModalBodyAction::FilePickerError(err),
|
|
);
|
|
}
|
|
},
|
|
file_picker_config,
|
|
);
|
|
|
|
ctx.notify();
|
|
}
|
|
|
|
fn handle_import_body_event(
|
|
&mut self,
|
|
event: &ImportModalBodyEvent,
|
|
ctx: &mut ViewContext<Self>,
|
|
) {
|
|
match event {
|
|
ImportModalBodyEvent::OpenFilePicker => self.open_file_picker(ctx),
|
|
ImportModalBodyEvent::OpenTargetWithHashedId(hashed_id) => {
|
|
ctx.emit(ImportModalEvent::OpenTargetWithHashedId(hashed_id.clone()))
|
|
}
|
|
ImportModalBodyEvent::UploadCompleted
|
|
| ImportModalBodyEvent::AllFileSavedLocally
|
|
| ImportModalBodyEvent::UploadSelected => ctx.notify(),
|
|
}
|
|
}
|
|
|
|
fn breadcrumb(&self, app: &AppContext) -> (String, Vec<usize>) {
|
|
let (text, highlight_start) = match self
|
|
.folder_id
|
|
.as_ref()
|
|
.and_then(|folder_id| CloudModel::as_ref(app).get_folder(folder_id))
|
|
{
|
|
Some(folder) => {
|
|
let breadcrumbs = folder.breadcrumbs(app);
|
|
(
|
|
format!("{} / {}", breadcrumbs, folder.display_name()),
|
|
breadcrumbs.chars().count() + 3,
|
|
)
|
|
}
|
|
None => (
|
|
// Convert to a Space for display, in case we're importing into a shared folder.
|
|
self.owner
|
|
.map(|owner| {
|
|
if owner == local_owner() {
|
|
"Personal".to_string()
|
|
} else {
|
|
UserWorkspaces::as_ref(app)
|
|
.owner_to_space(owner, app)
|
|
.name(app)
|
|
}
|
|
})
|
|
.unwrap_or_default(),
|
|
0,
|
|
),
|
|
};
|
|
|
|
// The unit of highlight index is character index not byte index.
|
|
let highlight_range = (highlight_start..text.chars().count()).collect();
|
|
(text, highlight_range)
|
|
}
|
|
|
|
fn render_breadcrumbs(
|
|
&self,
|
|
appearance: &Appearance,
|
|
theme: &GalaxyTheme,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let (breadcrumb_text, highlight_indices) = self.breadcrumb(app);
|
|
|
|
Container::new(
|
|
appearance
|
|
.ui_builder()
|
|
.span(breadcrumb_text)
|
|
.with_highlights(
|
|
highlight_indices,
|
|
Highlight::new().with_foreground_color(
|
|
theme.main_text_color(theme.surface_2()).into_solid(),
|
|
),
|
|
)
|
|
.with_style(UiComponentStyles {
|
|
font_color: Some(theme.sub_text_color(theme.surface_2()).into_solid()),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.finish(),
|
|
)
|
|
.with_margin_top(6.)
|
|
.finish()
|
|
}
|
|
|
|
fn render_close_button(&self, appearance: &Appearance) -> Box<dyn Element> {
|
|
appearance
|
|
.ui_builder()
|
|
.close_button(CLOSE_BUTTON_SIZE, self.close_button_mouse_state.clone())
|
|
.build()
|
|
.on_click(|ctx, _, _| ctx.dispatch_typed_action(ImportModalAction::Close))
|
|
.finish()
|
|
}
|
|
|
|
fn render_header(
|
|
&self,
|
|
appearance: &Appearance,
|
|
theme: &GalaxyTheme,
|
|
app: &AppContext,
|
|
) -> Box<dyn Element> {
|
|
let top_row = Flex::row()
|
|
.with_child(
|
|
Shrinkable::new(
|
|
1.0,
|
|
Align::new(
|
|
Text::new_inline("Import", appearance.ui_font_family(), HEADER_FONT_SIZE)
|
|
.with_color(appearance.theme().active_ui_text_color().into())
|
|
.finish(),
|
|
)
|
|
.left()
|
|
.finish(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_child(self.render_close_button(appearance))
|
|
.with_cross_axis_alignment(CrossAxisAlignment::Center)
|
|
.finish();
|
|
|
|
let header = Flex::column()
|
|
.with_child(top_row)
|
|
.with_child(self.render_breadcrumbs(appearance, theme, app))
|
|
.finish();
|
|
|
|
Container::new(header)
|
|
.with_corner_radius(CornerRadius::with_top(Radius::Pixels(MODAL_CORNER_RADIUS)))
|
|
.with_padding_left(24.)
|
|
.with_padding_top(16.)
|
|
.with_padding_right(16.)
|
|
.with_padding_bottom(16.)
|
|
.with_border(Border::bottom(1.).with_border_fill(theme.outline()))
|
|
.finish()
|
|
}
|
|
|
|
fn render_body(&self, theme: &GalaxyTheme) -> Box<dyn Element> {
|
|
Container::new(
|
|
ConstrainedBox::new(
|
|
ClippedScrollable::vertical(
|
|
self.clipped_scroll_state.clone(),
|
|
ChildView::new(&self.import_modal).finish(),
|
|
ScrollbarWidth::Auto,
|
|
theme.disabled_text_color(theme.surface_2()).into(),
|
|
theme.main_text_color(theme.surface_2()).into(),
|
|
theme.surface_2().into(),
|
|
)
|
|
.finish(),
|
|
)
|
|
.with_height(BODY_HEIGHT)
|
|
.finish(),
|
|
)
|
|
.with_uniform_padding(5.)
|
|
.with_corner_radius(CornerRadius::with_bottom(Radius::Pixels(
|
|
MODAL_CORNER_RADIUS,
|
|
)))
|
|
.with_background(theme.surface_2())
|
|
.finish()
|
|
}
|
|
|
|
fn render_footer(&self, appearance: &Appearance, app: &AppContext) -> Box<dyn Element> {
|
|
let button_text = if !self.import_modal.as_ref(app).upload_in_progress(app) {
|
|
"Close".to_string()
|
|
} else {
|
|
"Cancel".to_string()
|
|
};
|
|
|
|
Container::new(
|
|
Align::new(
|
|
appearance
|
|
.ui_builder()
|
|
.button(
|
|
ButtonVariant::Outlined,
|
|
self.footer_button_mouse_state.clone(),
|
|
)
|
|
.with_centered_text_label(button_text.to_string())
|
|
.with_style(UiComponentStyles {
|
|
width: Some(150.),
|
|
height: Some(40.),
|
|
font_size: Some(14.),
|
|
..Default::default()
|
|
})
|
|
.build()
|
|
.on_click(|ctx, _, _| ctx.dispatch_typed_action(ImportModalAction::Close))
|
|
.finish(),
|
|
)
|
|
.right()
|
|
.finish(),
|
|
)
|
|
.with_uniform_padding(16.)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl Entity for ImportModal {
|
|
type Event = ImportModalEvent;
|
|
}
|
|
|
|
impl View for ImportModal {
|
|
fn ui_name() -> &'static str {
|
|
"ImportModal"
|
|
}
|
|
|
|
fn render(&self, app: &AppContext) -> Box<dyn Element> {
|
|
let appearance = Appearance::as_ref(app);
|
|
let theme = appearance.theme();
|
|
|
|
let mut contents = Flex::column()
|
|
.with_child(self.render_header(appearance, theme, app))
|
|
.with_child(self.render_body(theme));
|
|
|
|
if !self.import_modal.as_ref(app).before_upload() {
|
|
contents.add_child(self.render_footer(appearance, app));
|
|
}
|
|
|
|
let modal = ConstrainedBox::new(
|
|
Container::new(contents.finish())
|
|
.with_background(theme.surface_2())
|
|
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(MODAL_CORNER_RADIUS)))
|
|
.with_border(Border::all(1.).with_border_fill(theme.outline()))
|
|
.with_margin_top(35.)
|
|
.finish(),
|
|
)
|
|
.with_width(500.)
|
|
.with_height(300.)
|
|
.finish();
|
|
|
|
// Stack needed so that modal can get bounds information,
|
|
// specifically to ensure no overlap with the window's traffic lights
|
|
let mut stack = Stack::new();
|
|
stack.add_positioned_child(
|
|
modal,
|
|
OffsetPositioning::offset_from_parent(
|
|
vec2f(0., 0.),
|
|
ParentOffsetBounds::WindowByPosition,
|
|
ParentAnchor::Center,
|
|
ChildAnchor::Center,
|
|
),
|
|
);
|
|
|
|
Container::new(Align::new(stack.finish()).finish())
|
|
.with_background_color(Fill::blur().into())
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
impl TypedActionView for ImportModal {
|
|
type Action = ImportModalAction;
|
|
|
|
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
|
|
match action {
|
|
ImportModalAction::Close => {
|
|
self.import_modal.update(ctx, |import_modal_body, ctx| {
|
|
import_modal_body.reset(ctx);
|
|
});
|
|
|
|
ctx.emit(ImportModalEvent::Close);
|
|
}
|
|
}
|
|
}
|
|
}
|