Skip to content

Commit 4d96c63

Browse files
committed
Add favorites persistence, sort options, file hover tooltips, fix drag-drop
1 parent 1cdac2a commit 4d96c63

6 files changed

Lines changed: 422 additions & 95 deletions

File tree

crates/editor/renzora_asset_browser/src/grid.rs

Lines changed: 127 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use egui_phosphor::regular;
55
use renzora_editor::{split_label_two_lines, AssetDragPayload, TileGrid, TileState};
66
use renzora_theme::Theme;
77

8-
use crate::state::{file_icon, folder_icon_color, is_hidden, AssetBrowserState};
8+
use crate::state::{file_icon, folder_icon_color, format_file_size, is_hidden, AssetBrowserState, SortDirection, SortMode};
99
use crate::thumbnails::supports_thumbnail;
1010

1111
/// Entry in the file grid (folder or file).
@@ -60,10 +60,40 @@ pub(crate) fn collect_entries(state: &AssetBrowserState) -> Option<Vec<GridEntry
6060
Err(_) => return None,
6161
};
6262

63+
// Folders always sort before files, then apply sort mode
64+
let sort_mode = state.sort_mode;
65+
let sort_dir = state.sort_direction;
6366
entries.sort_by(|a, b| {
64-
b.is_dir
65-
.cmp(&a.is_dir)
66-
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
67+
b.is_dir.cmp(&a.is_dir).then_with(|| {
68+
let cmp = match sort_mode {
69+
SortMode::Name => {
70+
a.name.to_lowercase().cmp(&b.name.to_lowercase())
71+
}
72+
SortMode::DateModified => {
73+
let time_a = std::fs::metadata(&a.path)
74+
.and_then(|m| m.modified())
75+
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
76+
let time_b = std::fs::metadata(&b.path)
77+
.and_then(|m| m.modified())
78+
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
79+
time_a.cmp(&time_b)
80+
}
81+
SortMode::Type => {
82+
let ext_a = a.path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
83+
let ext_b = b.path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
84+
ext_a.cmp(&ext_b).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
85+
}
86+
SortMode::Size => {
87+
let size_a = std::fs::metadata(&a.path).map(|m| m.len()).unwrap_or(0);
88+
let size_b = std::fs::metadata(&b.path).map(|m| m.len()).unwrap_or(0);
89+
size_a.cmp(&size_b)
90+
}
91+
};
92+
match sort_dir {
93+
SortDirection::Ascending => cmp,
94+
SortDirection::Descending => cmp.reverse(),
95+
}
96+
})
6797
});
6898

6999
// Apply search filter
@@ -296,6 +326,18 @@ pub fn grid_ui_interactive(
296326
);
297327
}
298328

329+
// Star badge on favorited folders
330+
if entry.is_dir && state.is_favorite(&entry.path) {
331+
let star_pos = egui::pos2(tile.rect.max.x - 10.0, tile.rect.min.y + 10.0);
332+
ui.painter().text(
333+
star_pos,
334+
Align2::CENTER_CENTER,
335+
regular::STAR,
336+
FontId::proportional(10.0),
337+
Color32::from_rgb(255, 200, 60),
338+
);
339+
}
340+
299341
// Draw label (skip if renaming)
300342
if !is_renaming {
301343
let (line1, line2) =
@@ -318,6 +360,13 @@ pub fn grid_ui_interactive(
318360
}
319361
}
320362

363+
// File hover tooltip (suppress during drag)
364+
if !entry.is_dir && tile.response.hovered() && state.drag_moving.is_empty() {
365+
tile.response.clone().on_hover_ui_at_pointer(|ui| {
366+
file_hover_tooltip(ui, &entry.path);
367+
});
368+
}
369+
321370
TileState {
322371
is_selected,
323372
is_hovered,
@@ -498,20 +547,21 @@ pub fn grid_ui_interactive(
498547
state.drag_moving = vec![entry.path.clone()];
499548
}
500549

501-
// Only produce viewport drag payload for files (not folders)
502-
if !entry.is_dir {
503-
let (icon, color) = file_icon(&entry.path);
504-
let origin = ui.ctx().pointer_latest_pos().unwrap_or_default();
505-
drag_payload = Some(AssetDragPayload {
506-
path: entry.path.clone(),
507-
name: entry.name.clone(),
508-
icon: icon.to_string(),
509-
color,
510-
origin,
511-
is_detached: false,
512-
drag_count: state.drag_moving.len(),
513-
});
514-
}
550+
let (icon, color) = if entry.is_dir {
551+
(regular::FOLDER, folder_icon_color(&entry.name))
552+
} else {
553+
file_icon(&entry.path)
554+
};
555+
let origin = ui.ctx().pointer_latest_pos().unwrap_or_default();
556+
drag_payload = Some(AssetDragPayload {
557+
path: entry.path.clone(),
558+
name: entry.name.clone(),
559+
icon: icon.to_string(),
560+
color,
561+
origin,
562+
is_detached: false,
563+
drag_count: state.drag_moving.len(),
564+
});
515565
}
516566

