ui-core
UI + core combination guide for frename. Use when: wiring a new feature that spans both UI state and core data, deciding where logic lives (core vs UI), connecting a new message to a core operation, c
SKILL.md.claude/skills/ui-core/SKILL.md
GitHubUI + Core Combination Guide — frename
WHEN to use this skill
- Wiring a new feature that spans both UI state and core data
- Deciding whether logic belongs in
frename-coreorsrc/features/ - Connecting a new
Messageto a core operation (TagList, AppDatabase) - Changing how
FileWorkspaceorFolderWorkspacecoordinates features - Understanding when/how data is written to disk
For wiring Message::Undo / Message::Redo or implementing undo commands, use the undo-dev skill instead.
The bridge: FileWorkspace
FileWorkspace<S> (src/features/file_workspace/state.rs) is the only place in the UI that directly holds a TagList<S>. It exposes all tag operations as thin wrappers:
| FileWorkspace method | Delegates to |
|---|---|
set_file(file) |
Rebuilds TagList::new(store, snapshot) |
set_tag_filter(query) |
tag_list.set_filter(query) |
toggle_tag_by_id(id) |
tag_list.toggle_by_id(id) |
save_tag(id) |
tag_list.save_tag(id) |
create_and_save_new_tag(name) |
tag_list.create_new_tag(name) then tag_list.save_tag(id) |
remove_stored_tag_by_id(id) |
tag_list.remove_stored_tag_by_id(id) |
reorder_tag_to_index(id, idx) |
tag_list.reorder_tag_to_index(id, idx) |
get_snapshot() |
tag_list.file_snapshot() → Option<(PathBuf, FileSnapshot)> |
has_tag_with_name(name) |
tag_list.has_tag_with_name(name) |
The UI never calls TagList or AppDatabase directly.
Message routing
Every user action follows this chain:
keyboard/mouse event
→ app::Message (App::update)
→ folder_workspace::Message (FolderWorkspace::update)
→ handle_tag_panel(msg) [for tag-related actions]
→ handle_file_name_panel(msg) [for chip drag / trash]
→ handle_folder_message(msg) [for file list selection]
→ FileWorkspace method
→ TagList method
→ StoredTagStore method (if persisting)
Views emit Element<'_, feature::Message>; folder_workspace maps them up via .map(Message::TagPanel) etc.
Where logic lives
| Decision | Location |
|---|---|
| Tag data, ordering, filter | frename-core (TagList) |
| When to toggle / save / delete | folder_workspace::handle_tag_panel |
| UI cursor (selected row) | TagPanelState.selected_tag_id (UI only) |
| Drag order (file name panel) | FileNamePanelState + tag_list.reorder_tag_to_index() |
| Layout dimensions for hit-testing | const in feature mod.rs, used by both state.rs and view.rs |
| File persistence (disk write) | Only in folder_workspace::apply_file_updated via SaveAndReparse |
| Session persistence (last folder/file) | AppStateStore::set_last_folder_and_file called in FolderWorkspace |
File save flow (deferred, not immediate)
Tags are NEVER written to disk while editing. The save happens only when the user switches to another file:
1. User selects different file
→ FolderWorkspace::select_file_at → Task::done(Message::FileOpened(file))
2. Message::FileOpened(file)
→ apply_file_opened():
snapshot = file_workspace.get_snapshot() // snapshot of the PREVIOUS file
file_workspace.set_file(Some(new_file)) // load new file
if snapshot: pending_file_updated = Some(snapshot)
return Task::done(Message::VideoPlayer(Unload))
3. Message::VideoPlayer(VideoUnloaded)
→ on_video_unloaded():
(path, snapshot) = pending_file_updated.take()
return Task::batch([
Task::done(Message::FileUpdated { path, snapshot }),
video_player.load_video(new_file_path),
])
4. Message::FileUpdated { path, snapshot }
→ apply_file_updated():
(new_path, snapshot_after) = snapshot.save_and_reparse(&path)
directory.update_file(&new_path, &snapshot_after)
Video must unload before the file rename because GStreamer holds a file handle on Windows.
In a debug build save_and_reparse renames in memory only; code that then opens the file on disk
needs FileTagger::disk_path (see core-dev, "Disk paths in a debug build").
Selection after tag operations
After every set_selected() call, emit Message::ScrollTagListToSelection to scroll the tag into view:
// In folder_workspace::handle_tag_panel
self.tag_panel.set_selected(Some(id));
Task::done(Message::ScrollTagListToSelection)
After CreateTag: clear filter first (done inside create_and_save_new_tag), then set selection, then scroll.
After SetFilter: clamp selection to filtered list (clamp_selection_to_filtered()), then scroll.
Adding a new tag operation — step by step
- Core: add method to
TagList<S>incrates/frename-core/src/tags/tag_list.rs - Core trait (if DB access needed): add method to
StoredTagStoreindb/traits.rs+ implement inAppDatabase+FakeAppStorage - Bridge: add thin wrapper to
FileWorkspace<S>insrc/features/file_workspace/state.rs - Message: add variant to
tag_panel::Message(orfolder_workspace::Messagefor workspace-level actions) - Handle: add arm in
folder_workspace::handle_tag_panel(orupdatefor workspace-level) - View: emit the message from the relevant view (
tag_grid/view.rsortag_panel/view.rs) - Scroll: if the operation changes selection, emit
ScrollTagListToSelection
Adding a new UI-only state field
Add to the relevant feature state struct:
- Tag panel cursor / layout →
TagPanelState - File name panel drag / drop →
FileNamePanelState - Workspace-level layout →
FolderWorkspace
UI-only state (no core counterpart) stays entirely in src/features/.
No need to touch frename-core for pure UI state.
Generics pattern
Both Directory<S> and TagList<S> are generic over S: StoredTagStore + Clone.
FileWorkspace<S> is similarly generic.
In production: S = AppDatabase (via LoggingAppStateStore<AppDatabase>).
In tests: S = FakeAppStorage.
FolderWorkspace pins the generic: file_workspace: FileWorkspace<AppDatabase>.
The UI crate never needs to be generic — the generic type is resolved at the FolderWorkspace level.