feat(myqbz): detail edit — overflow menu, modals, play-mode/kind/cover

Phase-2 Slice 7. The hero overflow (⋯) menu and the edit operations:
- Hero action row corrected to spec §3.6: exactly 4 controls (Play primary,
  filled white disc + black glyph; Shuffle; DJ-mix [turntable glyph]; ⋯ overflow)
  -- replaces the S3 flat-circles deviation.
- ⋯ overflow menu (spec §10): Rename, Edit description, Upload/Clear custom cover,
  play-mode toggle (label = the OTHER mode), Convert kind (hidden for
  artist_collection), Delete (destructive).
- ui/myqbz/MyQbzEditModal.slint: Rename / Description / Delete-confirm modals
  (mirrors EditPlaylistModal chrome).
- src/myqbz_edit.rs: rename / set_description / toggle play_mode / convert kind /
  delete via with_db -> with_connection -> qbz_mixtape::repo::*; each reloads the
  detail, delete navigates back. "Converted"/"Cannot convert this kind" toasts.
- src/myqbz_cover.rs: custom-cover upload (rfd picker, ext validate, resize
  1000x1000 Lanczos3, save to artwork cache, persist-before-delete-prev) + remove.

dj-mix + sync stay stubs (later slices). Authoritative copy (Upload cover / Clear
custom cover / In order / double-quote delete). Backend crates untouched.
cargo check -p qbz-slint green.

cc
This commit is contained in:
vicrodh committed 2026-06-09 04:38:35 -06:00
1 parent b049b1cd5c
commit 2a72878a1d
10 files changed
+981 -43

No files matched your search