517567
// Update drop target for ghost label
@@ -534,3 +584,62 @@ pub fn grid_ui_interactive(
534584
thumbnail_requests,
535585
}
536586
}
587+
588+
/// Render a tooltip with file info (name, type, size, date modified).
589+
pub(crate) fn file_hover_tooltip(ui: &mut egui::Ui, path: &std::path::Path) {
590+
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("unknown");
591+
ui.label(egui::RichText::new(name).strong());
592+
593+
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
594+
ui.label(format!("Type: {}", ext.to_uppercase()));
595+
}
596+
597+
if let Ok(meta) = std::fs::metadata(path) {
598+
ui.label(format!("Size: {}", format_file_size(meta.len())));
599+
if let Ok(modified) = meta.modified() {
600+
if let Ok(duration) = modified.duration_since(std::time::SystemTime::UNIX_EPOCH) {
601+
let secs = duration.as_secs();
602+
// Simple date formatting: YYYY-MM-DD HH:MM
603+
let days = secs / 86400;
604+
let time_of_day = secs % 86400;
605+
let hours = time_of_day / 3600;
606+
let minutes = (time_of_day % 3600) / 60;
607+
608+
// Approximate date from days since epoch
609+
let (year, month, day) = days_to_date(days);
610+
ui.label(format!("Modified: {}-{:02}-{:02} {:02}:{:02}", year, month, day, hours, minutes));
611+
}
612+
}
613+
}
614+
}
615+
616+
/// Convert days since Unix epoch to (year, month, day).
617+
fn days_to_date(mut days: u64) -> (u64, u64, u64) {
618+
let mut year = 1970;
619+
loop {
620+
let days_in_year = if is_leap(year) { 366 } else { 365 };
621+
if days < days_in_year {
622+
break;
623+
}
624+
days -= days_in_year;
625+
year += 1;
626+
}
627+
let month_days: &[u64] = if is_leap(year) {
628+
&[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
629+
} else {
630+
&[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
631+
};
632+
let mut month = 1;
633+
for &md in month_days {
634+
if days < md {
635+
break;
636+
}
637+
days -= md;
638+
month += 1;
639+
}
640+
(year, month, days + 1)
641+
}
642+
643+
fn is_leap(year: u64) -> bool {
644+
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
645+
}

crates/editor/renzora_asset_browser/src/lib.rs

Lines changed: 19 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ impl EditorPanel for AssetBrowserPanel {
6262
if state.project_root.as_ref() != Some(&project.path) {
6363
state.project_root = Some(project.path.clone());
6464
state.current_folder = Some(project.path.clone());
65+
state.load_favorites();
6566
}
6667
}
6768

@@ -461,62 +462,6 @@ impl EditorPanel for AssetBrowserPanel {
461462
state.selected_path = state.selected_assets.iter().next().cloned();
462463
}
463464

464-
// --- Internal drag ghost (for folder drags or when no viewport payload) ---
465-
if !state.drag_moving.is_empty() {
466-
if let Some(pos) = ui.ctx().pointer_latest_pos() {
467-
let count = state.drag_moving.len();
468-
let items_text = if count > 1 {
469-
format!("{} items", count)
470-
} else {
471-
state.drag_moving[0].file_name()
472-
.and_then(|n| n.to_str())
473-
.unwrap_or("item")
474-
.to_string()
475-
};
476-
477-
let label = if let Some(ref target) = state.move_drop_target {
478-
let folder_name = target.file_name()
479-
.and_then(|n| n.to_str())
480-
.unwrap_or("folder");
481-
format!("{} Move {} to {}", regular::ARROW_RIGHT, items_text, folder_name)
482-
} else {
483-
let icon = if count > 1 {
484-
regular::FOLDERS
485-
} else if state.drag_moving[0].is_dir() {
486-
regular::FOLDER
487-
} else {
488-
regular::FILE
489-
};
490-
format!("{} {}", icon, items_text)
491-
};
492-
493-
let accent = theme.semantic.accent.to_color32();
494-
let border_color = if state.move_drop_target.is_some() { accent } else {
495-
theme.widgets.border.to_color32()
496-
};
497-
498-
let ghost_pos = pos + egui::vec2(14.0, -10.0);
499-
egui::Area::new(egui::Id::new("asset_move_ghost"))
500-
.fixed_pos(ghost_pos)
501-
.order(egui::Order::Tooltip)
502-
.interactable(false)
503-
.show(ui.ctx(), |ui| {
504-
egui::Frame::NONE
505-
.fill(theme.surfaces.panel.to_color32())
506-
.stroke(Stroke::new(1.5, border_color))
507-
.inner_margin(egui::Margin::symmetric(8, 5))
508-
.corner_radius(egui::CornerRadius::same(6))
509-
.show(ui, |ui| {
510-
ui.label(
511-
egui::RichText::new(label)
512-
.font(FontId::proportional(11.0))
513-
.color(theme.text.primary.to_color32()),
514-
);
515-
});
516-
});
517-
}
518-
}
519-
520465
// --- Error display ---
521466
if let Some(ref error) = state.last_error {
522467
let error_rect = egui::Rect::from_min_size(
@@ -768,6 +713,24 @@ fn render_context_menu(
768713
separator(ui);
769714
section_header(ui, "Selection");
770715

716+
// Favorite toggle for single folder selection
717+
if state.selected_assets.len() == 1 {
718+
let selected_path = state.selected_assets.iter().next().unwrap().clone();
719+
if selected_path.is_dir() {
720+
let is_fav = state.is_favorite(&selected_path);
721+
let fav_label = if is_fav { "Unfavorite" } else { "Favorite" };
722+
let star_color = if is_fav {
723+
Color32::from_rgb(255, 200, 60)
724+
} else {
725+
text_primary
726+
};
727+
if menu_item(ui, regular::STAR, fav_label, "", star_color) {
728+
state.toggle_favorite(&selected_path);
729+
state.context_menu_pos = None;
730+
}
731+
}
732+
}
733+
771734
if state.selected_assets.len() == 1 {
772735
if menu_item(ui, regular::PENCIL, "Rename", "F2", text_primary) {
773736
if let Some(path) = state.selected_assets.iter().next() {

crates/editor/renzora_asset_browser/src/list.rs

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use egui_phosphor::regular;
55
use renzora_editor::AssetDragPayload;
66
use renzora_theme::Theme;
77

8-
use crate::grid::{collect_entries, GridResult};
8+
use crate::grid::{collect_entries, file_hover_tooltip, GridResult};
99
use crate::state::{file_icon, folder_icon_color, AssetBrowserState};
1010

1111
const ROW_HEIGHT: f32 = 22.0;
@@ -204,6 +204,13 @@ pub fn list_ui_interactive(ui: &mut egui::Ui, state: &mut AssetBrowserState, the
204204
}
205205
}
206206

207+
// File hover tooltip (suppress during drag)
208+
if !entry.is_dir && resp.hovered() && state.drag_moving.is_empty() {
209+
resp.clone().on_hover_ui_at_pointer(|ui| {
210+
file_hover_tooltip(ui, &entry.path);
211+
});
212+
}
213+
207214
if resp.clicked() {
208215
clicked_path = Some(entry.path.clone());
209216
}
@@ -277,19 +284,21 @@ pub fn list_ui_interactive(ui: &mut egui::Ui, state: &mut AssetBrowserState, the
277284
} else {
278285
state.drag_moving = vec![entry.path.clone()];
279286
}
280-
if !entry.is_dir {
281-
let (icon, color) = file_icon(&entry.path);
282-
let origin = ui.ctx().pointer_latest_pos().unwrap_or_default();
283-
drag_payload = Some(AssetDragPayload {
284-
path: entry.path.clone(),
285-
name: entry.name.clone(),
286-
icon: icon.to_string(),
287-
color,
288-
origin,
289-
is_detached: false,
290-
drag_count: state.drag_moving.len(),
291-
});
292-
}
287+
let (icon, color) = if entry.is_dir {
288+
(regular::FOLDER, folder_icon_color(&entry.name))
289+
} else {
290+
file_icon(&entry.path)
291+
};
292+
let origin = ui.ctx().pointer_latest_pos().unwrap_or_default();
293+
drag_payload = Some(AssetDragPayload {
294+
path: entry.path.clone(),
295+
name: entry.name.clone(),
296+
icon: icon.to_string(),
297+
color,
298+
origin,
299+
is_detached: false,
300+
drag_count: state.drag_moving.len(),
301+
});
293302
}
294303

295304
// Update drop target for ghost label

0 commit comments

Comments
 (0)