+161 -6
View File
@@ -36,7 +36,9 @@ mod location_view;
mod mix;
mod musician;
mod myqbz;
mod myqbz_cover;
mod myqbz_detail;
mod myqbz_edit;
mod myqbz_play;
mod nav;
mod play_history;
@@ -2264,7 +2266,10 @@ fn wire_myqbz_detail(
});
}
// --- STILL-STUBBED hero/CTA actions (later slices) ------------------
// --- STILL-STUBBED hero CTAs (DJ-mix modal + discography sync) -------
// DJ-mix: the TrackMixModal is a later slice — keep the existing stub.
// Sync: artist_discography has NO sync impl (spec §8) — no-op stub (the
// hero button is shown only for artist_collection for Tauri parity).
{
let weak = window.as_weak();
let log_stub = move |what: &str| {
@@ -2273,19 +2278,169 @@ fn wire_myqbz_detail(
.map(|w| w.global::<MyQbzDetailState>().get_id().to_string())
.unwrap_or_default();
log::info!(
"[qbz-slint] myqbz_detail {what}({id}) — not yet wired (read-only slice)"
"[qbz-slint] myqbz_detail {what}({id}) — not yet wired (later slice / no impl)"
);
};
let s = log_stub.clone();
window.global::<Act>().on_dj_mix(move || s("dj-mix"));
let s = log_stub.clone();
window.global::<Act>().on_edit(move || s("edit"));
let s = log_stub.clone();
window.global::<Act>().on_delete(move || s("delete"));
let s = log_stub.clone();
window.global::<Act>().on_sync(move || s("sync"));
}
// --- Hero overflow (⋯) menu — open the edit modals (spec 12 §10/§11) ---
// Rename / Edit description / Delete-confirm open the shared MyQbzEditModal
// with the right mode + prefill; the mutations + reload run on submit.
{
let weak = window.as_weak();
window.global::<Act>().on_open_rename(move || {
let Some(w) = weak.upgrade() else { return };
let ds = w.global::<MyQbzDetailState>();
let es = w.global::<MyQbzEditState>();
es.set_mode("rename".into());
es.set_name(ds.get_name());
es.set_draft_name(ds.get_name());
es.set_busy(false);
es.set_open(true);
});
}
{
let weak = window.as_weak();
window.global::<Act>().on_open_description(move || {
let Some(w) = weak.upgrade() else { return };
let ds = w.global::<MyQbzDetailState>();
let es = w.global::<MyQbzEditState>();
es.set_mode("description".into());
es.set_name(ds.get_name());
es.set_draft_description(ds.get_description());
es.set_busy(false);
es.set_open(true);
});
}
{
let weak = window.as_weak();
window.global::<Act>().on_open_delete(move || {
let Some(w) = weak.upgrade() else { return };
let ds = w.global::<MyQbzDetailState>();
let es = w.global::<MyQbzEditState>();
es.set_mode("delete".into());
es.set_name(ds.get_name());
es.set_busy(false);
es.set_open(true);
});
}
// --- Hero overflow — custom cover (set / remove) --------------------
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window.global::<Act>().on_upload_cover(move || {
let Some(w) = weak.upgrade() else { return };
let id = w.global::<MyQbzDetailState>().get_id().to_string();
if id.is_empty() {
return;
}
myqbz_cover::upload(weak.clone(), handle.clone(), image_cache.clone(), id);
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window.global::<Act>().on_remove_cover(move || {
let Some(w) = weak.upgrade() else { return };
let id = w.global::<MyQbzDetailState>().get_id().to_string();
if id.is_empty() {
return;
}
myqbz_cover::remove(weak.clone(), handle.clone(), image_cache.clone(), id);
});
}
// --- Hero overflow — play-mode toggle / convert kind ---------------
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window.global::<Act>().on_toggle_play_mode(move || {
let Some(w) = weak.upgrade() else { return };
let ds = w.global::<MyQbzDetailState>();
let id = ds.get_id().to_string();
let mode = ds.get_play_mode().to_string();
if id.is_empty() {
return;
}
myqbz_edit::toggle_play_mode(weak.clone(), handle.clone(), image_cache.clone(), id, mode);
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window.global::<Act>().on_convert_kind(move || {
let Some(w) = weak.upgrade() else { return };
let ds = w.global::<MyQbzDetailState>();
let id = ds.get_id().to_string();
let kind = ds.get_kind().to_string();
if id.is_empty() {
return;
}
myqbz_edit::convert_kind(weak.clone(), handle.clone(), image_cache.clone(), id, kind);
});
}
// --- Edit modals — submit (rename / description / delete) ----------
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window.global::<MyQbzEditActions>().on_submit_rename(move || {
let Some(w) = weak.upgrade() else { return };
let id = w.global::<MyQbzDetailState>().get_id().to_string();
let name = w.global::<MyQbzEditState>().get_draft_name().to_string();
myqbz_edit::rename(weak.clone(), handle.clone(), image_cache.clone(), id, name);
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
let image_cache = image_cache.clone();
window
.global::<MyQbzEditActions>()
.on_submit_description(move || {
let Some(w) = weak.upgrade() else { return };
let id = w.global::<MyQbzDetailState>().get_id().to_string();
let desc = w.global::<MyQbzEditState>().get_draft_description().to_string();
myqbz_edit::set_description(
weak.clone(),
handle.clone(),
image_cache.clone(),
id,
desc,
);
});
}
{
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window.global::<MyQbzEditActions>().on_confirm_delete(move || {
let Some(w) = weak.upgrade() else { return };
let id = w.global::<MyQbzDetailState>().get_id().to_string();
myqbz_edit::delete(weak.clone(), handle.clone(), id);
});
}
{
let weak = window.as_weak();
window.global::<MyQbzEditActions>().on_close(move || {
if let Some(w) = weak.upgrade() {
let es = w.global::<MyQbzEditState>();
es.set_open(false);
es.set_mode("".into());
es.set_busy(false);
}
});
}
// --- Per-row PLAY (default) -----------------------------------------
{
let runtime = app_runtime.clone();
+231
View File
@@ -0,0 +1,231 @@
//! My QBZ — custom-cover upload / remove (Phase-2 Slice 7).
//!
//! Mirrors Tauri's `v2_mixtape_upload_custom_cover` /
//! `v2_mixtape_remove_custom_cover` (spec 40 §7) 1:1, and the album
//! custom-cover convention (same artwork cache dir, same 1000×1000 Lanczos3
//! resize, same failure-safe "persist before deleting the previous file"
//! ordering):
//!
//! - **Upload:** validate the picked file's extension (png/jpg/jpeg/webp),
//! read the previous `custom_artwork_path` (to delete after persist), decode
//! + `resize(1000, 1000, Lanczos3)`, save as `mixtape_custom_{safe_id}_{epoch_secs}.jpg`
//! in `qbz_library::get_artwork_cache_dir()`, persist via
//! `repo::set_custom_artwork(Some(dest))`, then delete the previous file only
//! if it differs.
//! - **Remove:** read previous, `repo::set_custom_artwork(None)`, delete prev.
//!
//! Frontend-agnostic (ADR-005/006): the persistence is `qbz_mixtape::repo`
//! reached directly through `crate::library_db::with_db`; no Tauri command
//! wrappers. All blocking work (file decode/resize/IO + DB) runs on a
//! `spawn_blocking` worker; the reload + toast hop back to the event loop.
//!
//! NOTE on webp: the workspace `image` crate is built with only the `jpeg` +
//! `png` features, so a `.webp` source decodes to an error at runtime (the
//! extension is accepted to match the Tauri picker filter, but the decode
//! surfaces the "upload failed" toast). png/jpg/jpeg are the working formats.
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use image::imageops::FilterType;
use slint::ComponentHandle;
use crate::artwork::ImageCache;
use crate::AppWindow;
/// The four extensions the Tauri picker accepts (spec §7.1 step 1).
const ALLOWED_EXTENSIONS: [&str; 4] = ["png", "jpg", "jpeg", "webp"];
/// Epoch SECONDS (NOT ms) — the custom-cover filename timestamp is in seconds
/// (spec §1.6 / §7.1 step 4).
fn epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Keep ascii-alphanumeric / `-` / `_`; everything else becomes `_` (spec
/// §7.1 step 5). The collection id is a UUID, so this is normally a no-op.
fn safe_id(id: &str) -> String {
id.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect()
}
/// Read the stored `custom_artwork_path` for a collection (used to delete the
/// previous file after a new one persists). Runs synchronously via `with_db`.
fn get_prev_path(id: &str) -> Option<String> {
crate::library_db::with_db(|db| {
Ok(db.with_connection(|conn| {
qbz_mixtape::repo::get_custom_artwork(conn, id).unwrap_or(None)
}))
})
.flatten()
}
/// Persist `path` (or clear with None) into `custom_artwork_path`. Returns true
/// on success. Runs synchronously via `with_db`.
fn set_custom_artwork(id: &str, path: Option<&str>) -> bool {
let id = id.to_string();
let path = path.map(|p| p.to_string());
crate::library_db::with_db(move |db| {
db.with_connection(|conn| {
qbz_mixtape::repo::set_custom_artwork(conn, &id, path.as_deref())
})
.map_err(|e| {
qbz_library::LibraryError::Database(format!("set_custom_artwork failed: {e}"))
})
})
.is_some()
}
/// Decode `source`, resize to 1000×1000 (Lanczos3), and save as a JPEG at
/// `dest`. Returns an error string on any failure (decode / resize / save).
fn resize_and_save(source: &Path, dest: &Path) -> Result<(), String> {
let img = image::open(source).map_err(|e| format!("decode failed: {e}"))?;
let resized = img.resize(1000, 1000, FilterType::Lanczos3);
resized
.to_rgb8()
.save_with_format(dest, image::ImageFormat::Jpeg)
.map_err(|e| format!("save failed: {e}"))
}
/// The blocking upload body (extension validation → resize/save → persist →
/// delete-prev). Returns Ok(dest) or Err(reason). Mirrors the Tauri command
/// step order exactly (persist BEFORE deleting the previous file, and only
/// delete when it differs).
fn do_upload(id: &str, source_path: &str) -> Result<String, String> {
let source = PathBuf::from(source_path);
if !source.exists() {
return Err("source file not found".to_string());
}
// 1 — extension validation.
let ext_ok = source
.extension()
.and_then(|e| e.to_str())
.map(|e| ALLOWED_EXTENSIONS.contains(&e.to_lowercase().as_str()))
.unwrap_or(false);
if !ext_ok {
return Err("unsupported image type".to_string());
}
// 2 — read previous path (to delete after persist).
let prev = get_prev_path(id);
// 3-6 — build the destination filename in the shared artwork cache dir.
let artwork_dir = qbz_library::get_artwork_cache_dir();
let filename = format!("mixtape_custom_{}_{}.jpg", safe_id(id), epoch_secs());
let dest = artwork_dir.join(&filename);
// 7 — decode + resize + save.
resize_and_save(&source, &dest)?;
// 8 — persist the new path.
let dest_str = dest.to_string_lossy().to_string();
if !set_custom_artwork(id, Some(&dest_str)) {
// Persist failed — clean up the orphan we just wrote, surface the error.
let _ = std::fs::remove_file(&dest);
return Err("failed to save cover".to_string());
}
// 9 — delete the previous file AFTER persist, only if it differs.
if let Some(prev) = prev {
if prev != dest_str {
let _ = std::fs::remove_file(&prev);
}
}
Ok(dest_str)
}
/// The blocking remove body: read previous → clear → delete prev file.
fn do_remove(id: &str) -> Result<(), String> {
let prev = get_prev_path(id);
if !set_custom_artwork(id, None) {
return Err("failed to clear cover".to_string());
}
if let Some(prev) = prev {
let _ = std::fs::remove_file(&prev);
}
Ok(())
}
/// Hero overflow "Set custom cover": open the native image picker, then upload
/// + resize + persist + reload. Toasts success/failure (spec §10 item 3).
pub fn upload(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
id: String,
) {
handle.clone().spawn(async move {
let Some(file) = rfd::AsyncFileDialog::new()
.set_title("Choose a cover image")
.add_filter("Image", &["png", "jpg", "jpeg", "webp"])
.pick_file()
.await
else {
return; // user cancelled — no toast.
};
let source = file.path().to_string_lossy().to_string();
let upload_id = id.clone();
let result = tokio::task::spawn_blocking(move || do_upload(&upload_id, &source))
.await
.unwrap_or_else(|e| Err(format!("upload task panicked: {e}")));
match result {
Ok(_) => {
crate::toast::success_weak(&weak, "Cover updated");
reload(weak.clone(), handle.clone(), image_cache.clone(), id);
}
Err(e) => {
log::warn!("[qbz-slint] myqbz_cover upload failed: {e}");
crate::toast::error_weak(&weak, "Failed to upload cover");
}
}
});
}
/// Hero overflow "Remove custom cover": clear + delete the file + reload.
pub fn remove(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
id: String,
) {
handle.clone().spawn(async move {
let remove_id = id.clone();
let result = tokio::task::spawn_blocking(move || do_remove(&remove_id))
.await
.unwrap_or_else(|e| Err(format!("remove task panicked: {e}")));
match result {
Ok(()) => {
crate::toast::success_weak(&weak, "Cover removed");
reload(weak.clone(), handle.clone(), image_cache.clone(), id);
}
Err(e) => {
log::warn!("[qbz-slint] myqbz_cover remove failed: {e}");
crate::toast::error_weak(&weak, "Failed to remove cover");
}
}
});
}
/// Reload the open detail view so the hero reflects the new cover. Re-runs the
/// detail navigator's load/apply/artwork path for the same id (Tauri's
/// "-> reload"). The `set_view` inside `navigate` is harmless (we're already on
/// the detail view).
fn reload(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
id: String,
) {
let _ = weak.upgrade_in_event_loop(move |w| {
let _ = &w; // keep the closure's capture explicit.
crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id);
});
}
+284
View File
@@ -0,0 +1,284 @@
//! My QBZ — collection detail EDIT operations (Phase-2 Slice 7).
//!
//! Wires the hero overflow (⋯) menu + the Rename / Description / Delete-confirm
//! modals to the shared `qbz_mixtape::repo` setters, reached directly through
//! `crate::library_db::with_db` (ADR-005/006 — no Tauri command wrappers). Each
//! mutation mirrors its Tauri command (spec 40 §3.5/§3.6) and then RELOADS the
//! open detail view so the hero + state reflect the change (Tauri's
//! "-> reload"):
//!
//! - **Rename** (`v2_rename_mixtape_collection`): trim; empty -> no-op.
//! - **Description** (`v2_set_mixtape_description`): empty -> NULL (clear).
//! - **Play-mode toggle** (`v2_set_mixtape_play_mode`): in_order <-> album_shuffle.
//! - **Convert kind** (`v2_set_mixtape_kind`): mixtape <-> collection; the repo
//! REJECTS any artist_collection conversion -> the "Cannot convert this kind"
//! toast. Success -> "Converted".
//! - **Delete** (`v2_delete_mixtape_collection`, CASCADE): navigate BACK (which
//! re-applies the previous grid entry, dropping the deleted row) on success;
//! "Failed to delete" toast on error.
//!
//! All DB work runs synchronously inside `with_db` on a `spawn_blocking` worker
//! (no `&Connection` crosses an `.await`); the reload + toast hop back to the
//! event loop.
use qbz_models::mixtape::{CollectionKind, CollectionPlayMode};
use slint::ComponentHandle;
use crate::artwork::ImageCache;
use crate::{AppWindow, MyQbzEditState, NavState};
// ──────────────────────────── DB write helpers ────────────────────────
/// Run a repo mutation that returns `rusqlite::Result<()>` against the per-user
/// library.db. Returns `Ok(())` on success, `Err(message)` on any failure
/// (DB unavailable or the repo error). Synchronous (`with_db`).
fn with_repo<F>(f: F) -> Result<(), String>
where
F: FnOnce(&rusqlite::Connection) -> rusqlite::Result<()> + Send,
{
match crate::library_db::with_db(|db| Ok(db.with_connection(f))) {
Some(Ok(())) => Ok(()),
Some(Err(e)) => Err(e.to_string()),
None => Err("library database unavailable".to_string()),
}
}
// ──────────────────────────── reload after mutation ───────────────────
/// Reload the open detail view for `id` (Tauri's "-> reload") so the hero +
/// toolbar reflect the mutation. Re-runs the detail navigator's
/// load/apply/artwork path; the inner `set_view` is harmless (already there).
fn reload(
weak: &slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: &ImageCache,
id: String,
) {
let handle = handle.clone();
let image_cache = image_cache.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
crate::myqbz_detail::navigate(w.as_weak(), handle.clone(), image_cache.clone(), id);
});
}
// ──────────────────────────── public entry points ─────────────────────
/// Rename modal submit: trim the draft; empty -> close without writing. Else
/// `repo::rename_collection` -> reload -> close. Caps at 80 chars (Tauri
/// `<input maxlength=80>`).
pub fn rename(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
id: String,
raw_name: String,
) {
let name: String = raw_name.trim().chars().take(80).collect();
if id.is_empty() || name.is_empty() {
close_modal(&weak);
return;
}
set_busy(&weak, true);
handle.clone().spawn(async move {
let write_id = id.clone();
let write_name = name.clone();
let result = tokio::task::spawn_blocking(move || {
with_repo(|conn| qbz_mixtape::repo::rename_collection(conn, &write_id, &write_name))
})
.await
.unwrap_or_else(|e| Err(format!("rename task panicked: {e}")));
finish(&weak, &handle, &image_cache, id, result, None, "Failed to rename");
});
}
/// Description modal submit: trimmed empty -> NULL (clear). Else set it.
/// `repo::set_description` -> reload -> close.
pub fn set_description(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
id: String,
raw_description: String,
) {
if id.is_empty() {
close_modal(&weak);
return;
}
let trimmed = raw_description.trim().to_string();
let desc: Option<String> = if trimmed.is_empty() { None } else { Some(trimmed) };
set_busy(&weak, true);
handle.clone().spawn(async move {
let write_id = id.clone();
let write_desc = desc.clone();
let result = tokio::task::spawn_blocking(move || {
with_repo(|conn| {
qbz_mixtape::repo::set_description(conn, &write_id, write_desc.as_deref())
})
})
.await
.unwrap_or_else(|e| Err(format!("description task panicked: {e}")));
finish(&weak, &handle, &image_cache, id, result, None, "Failed to save description");
});
}
/// Hero overflow play-mode toggle: flip in_order <-> album_shuffle. Reads the
/// current mode from `MyQbzDetailState.play_mode`, persists the OTHER, reloads.
pub fn toggle_play_mode(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
id: String,
current_mode: String,
) {
if id.is_empty() {
return;
}
let next = if current_mode == "in_order" {
CollectionPlayMode::AlbumShuffle
} else {
CollectionPlayMode::InOrder
};
handle.clone().spawn(async move {
let write_id = id.clone();
let result = tokio::task::spawn_blocking(move || {
with_repo(|conn| qbz_mixtape::repo::set_play_mode(conn, &write_id, next))
})
.await
.unwrap_or_else(|e| Err(format!("play-mode task panicked: {e}")));
finish(&weak, &handle, &image_cache, id, result, None, "Failed to change play mode");
});
}
/// Hero overflow convert-kind: flip mixtape <-> collection. The repo rejects
/// any artist_collection conversion -> "Cannot convert this kind"; success ->
/// "Converted". Reads the current kind from `MyQbzDetailState.kind`.
pub fn convert_kind(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
image_cache: ImageCache,
id: String,
current_kind: String,
) {
if id.is_empty() {
return;
}
// artist_collection is non-convertible (the menu item is hidden for it, but
// guard here too).
let next = match current_kind.as_str() {
"mixtape" => CollectionKind::Collection,
"collection" => CollectionKind::Mixtape,
_ => {
crate::toast::error_weak(&weak, "Cannot convert this kind");
return;
}
};
handle.clone().spawn(async move {
let write_id = id.clone();
let result = tokio::task::spawn_blocking(move || {
with_repo(|conn| qbz_mixtape::repo::set_kind(conn, &write_id, next))
})
.await
.unwrap_or_else(|e| Err(format!("convert-kind task panicked: {e}")));
match result {
Ok(()) => {
crate::toast::success_weak(&weak, "Converted");
reload(&weak, &handle, &image_cache, id);
}
Err(_) => {
// The repo's only rejection here is the artist_collection guard.
crate::toast::error_weak(&weak, "Cannot convert this kind");
}
}
});
}
/// Delete-confirm: `repo::delete_collection` (CASCADE) -> navigate BACK (which
/// re-applies the previous grid entry, so the deleted row is gone) -> close.
/// "Failed to delete" toast on error.
pub fn delete(
weak: slint::Weak<AppWindow>,
handle: tokio::runtime::Handle,
id: String,
) {
if id.is_empty() {
close_modal(&weak);
return;
}
set_busy(&weak, true);
handle.spawn(async move {
let write_id = id.clone();
let result = tokio::task::spawn_blocking(move || {
with_repo(|conn| qbz_mixtape::repo::delete_collection(conn, &write_id))
})
.await
.unwrap_or_else(|e| Err(format!("delete task panicked: {e}")));
let _ = weak.upgrade_in_event_loop(move |w| {
let es = w.global::<MyQbzEditState>();
es.set_busy(false);
match result {
Ok(()) => {
es.set_open(false);
es.set_mode("".into());
// Navigate back: re-applies the previous grid entry, which
// re-lists collections from the DB (the deleted one is gone).
w.global::<NavState>().invoke_request_back();
}
Err(e) => {
log::warn!("[qbz-slint] myqbz_edit delete failed: {e}");
crate::toast::error(&w, "Failed to delete");
}
}
});
});
}
// ──────────────────────────── modal-state helpers ─────────────────────
/// On a successful mutation: close the modal (if any), reload, and (when given)
/// toast a success message. On failure: clear busy + toast `err_msg`.
fn finish(
weak: &slint::Weak<AppWindow>,
handle: &tokio::runtime::Handle,
image_cache: &ImageCache,
id: String,
result: Result<(), String>,
success_toast: Option<&'static str>,
err_msg: &'static str,
) {
match result {
Ok(()) => {
if let Some(msg) = success_toast {
crate::toast::success_weak(weak, msg);
}
close_modal(weak);
reload(weak, handle, image_cache, id);
}
Err(e) => {
log::warn!("[qbz-slint] myqbz_edit mutation failed: {e}");
set_busy(weak, false);
crate::toast::error_weak(weak, err_msg);
}
}
}
/// Close the edit modal (clears mode + busy). UI thread hop.
fn close_modal(weak: &slint::Weak<AppWindow>) {
let _ = weak.upgrade_in_event_loop(|w| {
let es = w.global::<MyQbzEditState>();
es.set_open(false);
es.set_mode("".into());
es.set_busy(false);
});
}
/// Set the modal busy flag (disables submit) from any thread.
fn set_busy(weak: &slint::Weak<AppWindow>, busy: bool) {
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<MyQbzEditState>().set_busy(busy);
});
}
+5
View File
@@ -108,3 +108,8 @@ pub fn error_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
pub fn success_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
show_weak(weak, message, ToastKind::Success);
}
/// Info toast from any thread.
pub fn info_weak(weak: &Weak<AppWindow>, message: impl Into<String>) {
show_weak(weak, message, ToastKind::Info);
}