diff --git a/docs/KEYBOARD.md b/docs/KEYBOARD.md index 21dc63c5e..87dcef1b5 100644 --- a/docs/KEYBOARD.md +++ b/docs/KEYBOARD.md @@ -18,8 +18,7 @@ Actions with no default key are not listed; every one of them can still be bound |-----|--------| | `Left` | Previous file | | `Right` | Next file | -| `Ctrl + L` | Open the library folder | -| `Alt + Up` | Go up one library folder | +| `Ctrl + L` | Open the library | | `Ctrl + F` | Focus the film strip search box | | `Ctrl + Shift + F` | Search every library folder and load the matches | @@ -148,7 +147,7 @@ Actions with no default key are not listed; every one of them can still be bound ## Tabs | Key | Action | |-----|--------| -| `Ctrl + 1` | Setup tab | +| `Ctrl + 1` | Roll tab | | `Ctrl + 2` | Geometry tab | | `Ctrl + 3` | Tone tab | | `Ctrl + 4` | Lab & Toning tab | diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 1211fac68..a918b3742 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -15,7 +15,7 @@ If Windows blocks writes to NegPy's default data folder, NegPy suggests `%LOCALA ### Screen layout * **Left, the film strip**: your loaded frames as a contact sheet, plus import, sorting, and triage tools. -* **Center, the canvas**: the live preview of the current frame. Most tools (crop, white-balance picker, heal brush, dodge/burn masks) are used by clicking directly on it. Scroll/pinch to zoom and drag to pan; a floating toolbar along the bottom holds Fit/1:1 zoom (**1:1** is one scan pixel per screen pixel, and lights up while you are at it; below **HQ** the preview is scaled up to reach it, which a **preview res · HQ off** pill on the canvas says) plus undo/redo, rotate/flip and more, moving overflow items into an **⋯** menu when the window narrows. What does not fit collapses from the right, and the **⋯** menu keeps every action whatever the row shows. **Preferences…** in that menu holds every app-wide setting (§14): the interface options, the performance budgets, **Edit Toolbar…** for which controls sit on the row and in what order, and **Persistent Settings…** for what carries onto the next file you open. Right-click the image for **Reset View** and **Sticky Zoom** (keeps the current zoom level when you switch to another frame, instead of resetting to fit), alongside the picker tools, copy/paste settings, and **Unload** (removes the frame from the session; its saved edit is kept). With nothing loaded it shows **Load some scans to get started**; click it for **Add Files** / **Add Folder**. +* **Center, the canvas**: the live preview of the current frame. Most tools (crop, white-balance picker, heal brush, dodge/burn masks) are used by clicking directly on it. Scroll/pinch to zoom and drag to pan; a floating toolbar along the bottom holds Fit/1:1 zoom (**1:1** is one scan pixel per screen pixel, and lights up while you are at it; below **HQ** the preview is scaled up to reach it, which a **preview res · HQ off** pill on the canvas says) plus undo/redo, rotate/flip and more, moving overflow items into an **⋯** menu when the window narrows. What does not fit collapses from the right, and the **⋯** menu keeps every action whatever the row shows. **Preferences…** in that menu holds every app-wide setting (§15): the interface options, the performance budgets, **Edit Toolbar…** for which controls sit on the row and in what order, and **Persistent Settings…** for what carries onto the next file you open. Right-click the image for **Reset View** and **Sticky Zoom** (keeps the current zoom level when you switch to another frame, instead of resetting to fit), alongside the picker tools, copy/paste settings, and **Unload** (removes the frame from the session; its saved edit is kept). With nothing loaded it shows **Load some scans to get started**; click it for **Add Files** / **Add Folder**. * **Right, the controls**: a pinned **Analysis** readout at the top, and below it an icon tab bar. Each icon opens a *workflow page* holding one or more collapsible panels. NegPy remembers the window size and whether it was maximized, the panel widths and which edge each panel sits on, which sections were open, and which controls tab was active. **Reset Panel Layout** in the **⋯** menu puts both panels back at their home edges and widths. @@ -38,26 +38,37 @@ While a peek is up the canvas carries a **NEGATIVE**, **EMBEDDED** or **FLAT SCA ### The workflow (and the order things happen) -The right-hand tabs follow the order you work in, which mirrors the processing pipeline: +**Roll** leads: what the whole roll shares, set once rather than edited per frame. + +| Tab | Icon | Panels | What it is for | +|-----|------|--------|---------------| +| **Roll** | film | Calibration · Demosaic · Normalization · Presets | Film type, capture-side color corrections, negative→positive normalization, roll-wide baselines | + +Beside Roll sits **Frame**, whose own tab bar follows the order you work in, mirroring the processing pipeline: | Tab | Icon | Panels | What it is for | |-----|------|--------|---------------| -| **Setup** | cogs | Presets · Calibration · Process · Roll Analysis | Film type, capture-side color corrections, negative→positive normalization, roll-wide baselines | | **Geometry** | crop | Geometry · Flat Field | Crop, straighten, lens and falloff correction | | **Exposure** | sun | Filtration · Tone · Dodge & Burn | White balance, print density/contrast/curve/saturation, local burns | | **Color** | palette | Lab · Alternative Processes · Toning | Chroma, sharpening, effects, lith and cyanotype printing, split and chemical toning | | **Finish** | brush | Retouch · Finishing | Dust removal, vignette, border, carrier | | **Favorites** | star | Your chosen sliders | Quick access to the controls you use most | | **History** | clock | Work prints · Edit history | Keep named versions, step back through every change | -| **Export** | file | Export settings | Format, size, color, batch output | + +Beside Frame and Roll, the panel's own tab bar holds the tabs that never change the render: + +| Tab | Icon | Panels | What it is for | +|-----|------|--------|---------------| | **Metadata** | tags | Archival metadata | Original camera, lens and film details | +| **Gear** | toolbox | Gear library | Manage cameras, lenses, film stocks, processes and scan setups | +| **Export** | file | Export settings | Format, size, color, batch output | | **Scan** | camera | Scanner · Camera Scanning | Capture film directly (Linux/macOS) | You do not have to touch every panel. The defaults are tuned to produce a good print straight away, and most frames need only a crop, perhaps a white-balance nudge, and export. A small **dot** on a panel header, and on a tab icon, means you changed something from its default. Every panel header has a **reset** action and an **ⓘ** that opens this guide at that panel's section. -Both side panels can be narrowed to give the canvas more room. As the controls panel shrinks, tab icons that no longer fit move into a **»** menu at the right of the tab bar. The tab you are on always stays visible. +Every panel can be narrowed to give the canvas more room. As a panel shrinks, its tab icons that no longer fit move into a **»** menu at the right of its own tab bar. The tab you are on always stays visible. ### What carries to the next frame @@ -88,46 +99,45 @@ A menu shows a key only when that key uses `⌘`. A shortcut bound to a plain ke ## 2. Film strip (left panel) -The header shows the NegPy logo and version. The **↻** button beside the version number asks GitHub for a newer release on demand; it becomes a green **⬇** when one is out. When a newer release is out, a green **⬇ Update Available** line also appears under the version; click either to read what changed and let NegPy install it ([§15](#15-updating-negpy)). The chevron at the header's top-right folds the branding away to give the frames more room. +The header shows the NegPy logo and version. The **↻** button beside the version number asks GitHub for a newer release on demand; it becomes a green **⬇** when one is out. When a newer release is out, a green **⬇ Update Available** line also appears under the version; click either to read what changed and let NegPy install it ([§16](#16-updating-negpy)). The chevron at the header's top-right folds the branding away to give the frames more room. -Below the header: the toolbar, the search box, and two collapsible sections. **Library** holds the folders your scans live in; **Film Strip** holds the frames you have open. Click either heading to fold it away; the one still open takes the whole panel. NegPy remembers which were open. +Below the header: the toolbar, the search box, and two collapsible sections. **Library** holds the folders your scans live in; **Film Strip** holds the frames you have open. Click either heading to fold it away; the one still open takes the whole panel. Drag the handle between them to resize the split; NegPy remembers both which were open and where you left the handle. ### Your library -The **Library** section is a folder tree of the places your scans live. Press **+** to add a folder, and point it at the one big `Scans` directory you keep everything under, subfolders and all. **↻** re-reads it from disk. Each row shows what is inside it ("36 photos", "2 folders"), and subfolders are read when you expand them. +The **Library** section lists every **roll** you have imported: a named, openable group of frames, not a live view of a folder. **Ctrl+L** opens the section, importing a first roll when you have none yet. Its own corner row holds three controls: **Sort** orders the list by Name or Date, ascending or descending, and the Film Strip follows the same order; **+** imports a roll; **↻** re-reads every roll's frame count from disk. Each row shows its name and count ("36 photos"). -**Browsing costs nothing.** NegPy opens, decodes and hashes nothing when you add a folder or click through the tree. It only lists what is there. +**Listing costs nothing.** NegPy opens, decodes and hashes nothing when you import a folder — importing only recognizes it. Opening a roll is the step that hashes and thumbnails. -#### The Library button +#### Importing -The **Library** button (book icon, first in the toolbar, or **Ctrl+L**) opens the folder your scans live in. The first time you press it, NegPy asks you to pick that folder and remembers it. The panel also goes there on its own: on launch when you do not restore a session, and whenever you unload the last frame. Your rolls are a more useful resting state than an empty sheet. +**+** (or the list's right-click menu) offers two actions: -To point it somewhere else, add another folder with **+**. To forget them all, use **Clear Library** in *Manage Database*. That clears the list of folders only, leaving your images, folders and edits untouched. +* **Import Folder as a Roll…** — pick one folder; it becomes one roll and opens. If its name matches a camera or film stock already in your Gear library (a word, or a run like "penf" for "Pen F"), Roll Settings opens pre-filled and ticked for the match — Apply to keep it, Cancel to skip it. Never guesses at gear you have not added. +* **Import Subfolders as Rolls…** — pick a parent folder, for example the one your scanner saves into; every folder directly inside it becomes its own roll in one pass, without opening any of them. One level only: a roll's own subfolders are not walked, so a deeper structure needs a second, more specific import. -#### Walking around +Either way NegPy reads the folder from disk and never creates, renames, moves or deletes anything in it: reorganize in Finder or Explorer, then re-import (or **↻**) to pick up the new arrangement. Every edit is stored against the image's content, so moving a file between folders keeps its edit, its history and its keep/reject mark. -* **Click** a folder to select it, **double-click** (or **Enter**) to open it. -* **Ctrl+click** several folders and open them together to load more than one roll at once. NegPy asks once, for the total. -* **Alt+Up** moves the selection to the folder above. -* The tree sorts the way the sheet does. Change **Sort** to Date or Descending and the folders follow. +#### Opening, renaming, deleting -When you open a folder that contains images, NegPy asks whether to **load the roll**. Only then does it hash and thumbnail them, which is the part that takes a moment on a big roll. Say no and your open frames stay as they were. Tick **Always load without asking** in that prompt if you would rather it just get on with it. +**Click** a roll to select it, **double-click** (or **Enter**) to open it. Opening asks whether to **load the roll** — only then does NegPy hash and thumbnail its frames, which is the part that takes a moment on a big roll. Say no and your open frames stay as they were. Tick **Always load without asking** in that prompt if you would rather it just get on with it. Opening a roll replaces what is in the Film Strip; nothing is lost either way, because your edits live in NegPy's database, keyed to each image, not to the list of open files. -Loading a roll replaces what is in the film strip; right-click → **Add to Session** appends instead. Nothing is lost either way, because your edits live in NegPy's database, not in the list of open files. +Right-click a roll for **Rename…** and **Delete…**. Renaming a folder roll offers **Also rename the folder on disk**, unticked by default and asked fresh every time: left off, only the library label changes and the folder keeps its name, same as a virtual roll; ticked, NegPy renames the actual folder in place and updates its own record to match, refusing (with a warning, nothing touched) if a sibling already has that name or it lacks permission to rename it there. A folder inside a cloud-sync directory (Dropbox, iCloud, OneDrive) may see this as a delete and re-upload rather than a rename. Deleting only forgets the roll record — the folder, its images and their edits are untouched, and a folder roll can always be re-imported. To forget every roll at once, use **Clear Library** in *Manage Database*. -#### Folders are your folders +#### Rolls that are not folders -NegPy reads the tree straight from disk and never creates, renames, moves or deletes anything in it. Reorganize in Finder or Explorer and the tree shows the new arrangement at the next refresh. Every edit is stored against the image's content, so moving a file between folders keeps its edit, its history and its keep/reject mark. +A roll does not have to be a folder, and the same photo can belong to more than one roll at once: a folder roll's icon is **amber**, one built from a search or a hand-picked set of frames is **red**. Search the library (the filter box's magnifier-over-folder button) or otherwise assemble a set of frames, then **Save as Roll…** in the Film Strip's row names and keeps it, right alongside your folder rolls. It is a fixed set of frames, not a live search: adding to it later means reopening it and using Save as Roll again, or adding to the session while it is the one loaded. Edits are unaffected either way — a frame's edit is its own, the same wherever it is opened from. ### Importing and managing files **A note on Nikon High Efficiency raw.** The Z 8 and Z 9 can record NEFs in **High Efficiency (HE)** or **HE\***, which use a licensed codec NegPy cannot decode. Such a file is still called `.NEF` and still carries the same TIFF compression tag as an ordinary lossless NEF, so nothing looks unusual until it fails to open. NegPy names the reason rather than reporting a generic unsupported-file error. Re-shoot in **Lossless Compressed** NEF, or convert with Adobe DNG Converter. Lossless NEFs from the same cameras open normally. -Toolbar buttons, left to right: +The **⋮** menu on the Film Strip section's own header, beside its ⓘ guide, holds **New Roll…**: clears the film strip, so you can drag in a fresh batch of frames to build a roll from scratch — **Save as Roll…**, below, is what turns that batch into a reopenable one. This is the same drop as **Clear All…** in the empty-space right-click menu. **Reset Roll to Defaults…**, beside it, undoes every visible frame's edit at once — the same reset **Reset Settings** does for one frame, applied to the whole roll; it asks first, and each frame's reset is still an ordinary undo step. -* **Add Files** / **Add Folder**: load individual images or every image in a folder. Pick a folder that holds only *other* folders and NegPy reveals it in the Library section instead of reporting that it found nothing. Dropping a folder on the window does the same. -* **Clear All…**: unload everything, or just the selected frames. +The Film Strip section has its own row of buttons above the frames, for actions that read or rewrite the loaded roll: + +* **Add** (import icon): **Add Files…** or **Add Folder…** — load individual images, or every image in a folder, into this session. Pick a folder that holds only *other* folders and NegPy points you at Library's **Import Subfolders as Rolls…** instead of reporting that it found nothing. Dropping a folder on the window does the same as Add Folder — most of the time that is the faster path anyway. * **Hot Folder**: watches the current folder and auto-loads new files as they appear, which is handy when a scanner or tethering app drops files into a directory. While it is on, the "Working…" import popup stays hidden so each new frame does not raise a window; the status line over the canvas still reports the import. * **Trichrome Scan** (three-exposure narrowband capture, also called trichromatic capture): treats the folder as red/green/blue exposure triplets and assembles each frame from three shots. Shots are grouped in the order they were taken, read from the files themselves, so filenames need follow no convention. Capture each frame's three exposures back to back, before moving on to the next frame. Where a file states no capture time, the folder falls back to filename order, and the names then have to sort into capture order. Right-click a frame → **Edit RGB Triplet…** to assign the three files by hand. Three shots are only assembled when they are one of each color *and* show the same frame; where they do not, they are left as separate frames to pair by hand, rather than assembled wrong. An assembled frame carries the three-dot badge described under [Triage](#triage-culling-the-roll). * **Half Frame**: splits each scan into two frames, for half-frame cameras. Each half is edited and metered separately and badged with which half it is. Enabling it opens a rectangle editor on the current scan: drag the green box to crop (everything outside is discarded), drag the orange line to set the split, and use **Cut thickness** to discard a band centered on the split, which is the physical black separator between the two exposures. **Auto-detect**, in the editor, re-finds the gutter on the current scan. @@ -140,10 +150,12 @@ Toolbar buttons, left to right: Half Frame does not apply to a frame assembled from more than one file: a Trichrome triplet, a stitch, or an HDR merge. Those are never split, and they never come back as a diptych, even when the file they are built around was worked on as two halves earlier. * **Apply (clone)**: copy the current frame's settings to selected frames or the whole roll. You choose which aspects in a dialog; crop and rotation are always per-image. +* **Roll Settings** (tag icon): tag gear, capture, place, process and scanning metadata for the current frame, a selection or the whole roll in one dialog. Fields start filled from the active frame; type or pick new values directly, or **Load** a metadata preset to fill and tick its fields, then tick which groups to write. Defaults to the whole roll when one is loaded. If Gear is not already set, opening it also checks the roll's folder name against your Gear library and pre-fills a match the same way the import-time suggestion does — it never overwrites a camera or film stock you have already tagged. +* **Save as Roll…** (red folder icon): name and keep the frames currently loaded as a roll, whether or not they came from a folder. See [Rolls that are not folders](#rolls-that-are-not-folders). +* **Unload…**: drop the active frame, or the whole selection when more than one is selected. Never the rest of the roll — for that, see *Clear All…* below. * **Sheet filter** (funnel): show *All Frames*, *Keepers Only*, or *Hide Rejected*. The choice is remembered between sessions and applies to every roll you open. -* **Sort**: by Name or Date, ascending or descending. -Above both sections sit a **filter box**, a **`.*`** regex toggle and a **search-library** button. Inside the Film Strip section is a **tally**, for example "36 frames · 12 keepers · 3 rejected". While a filter hides frames the tally counts both sets and names the filter, for example "3 of 36 frames · Keepers filter". When a filter hides every frame, the strip carries a message with a **Show all frames** link that clears the filter box and the funnel together. +Above both sections sit a **filter box**, a **`.*`** regex toggle and a **search-library** button, shared by the Library tree and the Film Strip alike. Inside the Film Strip section, beside its **tally** (for example "36 frames · 12 keepers · 3 rejected"), a **thumbnail size** slider resizes its grid — smaller fits more columns in the panel. While a filter hides frames the tally counts both sets and names the filter, for example "3 of 36 frames · Keepers filter". When a filter hides every frame, the strip carries a message with a **Show all frames** link that clears the filter box and the funnel together. When the loaded frames came from a roll, its name leads the tally, for example "Portra 400 — 36 frames"; a batch loaded by Add or drag-drop with no roll behind it shows the count alone. #### Filtering the sheet @@ -333,150 +345,10 @@ The numeric rows at the bottom. Each has the same explanation on hover, and each --- -## 4. Setup tab - -**Film mode** sits above the panels, because it is the first choice of every edit: **Color** (C-41 color negative), **B&W** (panchromatic negative) or **Slide** (transparency/reversal, E-6 and friends). Each swaps the core conversion math and re-runs the pipeline from scratch. The wand button beside them **auto-detects** the mode when a file loads. - - -### 4.1 Calibration: what your rig does to the colors - -Everything here corrects the *capture*, not the look. Three different things sit between the scene and your file: the camera's color filters, the film's dyes, and the light source. Each gets its own control. They are not interchangeable, and none substitutes for another. - -**Capture**, how the file is decoded before any of that: - -* **Scanning setup** (bulb button): a two-question wizard, *how do you scan?* then *what light source?*, that sets Linear RAW and Narrowband for you. It runs once after the first-launch tour, and the button reopens it whenever your rig changes. -* **Linear RAW** (default off): decodes with neutral multipliers for completely raw data. When off, it decodes RAW with the camera's as-shot white balance. Toggling it reloads the file. Let the **Scanning setup** wizard pick it, or try both and keep whichever gives better results. **Locked on for a Trichrome triplet**: a narrowband exposure has no full-spectrum scene for a white-balance gain to describe, so every triplet exposure decodes neutral regardless of this toggle. It stays visible and remembered, and unlocks again once the frame is no longer a triplet. -* **Narrowband**: corrects the oversaturation typical of narrowband (RGB-LED) capture, using a bundled input profile. Leave it off for ordinary broadband scans. An explicit Input ICC in Export overrides it. It is **grayed out on Transparency**; see *Narrowband and slides* below. - -What the wizard sets, by rig: - -| Capture | Light source | Linear RAW | Narrowband | -| --- | --- | --- | --- | -| Digital camera | White light (lightbox, CRI LED panel) | off | off | -| Digital camera | Narrowband RGB (Scanlight, RGB LED) | on | on | -| Film scanner | White light (Plustek, Epson, most flatbeds) | on | off | -| Narrowband Scanner | Nikon Coolscan, Kodak Pakon | on | on | - -Applying it sets the defaults for newly loaded files, updates the open frame, and rewrites every already-edited frame in the session, undoable per frame with Ctrl+Z. - -**Single-Shot Narrowband Calibration** is for single-shot camera scans under narrowband light. The camera's color filters overlap the light's bands, so a pure red exposure leaks a little into green and blue. That leak is a fixed property of your sensor and light together and has nothing to do with the film, so it is corrected on the linear capture before inversion. - -* **Profile**: the sensor matrix to apply. Custom `.toml` matrices live in `/NegPy/sensor/`. -* **Calibrate** (vials icon): build a profile from three bare-light R/G/B exposures. - -This block grays out unless **Linear RAW** is on, since profiles are calibrated against neutral white balance and the as-shot gains would misapply the matrix. It also grays out on **Transparency**, for the reason below. Your selection is remembered either way. It is also skipped for RGB-triplet assets, which never had the leak. It changes what the analysis reads, so **re-run Batch Analysis** after changing it. - -#### Narrowband and slides - -**Narrowband and Single-Shot Narrowband Calibration do not apply to Transparency**, with or without Normalize. Both stay visible and grayed so you can see what your rig is set to, both keep their values, and both come back the moment the frame is a negative again. - -Narrowband is a way to scan negatives. Its payoffs, defeating the orange mask and separating the dyes cleanly before a high-gain inversion, are things a slide does not need, and the bundled profile describes narrowband capture of *negative* dyes, which a slide does not have. Single-Shot Narrowband Calibration goes with it: the matrix un-mixes a narrowband light against your sensor's filters, so it means something only for a capture made under narrowband light, and there is no way to build one for a broadband capture. - -This matters because both are sticky and follow your rig from frame to frame, so a profile set up for your negatives would otherwise arrive on a slide you never touched. If you do scan slides on a narrowband rig, reach for **Hue Trim** instead: it corrects the hue rotation an unusual light imposes, which is the part that can be corrected. - -**Crosstalk** (hidden in B&W Negative) is a channel unmix applied to the raw densities before inversion. The dropdown lists only matrices for the film you are processing, because a Color Negative matrix does not describe a Transparency's dye set, and a mismatched stored profile resolves to no correction rather than the wrong one. - -The film's dyes each absorb outside their own band, but they are not the only cause: your light's spectrum and your sensor's color filters mix the channels too, and in the density domain all three arrive as the same kind of error. So treat the matrix as *your whole scanning setup*, not just the film. A profile that works beautifully on one rig may be wrong on another with the same stock. - -* **Matrix**: the profile to apply, grouped in the dropdown by where its numbers came from (measured, tuned on a rig, or from spec sheets). *Generic C41* is the built-in; drop custom `.toml` matrices in `/NegPy/crosstalk/` (see [CROSSTALK.md](CROSSTALK.md)). The slider button opens a matrix editor, where a **Type** control records that provenance and a **Process** control says which film the numbers describe. Process decides where the profile appears and whether it applies, so a matrix you build for slides needs it set to E-6. Anything created with **+** is already set to the process you are working in. - When the current film process has no matrices at all, the dropdown and **Strength** are disabled and a hint says so. The editor button stays live, because it is the way to build the first one. -* **Strength** (0.0 to 1.0): how much of the unmix to apply, for richer and cleaner color separation. It changes what the analysis reads, so **re-run Batch Analysis** after changing it. - -> **The bundled film matrices are derived from published spec sheets, not measured**, which is why they are all marked *(approx)*. They describe the film's **dyes alone**, so they are the whole story only where your capture reads each dye cleanly: a **Narrowband Scanner** (a Coolscan's mono sensor reads one LED at a time, fully clean; a Pakon's trilinear array comes close, with slight residual bleed), a **Trichrome** capture, or a Single-Shot Narrowband rig with **Single-Shot Narrowband Calibration** applied (see above). With a broadband light and a Bayer sensor, the capture adds mixing of its own that a dyes-only matrix does not describe. It may still help, but treat the number as a starting point rather than a correction for your setup. - -**Worth experimenting with.** If a stock or a light gives you trouble, open the matrix editor, nudge the six off-diagonal terms and save the result as your own profile. Name it after the *combination*, "Gold 200 + Spectracolor", not just the film. A profile you tuned on your own rig beats any datasheet, and profiles that work are worth [contributing back](CROSSTALK.md#contributing-a-matrix), since nobody can guess your light and sensor for you. - -**Light source:** - -* **Hue Trim** (-30° to 30°, default 0): rotates every hue by a fixed angle, to undo the rotation an unusual scanning light imposes. Narrowband LED and odd-phosphor panels sample the dyes away from where the film expects, which turns *every* color by roughly the same angle, so yellows read orange and greens go olive, while neutrals are left alone. That is why white balance cannot fix it: the error is a rotation, not a cast, so there is no gray to correct. Judge it on a subject whose color you know (foliage, a clear blue sky, skin), and leave it at 0 for an ordinary broadband light. The setting is **sticky**, because a light source is a property of your rig, so it carries to the next file until you change it. Neutrals are untouched, so it never disturbs the color-balance clip in **Normalization**. - - -### 4.2 Demosaic: turning the sensor mosaic into pixels - -A color sensor records one color per photosite behind a mosaic filter, and an algorithm fills in the other two. Which one you pick decides how sharp the result looks and how it treats film grain. Preview and export are chosen separately, and both are sticky. - -Bayer and X-Trans RAW only: a scanner TIFF, a Pakon scan or a linear DNG arrives already de-mosaiced. Only the algorithms your LibRaw build compiled in are listed. - -* **Preview** / **Export** (default **Auto** for both): *Auto* keeps NegPy's own choice, a fast half-size decode on screen and AHD for export. For the preview, Auto and Linear are the fastest; the others decode at full size. **AHD** is LibRaw's balanced default, **VNG** the smooth one, **PPG** fast with clean edges, **DCB** and **DHT** chase fine detail, and **AAHD** softens edges to suppress artifacts. - - -### 4.3 Normalization: negative → positive - -How the negative is measured and normalized into a positive. The film mode that decides *which* conversion runs sits above the panels (§4), and how the scan is decoded lives in **Calibration** (§4.1). - -* **Multi-core CPU rendering** (**Preferences → Performance**, beside **GPU acceleration**): spreads the CPU rendering kernels across your cores. It takes effect immediately, with no recompile and no restart. - - Be realistic about the gain. The kernels run much faster, but a merge is dominated by decoding the RAW files, which this does not touch, so the whole operation comes down by only about a tenth. Ordinary editing changes less again, because the GPU already carries the pipeline. The gain is largest wherever the CPU does the work: merges, exports, and any machine without a usable GPU. - - On Windows and Linux this is **on**. On macOS it is **off**, pending more evidence: the underlying threading layer terminates the process outright if two threads enter it at once, and while NegPy serialises every such call behind a lock, that has been proven on one Mac rather than on the range of them. If you turn it on and the app ever closes without warning, NegPy notices on the next launch and offers to turn it back off; that is the failure to expect, and it is recoverable. Setting `cpu_parallel` under `[performance]` in `override.toml` still wins over Preferences, for a machine that cannot start. - -**Analysis window**, where NegPy measures the black and white points. The slider takes half the row, the three buttons the other half: - -* **Analysis Buffer** (0.0 to 0.25): insets the measurement window from the frame edge so film rebate, sprocket holes and scanner borders do not skew detection. Raise it on scans with wide borders. -* **Analysis Region** (square-draw tool): draw a freehand region on the canvas to meter *exactly* that area, overriding the buffer. Double-click inside to confirm; the ✕ button clears it. -* **Lock Bounds** (padlock): freezes the analyzed normalization bounds for this frame, so cropping or moving sliders no longer re-analyzes it. Lock it in once you are happy with the bounds. - -**Normalization tuning:** - -* **Luma Range Clip** (-100 to 100): how aggressively the tonal range, the black/white-point span, is set. Neutral already applies a small robust clip. Positive tightens it, which is good for dense or fogged negatives where a few stray pixels would push the bounds to extremes. Negative pushes the bounds *outward*, for lifted blacks and unclipped highlights. -* **Color Clip** (-100 to 100): the per-channel color-balance clip (orange-mask removal), independent of the tonal range. Positive tightens channel balance; negative samples nearer the extremes. -* **Global / R / G / B** selector → **White Point** / **Black Point** (-0.25 to 0.25): manual offsets on top of the auto-detected bounds. A positive white point brightens; a positive black point lifts blacks. In R/G/B mode these become per-layer trims: per-dye-layer film-base (Dmin) and Dmax corrections, which is scanner-style per-channel levels. The selector is hidden in B&W Negative, where per-layer trims are meaningless, and in Transparency with Normalize off, where the sliders it scopes are hidden with the rest of the normalization tuning. - -**Crosstalk**, **Hue Trim** and the sensor unmix all live in **Calibration** (§4.1). They correct the capture rather than the negative-to-positive conversion. - -> **No Transparency matrix ships with NegPy.** On slides the Matrix dropdown starts empty, and it and Strength are disabled until a matrix exists. The editor button stays live, so you can build your own: press **+**, and it is created for the process you are in. A `.toml` marked `process = "Transparency"` dropped into your crosstalk folder works too (the pre-rename `process = "E-6"` still loads). It means something different there: on a negative the dyes' unwanted absorptions are an error to remove before inversion, so unmixing moves the render *toward* the scene, but a transparency **is** the finished image, and what you see on a lightbox already includes those absorptions, so unmixing moves it *away* from the slide's own look. In Transparency, treat it as a color-separation control, not a fidelity correction. **Hue Trim** is unaffected: it corrects the light source, so it applies to slides exactly as it does to negatives. - -**Normalize** (Transparency only) is the switch between two ways of rendering a slide. - -* **On**: auto-stretches the histogram to fill the dynamic range, metered per frame, and prints it through the paper model like a negative. This is a **rescue tool for faded or expired slides**, which is what it was added for. Where the dyes have lost their range, metering it back per frame is exactly right, and because the stretch is metered, two exposures of the same slide converge on a similar render. - - On a slide that was exposed as intended, expect it to look washed out and desaturated. That is not a miscalculation: a slide's density runs all the way to Dmax, but only its top ~1.5 decades carry picture, so a stretch measured across the whole range squeezes the picture into the top of the print curve, where the shoulder compresses tone and color together. Reach for it when the slide needs rescuing, not as a starting point. -* **Off** (default): renders the slide **as captured**. The camera's own color matrix is applied, and the tonal window is fixed to the decoder's white level rather than measured from the frame, so a slide opens looking the way it does in Photoshop, Preview, Affinity or Darktable, and a bracketed set stays a bracket, with each exposure rendering at its own brightness. Use this mode when you exposed the slide the way you wanted it and only need to adjust from there. - - With Normalize off, the paper simulation has nothing to act on, so the print-specific controls are hidden (paper profile, Paper White/Black, Auto Density, Auto Grade, split grade, Dye Separation) along with the normalization tuning above, which only shapes a *measured* stretch. What stays is a plain transfer curve, neutral at its defaults: **Print Density** (exposure), **ISO-R Grade** (contrast), **Toe** / **Shoulder** and their **Width** sliders (shadow and highlight roll-off), **Shadows Density** / **Highlights Density** (§6.2), the per-layer R/G/B trims, and white balance. Lab, Toning and Finish work as usual. - - **On a merged bracket, Normalize is grayed out**, whatever the switch said before the merge. The merge already decides where the tones land, and **Render exposure** picks which exposure it prints at; a metered stretch divides that choice straight back out, since below the anchor at which the frame stops clipping, moving it changes nothing. The two are not wanted together in any case, because Normalize rescues faded film and fading *compresses* the density range a bracket exists to capture. Unmerge the frame if you need the stretch. - - Coming from Lightroom's basic panel, the map is: **Exposure** → Print Density (inverted, lower is brighter), **Contrast** → ISO-R Grade (inverted, 180 is softest), **Shadows** → Shadows Density, **Highlights** → Highlights Density, with Toe and Shoulder shaping how each end rolls off. The Density sliders take the darkroom sign: *positive adds density*, so negative Shadows Density is what opens the shadows. **Whites** and **Blacks** have no equivalent here, because the tonal window is fixed by design, which is what makes the render faithful to the capture. - - A source with no camera matrix (a scanner TIFF, a JPEG) is already in the working space and passes straight through. - - **Linear RAW** is grayed out in **Calibration** here, because it does not apply to an as-captured render: it decodes without the as-shot white balance, which the camera matrix assumes is present, and the multipliers are folded back in, so the render is identical either way. It stays visible, since it is a sticky setting and a hidden one is a setting you cannot see the state of. With **Normalize** on it is live again, that render being a metered stretch rather than a transfer, and it is live with **Positive** on too — a positive source has no camera matrix, so a checked Linear RAW would otherwise silently force literal linear data over Positive's own read of the file's profile. An explicit Input ICC in Export replaces the camera's own primaries rotation rather than stacking on top of it, since the ICC supplies its own. The as-shot white balance still folds in as usual. - - **Narrowband** and **Single-Shot Narrowband Calibration** are grayed out for *any* transparency, Normalize or not; see [Narrowband and slides](#narrowband-and-slides). Reproducing a slide's appearance is a colorimetric problem, and narrowband illumination samples the spectrum at three isolated wavelengths, so the inter-band overlap the eye integrates is never measured, which is the same reason narrowband scans render oversaturated and hue-rotated. No input profile recovers what was never sampled, and the bundled one describes negative dyes besides. - -**Positive** (default off) sits next to Normalize, slide-only in the same way, and live only with Normalize off. As captured, NegPy reads the source as literal linear data and renders it like a raw capture, with a fixed exposure lift and a filmic highlight roll-off. Turn Positive on when the source is already a finished image, such as a scanned print or an export from other software: NegPy decodes its embedded profile instead (an untagged file falls back to sRGB) and skips the lift and the roll-off, so the Print sliders shape the image directly. With Normalize on it grays out, that render decoding on the source's own profile anyway. - - -### 4.4 Roll Analysis: a consistent look across the roll - -Meter the whole roll once and share the baseline, so frames from the same film match. - -* **Batch Analysis**: scans every loaded file and computes a roll-average density and color balance, discarding outliers. Run it once after importing. *(Tip: if you use Batch Autocrop, run it first, in **Image only** mode, so metering sees consistent crops.)* -* **Use Luma Average**: this frame takes the roll-wide tonal range; color still re-derives per frame. -* **Use Color Average**: this frame takes the roll-wide color balance; tonal range still re-derives per frame. Enable both for a fully consistent roll; leave both off for per-image auto-exposure. - -**ROLL**, to reuse a baseline across sessions: - -* **Roll dropdown** + **Load**: apply a saved roll's bounds and balance. -* **Save**: store the current Batch Analysis as a named roll, useful when you shoot the same stock repeatedly. -* **Delete**: remove the selected roll (it asks first). The frames keep their current look; only the saved baseline goes. - - -### 4.5 Presets - -Save and recall a complete edit, the full workspace, by name. - -* **Preset dropdown** + **Load**: apply a saved preset to the current image. -* **Name field** + **Save**: store the current settings as a new preset. -* **Trash**: delete the selected preset. - ---- - -## 5. Geometry tab +## 4. Geometry tab -### 5.1 Geometry: crop and straighten +### 4.1 Geometry: crop and straighten Where the frame gets its final shape: what is inside the print, and whether it sits level. Most scans need a pass here even when nothing else is touched. @@ -509,7 +381,7 @@ Where the frame gets its final shape: what is inside the print, and whether it s * **Distortion Correction** (-0.100 to 0.100, in steps of 0.001): radial lens distortion. Positive corrects barrel, negative pincushion. Use the film rebate as a straight-edge reference. Corrected before Tilt and Swing. -### 5.2 Flat Field: even out the light +### 4.2 Flat Field: even out the light Corrects uneven illumination (vignetting or falloff) from your copy-stand or scanner light, using a reference shot of the bare light source. @@ -518,12 +390,12 @@ Corrects uneven illumination (vignetting or falloff) from your copy-stand or sca --- -## 6. Exposure tab +## 5. Exposure tab This is the heart of the print. Three panels shape light, color and contrast, and everything here happens in the "print" stage of the pipeline. -### 6.1 Filtration: white balance +### 5.1 Filtration: white balance Color timing, like the dichroic filters on an enlarger head. A **Global / Shadows / Highlights** selector scopes the controls to the whole image, or biases them toward low- or high-density tones. @@ -534,11 +406,11 @@ Color timing, like the dichroic filters on an enlarger head. A **Global / Shadow * **Cyan / Magenta / Yellow** (-1 to 1): the three filtration axes, Cyan↔Red, Magenta↔Green and Yellow↔Blue. * **Cast Removal** (0.0 to 1.0, **color only**): balances each color layer against the frame's own grays, so neutrals stay neutral from deep shadows through highlights. The applied strength scales with how many clean near-neutrals the frame has. On Color Negative it defeats the orange mask and starts at about 0.5. On Transparency it starts at 0 and corrects a faded slide's crossover, since a slide's cast can be the photograph, so you ask for it. Hidden for B&W Negative. - It is hidden in Transparency and B&W Negative, because the render ignores it there. What it defeats is the **orange mask**, a cast the manufacturer built into the film rather than part of the picture. A slide has no mask and its cast *is* the photograph, so solving for a neutral axis would strip out the light you shot in; a B&W negative has one emulsion and no channels to balance. For a slide's color, use **Temperature** and the CMY sliders above, or **Hue Trim** (§4.1) if an unusual scanning light has rotated the hues. + It is hidden in Transparency and B&W Negative, because the render ignores it there. What it defeats is the **orange mask**, a cast the manufacturer built into the film rather than part of the picture. A slide has no mask and its cast *is* the photograph, so solving for a neutral axis would strip out the light you shot in; a B&W negative has one emulsion and no channels to balance. For a slide's color, use **Temperature** and the CMY sliders above, or **Hue Trim** (§10.1) if an unusual scanning light has rotated the hues. * **Ring-around** (target icon, or `Shift+F`): prints the frame as a 5×5 mosaic stepping 2cc at a time out to ±4cc on the magenta and yellow axes, so the direction of a color cast is visible instead of guessed. Each patch is a real render of the part of the frame it covers; click one to keep its filtration. The ladder is absolute and centered on neutral, so a ring printed off one frame compares to the next. `Escape` or a second press clears it, and any edit drops it. See **Rotating a proof** below. -### 6.2 Tone: density, contrast and the print curve +### 5.2 Tone: density, contrast and the print curve The paper's response. A **Global / R / G / B** selector at the top scopes most controls to the shared curve (Global), or to per-dye-layer trims for **crossover correction**, meaning casts that differ between shadows and highlights, which filtration alone cannot fix. @@ -584,7 +456,7 @@ The paper's response. A **Global / R / G / B** selector at the top scopes most c In R/G/B mode the sliders become per-layer trims on top of the global value, for that dye emulsion: **Grade** (±30 ISO-R), **Toe** / **Shoulder** (±1), **Toe Width** / **Shoulder Width** (±2), **Snap** (±0.5) and **Dye Separation** (±0.4). -### 6.3 Dodge & Burn: local exposure +### 5.3 Dodge & Burn: local exposure Draw masks and lighten or darken just those areas. Three shapes, one per darkroom move: @@ -614,10 +486,10 @@ Every mask is on the map, including ones whose outline you hid with the eye: tha --- -## 7. Color tab +## 6. Color tab -### 7.1 Lab: polish and detail +### 6.1 Lab: polish and detail Mimics what a lab scanner (Frontier or Noritsu) does automatically. Color controls hide in B&W Negative mode. @@ -644,7 +516,7 @@ Mimics what a lab scanner (Frontier or Noritsu) does automatically. Color contro * **Halation** (0.0 to 1.0): the red glow of light scattering back through the film base. Highlights only, strongly red-dominant. -### 7.2 Alternative Processes +### 6.2 Alternative Processes Two printing processes that are not ordinary silver-gelatin enlarging. Pick one with the **None / Lith / Cyanotype** buttons at the top, and only that process's controls are shown. Both are B&W Negative only, and both are off by default. @@ -677,7 +549,7 @@ While Cyanotype is selected, every chemical toner grays out in the Toning panel, --- -### 7.3 Toning +### 6.3 Toning Color the print itself rather than the scene: chemical toners that convert the silver (B&W Negative only), and a split tint that works in any mode. Lith silver is much finer than normal print silver, so the toners bite harder and differently on a lith print. With Lith on, only Selenium and Gold stay enabled; with Cyanotype on all six gray out, because there is no silver in the print at all. @@ -697,10 +569,10 @@ Color the print itself rather than the scene: chemical toners that convert the s --- -## 8. Finish tab +## 7. Finish tab -### 8.1 Retouch: dust, hairs, scratches +### 7.1 Retouch: dust, hairs, scratches Spotting, the way it was done with a brush on the finished print. There are three ways to find the marks (local contrast, the scanner's IR channel, or by hand) and they stack. However a mark is found, it is repaired the same way: the film under it is rebuilt from the clean film around it, with the frame's own grain transplanted back, and anything too wide for that goes to a fill that follows the structure through. @@ -736,7 +608,7 @@ The brush marks a *search area*, not a stamp: only the pixels that actually stan * **Undo Last** / **Clear All**: remove the most recent or all manual heals and traced lines; auto-detected dust is unaffected. Right-click a line to delete just that one. -### 8.2 Finishing: vignette, carrier, border +### 7.2 Finishing: vignette, carrier, border How the print is presented: edge burn, a filed-out carrier's black rebate, and the paper margin around it. Applied at the very end of the pipeline, after everything else is settled. @@ -764,7 +636,7 @@ The paper margin takes the mat color, so it runs into the border with no seam. --- -## 9. Favorites tab +## 8. Favorites tab The sliders you reach for most, gathered in one place, so a routine edit no longer costs a tab switch and a scroll. Empty until you fill it. @@ -780,7 +652,7 @@ tab switch and a scroll. Empty until you fill it. --- -## 10. History tab +## 9. History tab Two lists: the versions you chose to keep, above the running record of every change. @@ -805,7 +677,230 @@ A scrollable list of every edit step, the last 100 kept, newest on top. The curr --- -## 11. Export tab +## 10. Roll tab + +**Film mode** sits above the panels, because it is the first choice of every edit: **Color** (C-41 color negative), **B&W** (panchromatic negative) or **Slide** (transparency/reversal, E-6 and friends). Each swaps the core conversion math and re-runs the pipeline from scratch. The wand button beside them **auto-detects** the mode when a file loads. + + +### 10.1 Calibration: what your rig does to the colors + +Everything here corrects the *capture*, not the look. Three different things sit between the scene and your file: the camera's color filters, the film's dyes, and the light source. Each gets its own control. They are not interchangeable, and none substitutes for another. + +**Capture**, how the file is decoded before any of that: + +* **Scanning setup** (bulb button): a two-question wizard, *how do you scan?* then *what light source?*, that sets Linear RAW and Narrowband for you. It runs once after the first-launch tour, and the button reopens it whenever your rig changes. +* **Linear RAW** (default off): decodes with neutral multipliers for completely raw data. When off, it decodes RAW with the camera's as-shot white balance. Toggling it reloads the file. Let the **Scanning setup** wizard pick it, or try both and keep whichever gives better results. **Locked on for a Trichrome triplet**: a narrowband exposure has no full-spectrum scene for a white-balance gain to describe, so every triplet exposure decodes neutral regardless of this toggle. It stays visible and remembered, and unlocks again once the frame is no longer a triplet. +* **Narrowband**: corrects the oversaturation typical of narrowband (RGB-LED) capture, using a bundled input profile. Leave it off for ordinary broadband scans. An explicit Input ICC in Export overrides it. It is **grayed out on Transparency**; see *Narrowband and slides* below. + +What the wizard sets, by rig: + +| Capture | Light source | Linear RAW | Narrowband | +| --- | --- | --- | --- | +| Digital camera | White light (lightbox, CRI LED panel) | off | off | +| Digital camera | Narrowband RGB (Scanlight, RGB LED) | on | on | +| Film scanner | White light (Plustek, Epson, most flatbeds) | on | off | +| Narrowband Scanner | Nikon Coolscan, Kodak Pakon | on | on | + +Applying it sets the defaults for newly loaded files, updates the open frame, and rewrites every already-edited frame in the session, undoable per frame with Ctrl+Z. + +**Single-Shot Narrowband Calibration** is for single-shot camera scans under narrowband light. The camera's color filters overlap the light's bands, so a pure red exposure leaks a little into green and blue. That leak is a fixed property of your sensor and light together and has nothing to do with the film, so it is corrected on the linear capture before inversion. + +* **Profile**: the sensor matrix to apply. Custom `.toml` matrices live in `/NegPy/sensor/`. +* **Calibrate** (vials icon): build a profile from three bare-light R/G/B exposures. + +This block grays out unless **Linear RAW** is on, since profiles are calibrated against neutral white balance and the as-shot gains would misapply the matrix. It also grays out on **Transparency**, for the reason below. Your selection is remembered either way. It is also skipped for RGB-triplet assets, which never had the leak. It changes what the analysis reads, so **re-run Batch Analysis** after changing it. + +#### Narrowband and slides + +**Narrowband and Single-Shot Narrowband Calibration do not apply to Transparency**, with or without Normalize. Both stay visible and grayed so you can see what your rig is set to, both keep their values, and both come back the moment the frame is a negative again. + +Narrowband is a way to scan negatives. Its payoffs, defeating the orange mask and separating the dyes cleanly before a high-gain inversion, are things a slide does not need, and the bundled profile describes narrowband capture of *negative* dyes, which a slide does not have. Single-Shot Narrowband Calibration goes with it: the matrix un-mixes a narrowband light against your sensor's filters, so it means something only for a capture made under narrowband light, and there is no way to build one for a broadband capture. + +This matters because both are sticky and follow your rig from frame to frame, so a profile set up for your negatives would otherwise arrive on a slide you never touched. If you do scan slides on a narrowband rig, reach for **Hue Trim** instead: it corrects the hue rotation an unusual light imposes, which is the part that can be corrected. + +**Crosstalk** (hidden in B&W Negative) is a channel unmix applied to the raw densities before inversion. The dropdown lists only matrices for the film you are processing, because a Color Negative matrix does not describe a Transparency's dye set, and a mismatched stored profile resolves to no correction rather than the wrong one. + +The film's dyes each absorb outside their own band, but they are not the only cause: your light's spectrum and your sensor's color filters mix the channels too, and in the density domain all three arrive as the same kind of error. So treat the matrix as *your whole scanning setup*, not just the film. A profile that works beautifully on one rig may be wrong on another with the same stock. + +* **Matrix**: the profile to apply, grouped in the dropdown by where its numbers came from (measured, tuned on a rig, or from spec sheets). *Generic C41* is the built-in; drop custom `.toml` matrices in `/NegPy/crosstalk/` (see [CROSSTALK.md](CROSSTALK.md)). The slider button opens a matrix editor, where a **Type** control records that provenance and a **Process** control says which film the numbers describe. Process decides where the profile appears and whether it applies, so a matrix you build for slides needs it set to E-6. Anything created with **+** is already set to the process you are working in. + When the current film process has no matrices at all, the dropdown and **Strength** are disabled and a hint says so. The editor button stays live, because it is the way to build the first one. +* **Strength** (0.0 to 1.0): how much of the unmix to apply, for richer and cleaner color separation. It changes what the analysis reads, so **re-run Batch Analysis** after changing it. + +> **The bundled film matrices are derived from published spec sheets, not measured**, which is why they are all marked *(approx)*. They describe the film's **dyes alone**, so they are the whole story only where your capture reads each dye cleanly: a **Narrowband Scanner** (a Coolscan's mono sensor reads one LED at a time, fully clean; a Pakon's trilinear array comes close, with slight residual bleed), a **Trichrome** capture, or a Single-Shot Narrowband rig with **Single-Shot Narrowband Calibration** applied (see above). With a broadband light and a Bayer sensor, the capture adds mixing of its own that a dyes-only matrix does not describe. It may still help, but treat the number as a starting point rather than a correction for your setup. + +**Worth experimenting with.** If a stock or a light gives you trouble, open the matrix editor, nudge the six off-diagonal terms and save the result as your own profile. Name it after the *combination*, "Gold 200 + Spectracolor", not just the film. A profile you tuned on your own rig beats any datasheet, and profiles that work are worth [contributing back](CROSSTALK.md#contributing-a-matrix), since nobody can guess your light and sensor for you. + +**Light source:** + +* **Hue Trim** (-30° to 30°, default 0): rotates every hue by a fixed angle, to undo the rotation an unusual scanning light imposes. Narrowband LED and odd-phosphor panels sample the dyes away from where the film expects, which turns *every* color by roughly the same angle, so yellows read orange and greens go olive, while neutrals are left alone. That is why white balance cannot fix it: the error is a rotation, not a cast, so there is no gray to correct. Judge it on a subject whose color you know (foliage, a clear blue sky, skin), and leave it at 0 for an ordinary broadband light. The setting is **sticky**, because a light source is a property of your rig, so it carries to the next file until you change it. Neutrals are untouched, so it never disturbs the color-balance clip in **Normalization**. + + +### 10.2 Demosaic: turning the sensor mosaic into pixels + +A color sensor records one color per photosite behind a mosaic filter, and an algorithm fills in the other two. Which one you pick decides how sharp the result looks and how it treats film grain. Preview and export are chosen separately, and both are sticky. + +Bayer and X-Trans RAW only: a scanner TIFF, a Pakon scan or a linear DNG arrives already de-mosaiced. Only the algorithms your LibRaw build compiled in are listed. + +* **Preview** / **Export** (default **Auto** for both): *Auto* keeps NegPy's own choice, a fast half-size decode on screen and AHD for export. For the preview, Auto and Linear are the fastest; the others decode at full size. **AHD** is LibRaw's balanced default, **VNG** the smooth one, **PPG** fast with clean edges, **DCB** and **DHT** chase fine detail, and **AAHD** softens edges to suppress artifacts. + + +### 10.3 Normalization: negative → positive + +How the negative is measured and normalized into a positive. The film mode that decides *which* conversion runs sits above the panels (§10), and how the scan is decoded lives in **Calibration** (§10.1). + +* **Multi-core CPU rendering** (**Preferences → Performance**, beside **GPU acceleration**): spreads the CPU rendering kernels across your cores. It takes effect immediately, with no recompile and no restart. + + Be realistic about the gain. The kernels run much faster, but a merge is dominated by decoding the RAW files, which this does not touch, so the whole operation comes down by only about a tenth. Ordinary editing changes less again, because the GPU already carries the pipeline. The gain is largest wherever the CPU does the work: merges, exports, and any machine without a usable GPU. + + On Windows and Linux this is **on**. On macOS it is **off**, pending more evidence: the underlying threading layer terminates the process outright if two threads enter it at once, and while NegPy serialises every such call behind a lock, that has been proven on one Mac rather than on the range of them. If you turn it on and the app ever closes without warning, NegPy notices on the next launch and offers to turn it back off; that is the failure to expect, and it is recoverable. Setting `cpu_parallel` under `[performance]` in `override.toml` still wins over Preferences, for a machine that cannot start. + +**Analysis window**, where NegPy measures the black and white points. The slider takes half the row, the three buttons the other half: + +* **Analysis Buffer** (0.0 to 0.25): insets the measurement window from the frame edge so film rebate, sprocket holes and scanner borders do not skew detection. Raise it on scans with wide borders. +* **Analysis Region** (square-draw tool): draw a freehand region on the canvas to meter *exactly* that area, overriding the buffer. Double-click inside to confirm; the ✕ button clears it. +* **Lock Bounds** (padlock): freezes the analyzed normalization bounds for this frame, so cropping or moving sliders no longer re-analyzes it. Lock it in once you are happy with the bounds. + +**Normalization tuning:** + +* **Luma Range Clip** (-100 to 100): how aggressively the tonal range, the black/white-point span, is set. Neutral already applies a small robust clip. Positive tightens it, which is good for dense or fogged negatives where a few stray pixels would push the bounds to extremes. Negative pushes the bounds *outward*, for lifted blacks and unclipped highlights. +* **Color Clip** (-100 to 100): the per-channel color-balance clip (orange-mask removal), independent of the tonal range. Positive tightens channel balance; negative samples nearer the extremes. +* **Global / R / G / B** selector → **White Point** / **Black Point** (-0.25 to 0.25): manual offsets on top of the auto-detected bounds. A positive white point brightens; a positive black point lifts blacks. In R/G/B mode these become per-layer trims: per-dye-layer film-base (Dmin) and Dmax corrections, which is scanner-style per-channel levels. The selector is hidden in B&W Negative, where per-layer trims are meaningless, and in Transparency with Normalize off, where the sliders it scopes are hidden with the rest of the normalization tuning. + +**Crosstalk**, **Hue Trim** and the sensor unmix all live in **Calibration** (§10.1). They correct the capture rather than the negative-to-positive conversion. + +> **No Transparency matrix ships with NegPy.** On slides the Matrix dropdown starts empty, and it and Strength are disabled until a matrix exists. The editor button stays live, so you can build your own: press **+**, and it is created for the process you are in. A `.toml` marked `process = "Transparency"` dropped into your crosstalk folder works too (the pre-rename `process = "E-6"` still loads). It means something different there: on a negative the dyes' unwanted absorptions are an error to remove before inversion, so unmixing moves the render *toward* the scene, but a transparency **is** the finished image, and what you see on a lightbox already includes those absorptions, so unmixing moves it *away* from the slide's own look. In Transparency, treat it as a color-separation control, not a fidelity correction. **Hue Trim** is unaffected: it corrects the light source, so it applies to slides exactly as it does to negatives. + +**Normalize** (Transparency only) is the switch between two ways of rendering a slide. + +* **On**: auto-stretches the histogram to fill the dynamic range, metered per frame, and prints it through the paper model like a negative. This is a **rescue tool for faded or expired slides**, which is what it was added for. Where the dyes have lost their range, metering it back per frame is exactly right, and because the stretch is metered, two exposures of the same slide converge on a similar render. + + On a slide that was exposed as intended, expect it to look washed out and desaturated. That is not a miscalculation: a slide's density runs all the way to Dmax, but only its top ~1.5 decades carry picture, so a stretch measured across the whole range squeezes the picture into the top of the print curve, where the shoulder compresses tone and color together. Reach for it when the slide needs rescuing, not as a starting point. +* **Off** (default): renders the slide **as captured**. The camera's own color matrix is applied, and the tonal window is fixed to the decoder's white level rather than measured from the frame, so a slide opens looking the way it does in Photoshop, Preview, Affinity or Darktable, and a bracketed set stays a bracket, with each exposure rendering at its own brightness. Use this mode when you exposed the slide the way you wanted it and only need to adjust from there. + + With Normalize off, the paper simulation has nothing to act on, so the print-specific controls are hidden (paper profile, Paper White/Black, Auto Density, Auto Grade, split grade, Dye Separation) along with the normalization tuning above, which only shapes a *measured* stretch. What stays is a plain transfer curve, neutral at its defaults: **Print Density** (exposure), **ISO-R Grade** (contrast), **Toe** / **Shoulder** and their **Width** sliders (shadow and highlight roll-off), **Shadows Density** / **Highlights Density** (§5.2), the per-layer R/G/B trims, and white balance. Lab, Toning and Finish work as usual. + + **On a merged bracket, Normalize is grayed out**, whatever the switch said before the merge. The merge already decides where the tones land, and **Render exposure** picks which exposure it prints at; a metered stretch divides that choice straight back out, since below the anchor at which the frame stops clipping, moving it changes nothing. The two are not wanted together in any case, because Normalize rescues faded film and fading *compresses* the density range a bracket exists to capture. Unmerge the frame if you need the stretch. + + Coming from Lightroom's basic panel, the map is: **Exposure** → Print Density (inverted, lower is brighter), **Contrast** → ISO-R Grade (inverted, 180 is softest), **Shadows** → Shadows Density, **Highlights** → Highlights Density, with Toe and Shoulder shaping how each end rolls off. The Density sliders take the darkroom sign: *positive adds density*, so negative Shadows Density is what opens the shadows. **Whites** and **Blacks** have no equivalent here, because the tonal window is fixed by design, which is what makes the render faithful to the capture. + + A source with no camera matrix (a scanner TIFF, a JPEG) is already in the working space and passes straight through. + + **Linear RAW** is grayed out in **Calibration** here, because it does not apply to an as-captured render: it decodes without the as-shot white balance, which the camera matrix assumes is present, and the multipliers are folded back in, so the render is identical either way. It stays visible, since it is a sticky setting and a hidden one is a setting you cannot see the state of. With **Normalize** on it is live again, that render being a metered stretch rather than a transfer, and it is live with **Positive** on too — a positive source has no camera matrix, so a checked Linear RAW would otherwise silently force literal linear data over Positive's own read of the file's profile. An explicit Input ICC in Export replaces the camera's own primaries rotation rather than stacking on top of it, since the ICC supplies its own. The as-shot white balance still folds in as usual. + + **Narrowband** and **Single-Shot Narrowband Calibration** are grayed out for *any* transparency, Normalize or not; see [Narrowband and slides](#narrowband-and-slides). Reproducing a slide's appearance is a colorimetric problem, and narrowband illumination samples the spectrum at three isolated wavelengths, so the inter-band overlap the eye integrates is never measured, which is the same reason narrowband scans render oversaturated and hue-rotated. No input profile recovers what was never sampled, and the bundled one describes negative dyes besides. + +**Positive** (default off) sits next to Normalize, slide-only in the same way, and live only with Normalize off. As captured, NegPy reads the source as literal linear data and renders it like a raw capture, with a fixed exposure lift and a filmic highlight roll-off. Turn Positive on when the source is already a finished image, such as a scanned print or an export from other software: NegPy decodes its embedded profile instead (an untagged file falls back to sRGB) and skips the lift and the roll-off, so the Print sliders shape the image directly. With Normalize on it grays out, that render decoding on the source's own profile anyway. + + +### 10.4 Roll Analysis: a consistent look across the roll + +Meter the whole roll once and share the baseline, so frames from the same film match. + +* **Batch Analysis**: scans every loaded file and computes a roll-average density and color balance, discarding outliers. Run it once after importing. *(Tip: if you use Batch Autocrop, run it first, in **Image only** mode, so metering sees consistent crops.)* +* **Use Luma Average**: this frame takes the roll-wide tonal range; color still re-derives per frame. +* **Use Color Average**: this frame takes the roll-wide color balance; tonal range still re-derives per frame. Enable both for a fully consistent roll; leave both off for per-image auto-exposure. + +**ROLL**, to reuse a baseline across sessions: + +* **Roll dropdown** + **Load**: apply a saved roll's bounds and balance. +* **Save**: store the current Batch Analysis as a named roll, useful when you shoot the same stock repeatedly. +* **Delete**: remove the selected roll (it asks first). The frames keep their current look; only the saved baseline goes. + + +### 10.5 Presets + +Save and recall a complete edit, the full workspace, by name. + +* **Preset dropdown** + **Load**: apply a saved preset to the current image. +* **Name field** + **Save**: store the current settings as a new preset. +* **Trash**: delete the selected preset. + +--- + +## 11. Metadata tab + +Archival metadata for the **original analog capture** (camera, lens, film, process), written into exported files as EXIF and embedded XMP, so DAMs like Lightroom show your film gear rather than the scanner. + +Every export format carries it: JPEG, TIFF, PNG, JPEG XL and WebP. A TIFF holds the capture position in XMP only, and EXIF text is 7-bit, so typographic punctuation is transliterated (`4×5` is written `4x5`). **Protect original metadata**, on the Export tab (§13), copies the source file's own EXIF/XMP instead of writing these fields. + + +### Metadata Presets + +A saved set of metadata values, stored in `~/NegPy/presets/metadata/`, separate from the edit presets on the Roll tab: + +* **Preset** + **Load**: write the selected preset's fields onto this frame. Only the fields the preset stores change; everything else on the frame stays. Hover the field for a list of what a preset holds. + +Presets, cameras, lenses, film stocks and every other gear record are managed on the **Gear** tab (§12), not here. + +Gear travels as one unit: camera, lens, film stock, the film format and every other value read from them. So a loaded preset fills the dropdowns below and the exported EXIF with the same pick, and a preset for a 120 stock cannot leave the frame claiming 35mm. Picking a film stock sets the format either way, so set a frame format such as `6×7` after choosing the stock. The frame number is never stored in a preset. + + +### Analog Gear + +Searches the gear you've declared as your own (§12); pick **Other…** for the full built-in catalog. + +* **Camera / Lens / Film stock**: pick from your library. Empty means not set. +* **Clear**: empties all three. +* **Infer from folder name**: fills whichever of camera, film stock, ISO and capture date is not already set. Camera and film stock match against the gear catalog (your own gear and the built-in reference list) — the same match Roll Settings offers on import; ISO and capture date read any plausible number or date in the name directly. Two plausible readings of the same field leave it alone rather than guess. Needs a folder: the active folder roll's own folder, or the current frame's containing directory with no roll active. + + +### Capture + +* **Date**: when the frame was shot. Give only what you know: `1998`, `1998-07`, `1998-07-14` or `1998-07-14 16:30`, with an optional offset such as `+02:00`. An impossible date turns the field red and is not saved. EXIF `DateTimeOriginal` pads the missing parts; XMP `photoshop:DateCreated` keeps the truncated form and `negpy:CaptureDatePrecision` names it. The scan file's own timestamp moves to `DateTimeDigitized`. +* **Place**: the capture location. The map-pin button opens a map to search a place name, click a position or paste coordinates, the ✕ beside it empties the place, and the field itself accepts a pasted coordinate pair or an OpenStreetMap/Google Maps link. Coordinates are written to the EXIF GPS tags and XMP `exif:GPS*`, the names to XMP `photoshop:City`/`State`/`Country`; a TIFF carries the location in XMP only, and a place you set replaces the source file's GPS block whole, rather than leaving its altitude or heading beside your coordinates. A geotagged source with no place set here keeps its own coordinates on export, and the map opens centered on them. Where the frame was digitized is a starting view, never the capture place. Zoom the map with its **+** / **−** buttons, a scroll wheel or a trackpad pinch, and drag to pan. Opening the map contacts OpenStreetMap; typing coordinates needs no network. + + +### Process + +* **Saved process**: pick a development recipe from the library to fill Developer, Dilution, Push / Pull, Time and Temperature. Typing over any of them unlinks it, so the picker never names a value that is gone. +* **Format**: `—` (not set), `35mm`, `120`, `4×5`, `8×10`, `110`, or `Other` with a free-text field. +* **Developer** and **Dilution**: the developer, for example `D-76`, and its working strength, for example `1+1`, `1+50` or `stock`. The two join in EXIF `ImageDescription` as `D-76 1+1`; the dilution also goes to XMP as `negpy:DevelopmentDilution`. +* **Push / Pull**: `Push +3` … `Normal` … `Pull -3`. +* **Time** and **Temp (°C)**: development time as `9:30` or plain minutes, and the temperature it ran at. An unreadable time turns the field red and is not saved. Both are written to XMP as `negpy:DevelopmentTime` and `negpy:DevelopmentTemperature`, and searchable as `devtime:` (minutes) and `temp:`. +* **Clear**: empties the saved process and everything it fills: developer, dilution, push/pull, time and temperature. Format stays, since the film stock sets it. + + +### Scanning + +* **Saved setup**: pick a digitizing setup from the library to fill Scanning. Typing over it unlinks it. +* **Scanning**: scan method or notes. EXIF `Software` is always `NegPy`. +* **Clear**: empties the saved setup and the scanning note. Roll and Frame stay, since the scan stamps them rather than the setup. +* **Roll / Frame**: Scanlight capture roll name and frame number, stamped automatically on capture and editable here. Available in export filename templates as `{{ roll }}` and `{{ frame }}`, and written to XMP as `negpy:CaptureRoll` and `negpy:CaptureFrame` when set. Not the Roll Analysis normalization name. + + +### Exposure + +Optional original shutter, aperture and ISO. Click the lock to edit a free-text string, for example `1/125s f/2.8 ISO 400`. + + +### Metadata Preview + +A live view of exactly what will be embedded, grouped by capture, scan, process and file. The Scan group shows the source file's own timestamp and coordinates, so you can see what you are replacing. **Description…** opens a checklist of which fields join into EXIF `ImageDescription`. The defaults are camera, lens, film stock and ISO; format, developer, push/pull and scanning are off until you enable them. Confirming **Description…** sets that frame's selection and becomes the sticky default for other frames that do not have their own, so the last confirm on the roll wins. Sync metadata and Sync settings can also copy a frame's selection with the rest of the metadata. + +When you set capture gear, it is written to standard EXIF, and the digitizing rig is preserved separately in `negpy:Scan*` XMP tags. Leave gear unset and your scanner or DSLR stays visible in EXIF instead. + +--- + +## 12. Gear tab + +A searchable, user-extendable library shared by Metadata (§11), Roll Settings and every other picker in the app that offers gear, split into two subtabs: **Items** for physical gear, **Presets** for saved metadata field sets. Presets have no bundled/personal split to browse, so the Catalog toggle lives on Items only. + +**Items** holds cameras, lenses, film stocks, development processes and scan setups. **Category** picks what the list below shows: **Cameras**, **Lenses**, **Film Stocks**, **Process** or **Scanning**. A Process entry is a development recipe (developer, dilution, push/pull, time and temperature); a Scanning entry is a digitizing setup. The list defaults to gear you have added yourself; a built-in reference catalog of common cameras, lenses, film stocks, processes and scan setups is available alongside it, without cluttering the list. Your own gear saves to `~/NegPy/gear/`. An empty category reads "You haven't added any…yet" rather than showing a blank list. + +* **+**: pick the one you own from the built-in catalog — it copies into your own list, editable from there — or **Add Custom** to enter one by hand. +* **Catalog**: show the built-in reference models alongside your own, for browsing the full list. Off by default. +* **copy / trash**: duplicate or delete the selected item. Trash is disabled on a built-in catalog entry, since it is reference data, not yours to remove; duplicate still copies it into your own list. + +**Presets** holds saved metadata field sets. + +* **+** stores the current frame's metadata under a name you pick. +* **pen**: rename a preset or change which fields it stores. +* **copy / trash**: duplicate or delete the selected preset. +* The fields of a preset are then editable in place: swap its camera, lens, film stock, saved process or saved setup, or retype a developer, dilution, push, time, temperature, scanning note, roll or exposure. No frame needs to be open. Each of these searches your own gear by default, with **Other…** for the full catalog, same as the Metadata tab (§11). Picking from the library refills everything read from it; typing over a filled value unlinks the pick. A stored capture date, place, description-field set or flag is shown but not editable here, being a per-frame decision. **Notes** is free text. + +--- + +## 13. Export tab ### Output intent @@ -816,7 +911,7 @@ A scrollable list of every edit step, the last 100 kept, newest on top. The curr * **Linear**: bypass the entire darkroom pipeline and dump the scanner's or camera's decoded buffer as a linear 16-bit file. The output format is selectable: **TIFF** (default, zlib-compressed, genuinely untagged) or **JPEG XL** (lossless). JPEG XL has no untagged state, so it comes out asserting sRGB primaries and a linear transfer regardless, which is not true for camera or scanner-native primaries; use TIFF if an unasserted file matters. An **Effort** slider (1–9, default 7) controls JPEG XL encoder speed against compression. No normalization, exposure, color management, flatfield or sensor correction, just the raw data with lossless geometry (rotation and flip) applied. Supported sources: * **Pakon RAW**: 4× expansion by default (14-bit sensor range scaled into 16-bit). F335 files (16-bit sensor) default to no expansion. * **LinearRaw DNG**: SilverFast HDRi (3-channel) and VueScan (4-channel RGB+IR). IR is written as a separate grayscale file with an `_ir` suffix, in the same Format you chose for the main output. - * **Camera RAW**: demosaiced with unity white balance (1,1,1,1), using the algorithm chosen for Export in the Demosaic panel (§4.2); the resolved algorithm is recorded in the description. The camera's as-shot WB is written into XMP (`RAW-WB: R G B`) so downstream tools can apply it. Source device and timestamp are preserved. Trichrome triplets (narrowband R/G/B exposures) are merged into a single combined TIFF. Stitch composites are assembled with flatfield and sensor correction applied per-part for clean seams; stitch and triplet combinations are also supported. + * **Camera RAW**: demosaiced with unity white balance (1,1,1,1), using the algorithm chosen for Export in the Demosaic panel (§10.2); the resolved algorithm is recorded in the description. The camera's as-shot WB is written into XMP (`RAW-WB: R G B`) so downstream tools can apply it. Source device and timestamp are preserved. Trichrome triplets (narrowband R/G/B exposures) are merged into a single combined TIFF. Stitch composites are assembled with flatfield and sensor correction applied per-part for clean seams; stitch and triplet combinations are also supported. * **Coolscan NEF**: Nikon Coolscan scanner files. Despite the name, these are not raw sensor data: the content depends on the Nikon Scan settings used at scan time, so linear, unprocessed output needs the right settings before scanning. The full-res RGB SubIFD is read directly, and any extra channels beyond RGB are dropped, since Coolscan has no separate IR channel. No expansion. * **Flextight FFF**: Imacon/Hasselblad Flextight scanner files, including both standard uncompressed 16-bit RGB exports and SGI LogLuv compressed raw files (`.3fr`/`.fff`). LogLuv files are decoded through a LogLuv → XYZ → linear sRGB pipeline with per-channel percentile normalization; LogLuv is HDR, so normalization is part of the decode, and without it the data would be truncated, not raw. The largest image IFD is selected by pixel count. Data is linear scanner transmittance. Embedded FlexColor metadata (film stock, film type, scan date, scanner serial) from the proprietary plist (tag 50457) and the firmware blob (tag 46279) is carried through to the output TIFF headers. No expansion. * **Noritsu RAW**: headerless BGR 16-bit scanner dumps. Frame dimensions are auto-detected from file size against known Noritsu scan dimensions. 16× expansion by default (12-bit sensor data in 16-bit range). @@ -835,6 +930,9 @@ A scrollable list of every edit step, the last 100 kept, newest on top. The curr The primary **Export** action. Its chevron menu picks the scope: current frame (Ctrl+E), selected frames, or all visible frames. Every scope uses the settings below. To deliver the same frames in more than one format or size in a single run, use Export Presets. +* **Protect original metadata**: copy the source file's EXIF/XMP to exports unchanged, adding nothing. When it is on, the Metadata tab's fields ([§11](#11-metadata-tab)) are ignored and the source's resolution is copied exactly: the same numbers, axes and unit, whether the source states it in EXIF or in its own header, even where the export was resized. A source that declares no resolution stays that way in every format that can leave it out. TIFF cannot, so it states the export's own resolution rather than the unit-less value readers report as 1 DPI. +* **Sync custom metadata to all files in batch export**: batch and preset exports write this frame's capture, gear and process values to every file, instead of each file's own. Disabled with Protect on, since it ignores those fields. + ### Format / Size / Color Management / Destination * **Format**: `JPEG`, `TIFF`, `PNG`, `JPEG XL`, or `WebP`, with quality or effort options per format. **JPEG XL supports only `sRGB`, `P3 D65`, `Rec 2020` or `Grayscale`** for Export profile: it tags color with compact enumerated values rather than an embedded ICC profile, and NegPy's JXL encoder cannot carry an arbitrary one, so `Adobe RGB`, `ProPhoto RGB` and a custom Output ICC are rejected with an error. Pick a supported space or a different format. @@ -895,71 +993,7 @@ A soft proof shows what the picture becomes when a given printer puts it on a gi --- -## 12. Metadata tab - -Archival metadata for the **original analog capture** (camera, lens, film, process), written into exported files as EXIF and embedded XMP, so DAMs like Lightroom show your film gear rather than the scanner. - -Every export format carries it: JPEG, TIFF, PNG, JPEG XL and WebP. A TIFF holds the capture position in XMP only, and EXIF text is 7-bit, so typographic punctuation is transliterated (`4×5` is written `4x5`). - -* **Protect original metadata**: copy the source file's EXIF/XMP to exports unchanged, adding nothing. When it is on, the fields below are ignored and the source's resolution is copied exactly: the same numbers, axes and unit, whether the source states it in EXIF or in its own header, even where the export was resized. A source that declares no resolution stays that way in every format that can leave it out. TIFF cannot, so it states the export's own resolution rather than the unit-less value readers report as 1 DPI. -* **Sync custom metadata to all files in batch export**: batch and preset exports write this frame's capture, gear and process values to every file, instead of each file's own. - - -### Metadata Presets - -A saved set of metadata values, stored in `~/NegPy/presets/metadata/`, separate from the edit presets on the Setup tab: - -* **Preset** + **Load**: write the selected preset's fields onto this frame. Only the fields the preset stores change; everything else on the frame stays. Hover the field for a list of what a preset holds. -* **Manage…**: the library, with a page each for **Cameras**, **Lenses**, **Film Stocks**, **Process**, **Scanning** and **Presets**. A Process entry is a development recipe (developer, dilution, push/pull, time and temperature); a Scanning entry is a digitizing setup. On the Presets page, **+** stores the current frame's metadata under a name you pick, the **pen** renames a preset or changes which fields it stores, and **copy** and **trash** duplicate and delete. The fields a preset stores are then editable in place: swap its camera, lens, film stock, saved process or saved setup, or retype a developer, dilution, push, time, temperature, scanning note, roll or exposure. No frame needs to be open. Picking from the library refills everything read from it; typing over a filled value unlinks the pick. A stored capture date, place, description-field set or flag is shown but not editable here, being a per-frame decision. **Notes** is free text. Starter data seeds into `~/NegPy/gear/` on first launch. - -Gear travels as one unit: camera, lens, film stock, the film format and every other value read from them. So a loaded preset fills the dropdowns below and the exported EXIF with the same pick, and a preset for a 120 stock cannot leave the frame claiming 35mm. Picking a film stock sets the format either way, so set a frame format such as `6×7` after choosing the stock. The frame number is never stored in a preset. - - -### Analog Gear - -Searchable; type in any field to filter the library. - -* **Camera / Lens / Film stock**: pick from your library. Empty means not set. **Clear** empties all three. - - -### Capture - -* **Date**: when the frame was shot. Give only what you know: `1998`, `1998-07`, `1998-07-14` or `1998-07-14 16:30`, with an optional offset such as `+02:00`. An impossible date turns the field red and is not saved. EXIF `DateTimeOriginal` pads the missing parts; XMP `photoshop:DateCreated` keeps the truncated form and `negpy:CaptureDatePrecision` names it. The scan file's own timestamp moves to `DateTimeDigitized`. -* **Place**: the capture location. The map-pin button opens a map to search a place name, click a position or paste coordinates, the ✕ beside it empties the place, and the field itself accepts a pasted coordinate pair or an OpenStreetMap/Google Maps link. Coordinates are written to the EXIF GPS tags and XMP `exif:GPS*`, the names to XMP `photoshop:City`/`State`/`Country`; a TIFF carries the location in XMP only, and a place you set replaces the source file's GPS block whole, rather than leaving its altitude or heading beside your coordinates. A geotagged source with no place set here keeps its own coordinates on export, and the map opens centered on them. Where the frame was digitized is a starting view, never the capture place. Zoom the map with its **+** / **−** buttons, a scroll wheel or a trackpad pinch, and drag to pan. Opening the map contacts OpenStreetMap; typing coordinates needs no network. - - -### Process - -* **Saved process**: pick a development recipe from the library to fill Developer, Dilution, Push / Pull, Time and Temperature. Typing over any of them unlinks it, so the picker never names a value that is gone. -* **Format**: `—` (not set), `35mm`, `120`, `4×5`, `8×10`, `110`, or `Other` with a free-text field. -* **Developer** and **Dilution**: the developer, for example `D-76`, and its working strength, for example `1+1`, `1+50` or `stock`. The two join in EXIF `ImageDescription` as `D-76 1+1`; the dilution also goes to XMP as `negpy:DevelopmentDilution`. -* **Push / Pull**: `Push +3` … `Normal` … `Pull -3`. -* **Time** and **Temp (°C)**: development time as `9:30` or plain minutes, and the temperature it ran at. An unreadable time turns the field red and is not saved. Both are written to XMP as `negpy:DevelopmentTime` and `negpy:DevelopmentTemperature`, and searchable as `devtime:` (minutes) and `temp:`. -* **Clear**: empties the saved process and everything it fills: developer, dilution, push/pull, time and temperature. Format stays, since the film stock sets it. - - -### Scanning - -* **Saved setup**: pick a digitizing setup from the library to fill Scanning. Typing over it unlinks it. -* **Scanning**: scan method or notes. EXIF `Software` is always `NegPy`. -* **Clear**: empties the saved setup and the scanning note. Roll and Frame stay, since the scan stamps them rather than the setup. -* **Roll / Frame**: Scanlight capture roll name and frame number, stamped automatically on capture and editable here. Available in export filename templates as `{{ roll }}` and `{{ frame }}`, and written to XMP as `negpy:CaptureRoll` and `negpy:CaptureFrame` when set. Not the Roll Analysis normalization name. - - -### Exposure - -Optional original shutter, aperture and ISO. Click the lock to edit a free-text string, for example `1/125s f/2.8 ISO 400`. - - -### Metadata Preview - -A live view of exactly what will be embedded, grouped by capture, scan, process and file. The Scan group shows the source file's own timestamp and coordinates, so you can see what you are replacing. **Description…** opens a checklist of which fields join into EXIF `ImageDescription`. The defaults are camera, lens, film stock and ISO; format, developer, push/pull and scanning are off until you enable them. Confirming **Description…** sets that frame's selection and becomes the sticky default for other frames that do not have their own, so the last confirm on the roll wins. Sync metadata and Sync settings can also copy a frame's selection with the rest of the metadata. - -When you set capture gear, it is written to standard EXIF, and the digitizing rig is preserved separately in `negpy:Scan*` XMP tags. Leave gear unset and your scanner or DSLR stays visible in EXIF instead. - ---- - -## 13. Scan tab +## 14. Scan tab Capture film directly into NegPy. Two collapsible sections. @@ -1016,7 +1050,7 @@ Every preview dialog ends the same way: **Cancel**, then **Apply** (keep the fra --- -## 14. Preferences +## 15. Preferences Settings for the whole application, not for one photo. Open them from the canvas **⋯** menu → **Preferences…**, with `Ctrl + ,`, or on macOS from the application menu. Changes apply as you make them; the rows NegPy reads at startup say so and light a restart notice. @@ -1033,7 +1067,7 @@ Settings for the whole application, not for one photo. Open them from the canvas ### Performance * **GPU acceleration**: render the pipeline on the GPU. The active backend is named below the row. Off falls back to the CPU pipeline, which is slower but produces the same image. If the GPU viewport itself fails to start, a warning toast says so at launch and an amber line here repeats it; the display then runs on the CPU. -* **Multi-core CPU rendering**: see §4.3. It takes effect at once, with no restart. +* **Multi-core CPU rendering**: see §10.3. It takes effect at once, with no restart. * **Preview size** (512 to 8192 px): long edge of the interactive canvas. Higher is sharper at 100% zoom, and costs proportionally more VRAM and CPU per frame, so lower the cache limit and the rendered-frame count to match. RAW files decode at half sensor size for the preview, so there is nothing to gain past half the long edge of your scan. * **Preview cache** and **Preview cache limit**: how many recently-viewed photos stay decoded in memory, and the memory ceiling for them. Lower both on a machine with little RAM. * **HQ buffers**: full-resolution HQ preview buffers kept in memory. Each is large (a 60 MP scan is about 700 MB), and keeping the previous frame makes going back instant. @@ -1071,7 +1105,7 @@ If NegPy crashes on launch or has rendering glitches, force the backend without --- -## 15. Updating NegPy +## 16. Updating NegPy NegPy asks GitHub for the newest release once at startup. If there is one, a green **⬇ Update Available: vX.Y.Z** line appears under the logo in the left panel. Click it to open the update window with the release notes, the download size and one button. diff --git a/negpy/desktop/controller.py b/negpy/desktop/controller.py index 22dc604e3..31c33a8d5 100644 --- a/negpy/desktop/controller.py +++ b/negpy/desktop/controller.py @@ -77,6 +77,7 @@ resolve_preset_export, ) from negpy.services.assets.composites import forget_composite, restore_maps +from negpy.services.assets import rolls from negpy.services.assets.half_frame import ( HalfGeometry, base_hash, @@ -941,8 +942,20 @@ def restore_session(self) -> None: active = self.session.repo.get_global_setting("session_active_path") self._pending_scanned_file = active if active in paths else paths[0] triplets = self.session.repo.get_global_setting("session_triplets", {}) or {} + self.state.active_roll_id = self._roll_id_for_restored_paths(paths) self.request_asset_discovery(paths, auto_open=True, restore_triplets=triplets) + def _roll_id_for_restored_paths(self, paths: List[str]) -> Optional[str]: + """The one roll every restored path agrees on, or None -- the roll a fresh + process would otherwise forget it had open, the same "only when unambiguous" + rule open_library_folders applies when several folders are opened at once.""" + candidates = set(rolls.rolls_containing_path(self.session.repo, paths[0])) + for path in paths[1:]: + candidates &= set(rolls.rolls_containing_path(self.session.repo, path)) + if not candidates: + return None + return next(iter(candidates)) if len(candidates) == 1 else None + def request_asset_discovery( self, paths: List[str], @@ -1032,22 +1045,48 @@ def _start_next_asset_discovery(self) -> None: if self._pending_asset_discoveries and not self._discovery_running and self._active_batch is None: self._start_asset_discovery(self._pending_asset_discoveries.pop(0)) - # --- Library (folders on disk) -------------------------------------------- + # --- Library (a library of Rolls) ------------------------------------------ def library_roots(self) -> List[str]: + """Top-level directories a library search walks. Maintained automatically by + importing a roll (or a parent full of them) — not a user-visible list.""" saved = self.session.repo.get_global_setting("library_roots", []) or [] - return [p for p in saved if isinstance(p, str)] + return [p for p in saved if isinstance(p, str)] if isinstance(saved, list) else [] + + def _register_library_roots(self, paths: List[str]) -> None: + roots = self.library_roots() + new = [p for p in paths if p not in roots] + if new: + self.session.repo.save_global_setting("library_roots", [*roots, *new]) + + def has_rolls(self) -> bool: + return bool(rolls.saved_rolls(self.session.repo)) + + def import_subfolders_as_rolls(self, parent_path: str) -> List[str]: + """Recognize every immediate subfolder of *parent_path* as its own roll, and + register it as a search root -- nothing is opened or loaded.""" + roll_ids = rolls.import_subfolders_as_rolls(self.session.repo, parent_path) + if roll_ids: + self._register_library_roots([parent_path]) + return roll_ids def open_library_folder(self, folder: str, add_to_session: bool = False) -> None: self.open_library_folders([folder], add_to_session=add_to_session) def open_library_folders(self, folders: List[str], add_to_session: bool = False) -> None: - """Load one or several folders' frames. Replacing the session costs nothing — - every edit lives in the database under its own content hash, not in the file list.""" + """Recognize and load one or several folders as rolls. Replacing the session + costs nothing — every edit lives in the database under its own content hash, + not in the file list.""" present = [f for f in folders if os.path.isdir(f)] if not present: self.set_status("Folder is no longer on disk", 3000) return + if not add_to_session: + # Recognizing every opened folder is independent of which one, if any, + # becomes the active roll -- that only makes sense for a single one. + recognized = [rolls.recognize_folder(self.session.repo, f) for f in present] + self.state.active_roll_id = recognized[0] if len(recognized) == 1 else None + self._register_library_roots(present) self.request_asset_discovery( present, auto_open=True, @@ -1055,6 +1094,55 @@ def open_library_folders(self, folders: List[str], add_to_session: bool = False) reselect_path=self.state.current_file_path if add_to_session else None, ) + def open_roll(self, roll_id: str) -> None: + """Open a roll (folder or virtual) by id. A folder roll's own contents are + (re)walked as usual, the same as opening it from the tree; its extra_paths -- + files added by hand that are not physically in the folder -- ride along in the + same discovery pass, since request_asset_discovery already accepts a mix of + folder and file paths.""" + entry = rolls.roll_for_id(self.session.repo, roll_id) + if entry is None: + self.set_status("That roll no longer exists", 3000) + return + if entry["kind"] == "folder": + paths = [entry["folder_path"], *entry.get("extra_paths", [])] + else: + paths = list(entry.get("member_paths", [])) + if not paths: + self.set_status("This roll has no frames", 3000) + return + self.state.active_roll_id = roll_id + self.request_asset_discovery(paths, auto_open=True, replace_existing=True) + + def create_roll_from_session(self, name: str) -> Optional[str]: + """Save the frames currently in the Film Strip as a new virtual roll: a roll + that is not a folder, e.g. a library search's results, kept and named.""" + paths = [f["path"] for f in self.state.uploaded_files if f.get("path")] + if not paths: + self.set_status("Nothing loaded to save as a roll", 3000) + return None + roll_id = rolls.create_virtual_roll(self.session.repo, name, paths) + self.state.active_roll_id = roll_id + self.set_status(f'Saved as roll "{name}"', 3000) + return roll_id + + def request_rename_roll(self, roll_id: str, new_name: str, rename_folder: bool) -> bool: + """Rename a roll's display name, and -- only if asked -- its backing folder on + disk too. All-or-nothing: if the disk rename fails (missing folder, a sibling + already named that, no permission), the display name is left alone as well, + so the two names can never end up telling different stories. + """ + if rename_folder: + entry = rolls.roll_for_id(self.session.repo, roll_id) + old_path = entry.get("folder_path", "") if entry else "" + new_path = rolls.rename_folder_roll_disk(self.session.repo, roll_id, new_name) + if new_path is None: + return False + if old_path and roll_id == self.state.active_roll_id: + self.session.rehome_folder_paths(old_path, new_path) + rolls.rename_roll(self.session.repo, roll_id, new_name) + return True + def invalidate_library_walk(self) -> None: """Drop the cached traversal so the next search re-reads the folders.""" QMetaObject.invokeMethod(self.library_worker, "invalidate", Qt.ConnectionType.QueuedConnection) @@ -1093,6 +1181,9 @@ def _on_library_search_finished(self, paths: List[str]) -> None: self.set_status("No frames in the library match that search", 4000) return self.set_status(f"{len(paths)} frame{'s' if len(paths) != 1 else ''} found", 3000) + # An ad hoc result, not (yet) any roll -- Save as Roll in the Film Strip turns it + # into one. + self.state.active_roll_id = None self.request_asset_discovery(paths, auto_open=True, replace_existing=True) def set_rgb_scan_mode(self, enabled: bool) -> None: @@ -1463,6 +1554,13 @@ def _on_discovery_finished(self, valid_assets: List[Dict]) -> None: self._reselect_after_discovery = None active_discovery_keys = self._active_discovery_keys self._active_discovery_keys = frozenset() + + # Files appended (not replaced) while a roll is active join its membership, so + # reopening that roll later still shows what was added by hand. + if not replace_existing and self.state.active_roll_id: + for asset in valid_assets: + if asset.get("path"): + rolls.add_extra_member(self.session.repo, self.state.active_roll_id, asset["path"]) pending_scan = getattr(self, "_pending_scanned_file", None) if replace_existing and valid_assets: @@ -3296,6 +3394,16 @@ def _on_normalization_finished(self, locked_floors: tuple, locked_ceils: tuple) self.status_progress_requested.emit(0, 0) self.request_render() + def request_reset_roll(self) -> None: + """Reset every visible frame to its own bare defaults -- Reset Settings, applied + to the whole roll at once.""" + visible = [self.state.uploaded_files[i] for i in self.session.asset_model.visible_actual_indices_ordered()] + if not visible: + return + self.session.reset_roll(visible) + self.set_status(f"Reset {count_of(len(visible), 'frame')} to defaults", timeout=3000) + self.request_render() + def save_current_normalization_as_roll(self, name: str) -> None: """ Persists current batch normalization values as a named roll. diff --git a/negpy/desktop/session.py b/negpy/desktop/session.py index ccf7d10d8..ed6530b4f 100644 --- a/negpy/desktop/session.py +++ b/negpy/desktop/session.py @@ -76,6 +76,10 @@ class AppState: source_exif: Dict[str, Any] = field(default_factory=dict) # file_hash -> piexif dict selected_file_idx: int = -1 selected_indices: List[int] = field(default_factory=list) + # The roll (negpy.services.assets.rolls) the loaded frames came from, if any -- a + # plain Add Files pick or a clear leaves this None. Files appended while it is set + # join that roll's membership so reopening it later still shows them. + active_roll_id: Optional[str] = None active_adjustment_idx: int = 0 last_metrics: Dict[str, Any] = field(default_factory=dict) metrics_lock: threading.Lock = field(default_factory=threading.Lock, init=False, compare=False, repr=False) @@ -1364,6 +1368,21 @@ def reset_settings(self) -> None: asset = self.state.uploaded_files[idx] if 0 <= idx < len(self.state.uploaded_files) else {} self.update_config(self._asset_defaults(WorkspaceConfig(), asset), persist=True) + def reset_roll(self, assets: List[Dict]) -> None: + """`reset_settings`, applied to every one of *assets* at once. Each frame's reset + is still an ordinary undo step; the active frame (if among them) re-renders via + `update_config`, the rest are written straight to the DB with an external history + step, the same split `_on_normalization_finished` uses for a roll-wide write. + """ + for f_info in assets: + new_p = self._asset_defaults(WorkspaceConfig(), f_info) + if f_info["hash"] == self.state.current_file_hash: + self.update_config(new_p, persist=True) + continue + old_p = self.repo.load_file_settings(f_info["hash"]) or self.config_for_asset(f_info) + self.push_external_history(f_info["hash"], old_p, new_p) + self.repo.save_file_settings(f_info["hash"], new_p, file_path=f_info["path"]) + def reset_section(self, section: str) -> None: """Reset a single feature section to its default config.""" from negpy.features.exposure.models import ExposureConfig @@ -1612,12 +1631,51 @@ def clear_files(self) -> None: self.state.uploaded_files.clear() self.state.thumbnails.clear() self.state.rendered_thumbnails.clear() + self.state.active_roll_id = None self._reset_active_image_state() self.asset_model.refresh() self.state_changed.emit() self._persist_session() + def rehome_folder_paths(self, old_prefix: str, new_prefix: str) -> None: + """After a folder roll's own folder is renamed on disk, repoint every loaded + asset (and the active file) that lived under *old_prefix* to *new_prefix* -- + content hashes are unchanged, so edits and history still find their frame by + hash alone; only the session's own path bookkeeping needs to catch up. + """ + old_prefix = old_prefix.rstrip("/\\") + + def rehome(path: str) -> str: + if path and (path == old_prefix or path.startswith(old_prefix + os.sep)): + return new_prefix + path[len(old_prefix) :] + return path + + changed = False + for f in self.state.uploaded_files: + for key in ("path", "green_path", "blue_path"): + if f.get(key): + new_val = rehome(f[key]) + if new_val != f[key]: + f[key] = new_val + changed = True + for key in ("stitch_paths", "hdr_paths"): + if f.get(key): + new_list = [rehome(p) for p in f[key]] + if new_list != f[key]: + f[key] = new_list + changed = True + + if self.state.current_file_path: + new_current = rehome(self.state.current_file_path) + if new_current != self.state.current_file_path: + self.state.current_file_path = new_current + changed = True + + if changed: + self.asset_model.refresh() + self._persist_session() + def remove_current_file(self) -> None: """ Removes the currently selected file from the session. diff --git a/negpy/desktop/view/confirm.py b/negpy/desktop/view/confirm.py index 6a64305da..b097a6bc1 100644 --- a/negpy/desktop/view/confirm.py +++ b/negpy/desktop/view/confirm.py @@ -1,4 +1,31 @@ -from PyQt6.QtWidgets import QMessageBox +from PyQt6.QtWidgets import QCheckBox, QMessageBox + + +def confirm_load_roll(parent, repo, image_count: int, label: str) -> bool: + """Ask before hashing and thumbnailing a folder's images into the session. + + Skippable via "Always load without asking", persisted so importing a library + full of rolls one at a time does not re-prompt for each. + """ + if repo.get_global_setting("library_autoload_folders", False): + return True + + n = image_count + box = QMessageBox(parent) + box.setIcon(QMessageBox.Icon.Question) + box.setWindowTitle("Load Roll") + box.setText(f"Load {n} image{'s' if n != 1 else ''} from “{label}”?") + box.setInformativeText("They are hashed and thumbnailed on load, which takes a moment on a large roll.") + remember = QCheckBox("Always load without asking") + box.setCheckBox(remember) + load = box.addButton("Load", QMessageBox.ButtonRole.AcceptRole) + box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) + box.exec() + if box.clickedButton() is not load: + return False + if remember.isChecked(): + repo.save_global_setting("library_autoload_folders", True) + return True def confirm_unload(parent, *, clear_all: bool = False, count: int = 1) -> bool: @@ -29,6 +56,21 @@ def confirm_unload(parent, *, clear_all: bool = False, count: int = 1) -> bool: return box.exec() == QMessageBox.StandardButton.Yes +def confirm_reset_roll(parent, count: int) -> bool: + """Ask before resetting every visible frame to its own defaults. Each frame's + reset is still an ordinary undo step, but doing it to a whole roll at once is + easy to fire by accident. Enter confirms (default button); Esc cancels. + """ + box = QMessageBox(parent) + box.setIcon(QMessageBox.Icon.Question) + box.setWindowTitle("Reset Roll to Defaults") + box.setText(f"Reset all {count} loaded frame{'s' if count != 1 else ''} to their own defaults?") + box.setInformativeText("Every frame's edit is undone at once. Each one is still a normal undo step, frame by frame.") + box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel) + box.setDefaultButton(QMessageBox.StandardButton.Yes) + return box.exec() == QMessageBox.StandardButton.Yes + + def confirm_delete_named(parent, kind: str, name: str, *, informative: str = "") -> bool: """Ask before deleting a named, user-created item — a work print, a roll, a flat-field profile. None of them are undoable and none can be re-derived from the @@ -45,6 +87,19 @@ def confirm_delete_named(parent, kind: str, name: str, *, informative: str = "") return box.exec() == QMessageBox.StandardButton.Yes +def confirm_delete_several(parent, kind: str, names: list, *, informative: str = "") -> bool: + """Ask before deleting several named items at once, selected together. Enter + confirms; Esc cancels.""" + box = QMessageBox(parent) + box.setIcon(QMessageBox.Icon.Question) + box.setWindowTitle(f"Delete {len(names)} {kind}s") + box.setText(f"Delete these {len(names)} {kind.lower()}s?") + box.setInformativeText(informative or "\n".join(names)) + box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel) + box.setDefaultButton(QMessageBox.StandardButton.Yes) + return box.exec() == QMessageBox.StandardButton.Yes + + def confirm_delete_mask(parent) -> bool: """Ask before deleting a single dodge/burn mask. Enter confirms; Esc cancels.""" box = QMessageBox(parent) diff --git a/negpy/desktop/view/keyboard_shortcuts.py b/negpy/desktop/view/keyboard_shortcuts.py index 3865df558..6dae6a034 100644 --- a/negpy/desktop/view/keyboard_shortcuts.py +++ b/negpy/desktop/view/keyboard_shortcuts.py @@ -172,7 +172,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "local_draw": lambda: _toggle_tool_button(self.window, "tone", controls.local_sidebar.draw_btn), "local_oval": lambda: _toggle_tool_button(self.window, "tone", controls.local_sidebar.oval_btn), "local_gradient": lambda: _toggle_tool_button(self.window, "tone", controls.local_sidebar.gradient_btn), - "analysis_draw": lambda: _toggle_tool_button(self.window, "setup", controls.process_sidebar.analysis_region_btn), + "analysis_draw": lambda: _toggle_tool_button(self.window, "roll", controls.process_sidebar.analysis_region_btn), "toggle_flat_peek": controller.toggle_flat_peek, "toggle_negative_peek": controller.toggle_negative_peek, "toggle_embedded_peek": controller.toggle_embedded_peek, @@ -186,7 +186,6 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "toggle_soft_proof": lambda: controller.set_soft_proof(not controller.state.soft_proof_enabled), "cancel_tool": lambda: _context_cancel(controller, self.window), "show_library": self.window.session_panel.show_library, - "browse_parent": self.window.session_panel.browse_parent, "focus_search": self.window.session_panel.file_browser.focus_search, "search_library": self.window.session_panel.file_browser.search_library, "toggle_library_tree": self.window.session_panel.toggle_library_tree, @@ -199,7 +198,7 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "reset_panel_layout": self.window.reset_panel_layout, "edit_toolbar": toolbar.open_toolbar_editor, "tab_favourites": lambda: right.show_tab_by_key("favourites"), - "tab_setup": lambda: right.show_tab_by_key("setup"), + "tab_roll": lambda: right.show_tab_by_key("roll"), "tab_geometry": lambda: right.show_tab_by_key("geometry"), "tab_tone": lambda: right.show_tab_by_key("tone"), "tab_color": lambda: right.show_tab_by_key("color"), @@ -207,6 +206,9 @@ def _build_actions(self) -> dict[str, Callable[[], None]]: "tab_export": lambda: right.show_tab_by_key("export"), "tab_metadata": lambda: right.show_tab_by_key("metadata"), "tab_history": lambda: right.show_tab_by_key("history"), + "tab_gear": lambda: right.show_tab_by_key("gear"), + "tab_gear_items": lambda: right.show_gear_subtab_by_key("items"), + "tab_gear_presets": lambda: right.show_gear_subtab_by_key("presets"), "tab_scan": lambda: right.show_tab_by_key("scan"), "fit_view": self.window.canvas.fit_to_window, "zoom_100": self.window.canvas.zoom_to_original, diff --git a/negpy/desktop/view/shortcut_registry.py b/negpy/desktop/view/shortcut_registry.py index 0e71bef3e..b26e632ad 100644 --- a/negpy/desktop/view/shortcut_registry.py +++ b/negpy/desktop/view/shortcut_registry.py @@ -188,11 +188,10 @@ class ShortcutEntry: "vignette_size_inc": ShortcutEntry("Alt+S", "Vignette size up", "Finishing"), "border_size_dec": ShortcutEntry("Alt+Shift+D", "Border width down", "Finishing"), "border_size_inc": ShortcutEntry("Alt+D", "Border width up", "Finishing"), - "show_library": ShortcutEntry("Ctrl+L", "Open the library folder", "Navigation"), - "browse_parent": ShortcutEntry("Alt+Up", "Go up one library folder", "Navigation"), + "show_library": ShortcutEntry("Ctrl+L", "Open the library", "Navigation"), "focus_search": ShortcutEntry("Ctrl+F", "Focus the film strip search box", "Navigation"), "search_library": ShortcutEntry("Ctrl+Shift+F", "Search every library folder and load the matches", "Navigation"), - "toggle_library_tree": ShortcutEntry("", "Show/hide the library folder tree", "View"), + "toggle_library_tree": ShortcutEntry("", "Show/hide the library", "View"), "toggle_immersive_canvas": ShortcutEntry("", "Immersive canvas (toolbar overlaps image)", "View"), "toggle_sticky_zoom": ShortcutEntry("", "Sticky zoom (keep zoom level when switching images)", "View"), "toggle_slider_values": ShortcutEntry("", "Show slider values (keep every value box open)", "View"), @@ -201,7 +200,7 @@ class ShortcutEntry: "toggle_right_panel": ShortcutEntry("Ctrl+]", "Toggle controls panel (re-docks when floating)", "View"), "reset_panel_layout": ShortcutEntry("Ctrl+Shift+L", "Dock session and controls panels", "View"), "edit_toolbar": ShortcutEntry("", "Edit the canvas toolbar (which controls it shows, in what order)", "View"), - "tab_setup": ShortcutEntry("Ctrl+1", "Setup tab", "Tabs"), + "tab_roll": ShortcutEntry("Ctrl+1", "Roll tab", "Tabs"), "tab_geometry": ShortcutEntry("Ctrl+2", "Geometry tab", "Tabs"), "tab_tone": ShortcutEntry("Ctrl+3", "Tone tab", "Tabs"), "tab_color": ShortcutEntry("Ctrl+4", "Lab & Toning tab", "Tabs"), @@ -209,6 +208,9 @@ class ShortcutEntry: "tab_history": ShortcutEntry("Ctrl+6", "History tab", "Tabs"), "tab_export": ShortcutEntry("Ctrl+7", "Export tab", "Tabs"), "tab_metadata": ShortcutEntry("Ctrl+8", "Metadata tab", "Tabs"), + "tab_gear": ShortcutEntry("", "Gear tab", "Tabs"), + "tab_gear_items": ShortcutEntry("", "Gear: Items subtab", "Tabs"), + "tab_gear_presets": ShortcutEntry("", "Gear: Presets subtab", "Tabs"), "tab_scan": ShortcutEntry("Ctrl+9", "Scan tab", "Tabs"), "tab_favourites": ShortcutEntry("Ctrl+0", "Favorites tab", "Tabs"), "fit_view": ShortcutEntry("0", "Fit to window", "View"), diff --git a/negpy/desktop/view/sidebar/controls_panel.py b/negpy/desktop/view/sidebar/controls_panel.py index e77db6dd2..75f828d06 100644 --- a/negpy/desktop/view/sidebar/controls_panel.py +++ b/negpy/desktop/view/sidebar/controls_panel.py @@ -265,15 +265,11 @@ def _init_ui(self) -> None: icon_name="fa5s.paint-brush", ) - # Group the sections into workflow pages (each becomes an icon tab in RightPanel). + # Group the sections into workflow pages (each becomes an icon tab in RightPanel's + # Frame group). Calibration, Demosaic, Roll Analysis, Normalization and Presets are + # roll-wide facts, not per-frame edits -- RightPanel builds them into its own + # top-level Roll tab instead of a page here. groups = [ - ( - "setup", - "fa5s.cogs", - "Setup — Calibration, Demosaic, Normalization, Roll Analysis, Presets", - [self.sensor_section, self.demosaic_section, self.process_section, self.roll_section, self.presets_section], - ["sensor_section", "demosaic_section", "process_section", "roll_section"], - ), ( "geometry", "fa5s.crop", @@ -310,10 +306,6 @@ def _init_ui(self) -> None: page_layout = QVBoxLayout(page) page_layout.setContentsMargins(0, 0, 0, 0) page_layout.setSpacing(8) - if key == "setup": - # Film mode rides above the collapsibles, since it is the first choice of every edit, and - # this is what reparents the bar out of ProcessSidebar. - page_layout.addWidget(self.process_sidebar.mode_bar) for section in sections: page_layout.addWidget(section) page_layout.addStretch(1) diff --git a/negpy/desktop/view/sidebar/export.py b/negpy/desktop/view/sidebar/export.py index 150f88bf6..0adc3ba48 100644 --- a/negpy/desktop/view/sidebar/export.py +++ b/negpy/desktop/view/sidebar/export.py @@ -112,6 +112,8 @@ def _connect_signals(self) -> None: self.manage_presets_btn.clicked.connect(self._open_presets_dialog) self.export_presets_btn.clicked.connect(self._on_export_presets_clicked) self.export_main_btn.clicked.connect(self._on_export_clicked) + self.protect_check.toggled.connect(self._on_protect_toggled) + self.sync_check.toggled.connect(self._on_sync_to_batch_toggled) self.intent_btn_group.idToggled.connect(self._on_flat_output_toggled) self.flat_peek_btn.toggled.connect(lambda checked: self.controller.toggle_flat_peek(force=checked)) @@ -1291,6 +1293,26 @@ def _add_export_section(self) -> None: saved = self._RETIRED_EXPORT_SCOPES.get(saved, saved) self._set_export_scope(saved if saved in self._EXPORT_SCOPES else "current", persist=False) + # Export-time behaviors over the Metadata tab's per-frame fields, not metadata + # content themselves, so they live beside the Export button rather than on + # that tab. Both are stored on the Metadata config. + meta = self.state.config.metadata + self.protect_check = QCheckBox("Protect original metadata") + self.protect_check.setChecked(meta.protect_original_metadata) + self.protect_check.setToolTip( + "When enabled, NegPy copies EXIF and XMP from the source file onto exports " + "without adding or changing metadata. Gear and process fields are ignored." + ) + self.layout.addWidget(self.protect_check) + + self.sync_check = QCheckBox("Sync custom metadata to all files in batch export") + self.sync_check.setChecked(meta.sync_to_batch) + self.sync_check.setEnabled(not meta.protect_original_metadata) + self.sync_check.setToolTip( + "Batch and preset exports write this frame's capture, gear and process values to every file, instead of each file's own." + ) + self.layout.addWidget(self.sync_check) + def _set_export_scope(self, key: str, persist: bool = True) -> None: self._export_scope = key _label, btn_label, tooltip = self._EXPORT_SCOPES[key] @@ -1300,6 +1322,16 @@ def _set_export_scope(self, key: str, persist: bool = True) -> None: if persist: self.controller.session.repo.save_global_setting("export_scope", key) + def _on_sync_to_batch_toggled(self, checked: bool) -> None: + self.update_config_section("metadata", persist=True, render=False, readback_metrics=False, sync_to_batch=checked) + + def _on_protect_toggled(self, checked: bool) -> None: + """Protect ignores gear/process fields, so syncing them to a batch would mean + nothing; the Metadata tab's own fields disable through its sync_ui() picking + up this same config change.""" + self.sync_check.setEnabled(not checked) + self.update_config_section("metadata", persist=True, render=False, readback_metrics=False, protect_original_metadata=checked) + def _flush_export_settings(self) -> None: """Stop the debounce timer and write the form into state immediately.""" self.update_timer.stop() @@ -1546,6 +1578,10 @@ def sync_ui(self) -> None: if not self.cs_output_path_edit.hasFocus(): self.cs_output_path_edit.setText(conf.contact_sheet_output_path) self.sidecars_enabled_btn.setChecked(conf.export_sidecars_enabled) + meta = self.state.config.metadata + self.protect_check.setChecked(meta.protect_original_metadata) + self.sync_check.setChecked(meta.sync_to_batch) + self.sync_check.setEnabled(not meta.protect_original_metadata) self.printing_notes_preview_btn.setChecked(self.state.printing_notes) self._refresh_contact_sheet_templates() saved_template = conf.contact_sheet_template.strip() @@ -1594,6 +1630,8 @@ def block_signals(self, blocked: bool) -> None: self.cs_output_path_edit, self.cs_template_combo, self.sidecars_enabled_btn, + self.protect_check, + self.sync_check, self.flat_peek_btn, self.printing_notes_preview_btn, self.linear_wb_checkbox, diff --git a/negpy/desktop/view/sidebar/files.py b/negpy/desktop/view/sidebar/files.py index 5e71211b6..f794eaa48 100644 --- a/negpy/desktop/view/sidebar/files.py +++ b/negpy/desktop/view/sidebar/files.py @@ -24,12 +24,14 @@ QFileDialog, QFrame, QHBoxLayout, + QInputDialog, QLabel, QLineEdit, QListView, QMenu, QMessageBox, QSlider, + QSplitter, QStyle, QStyledItemDelegate, QStyleOptionViewItem, @@ -41,7 +43,7 @@ from negpy.kernel.system.text import count_of from negpy.desktop.controller import AppController from negpy.desktop.session import AppState, _source_effective_bounds, composite_kind -from negpy.desktop.view.confirm import confirm_unload +from negpy.desktop.view.confirm import confirm_reset_roll, confirm_unload from negpy.features.hdr.logic import anchor_choices from negpy.features.hdr.models import hdr_frame_paths from negpy.desktop.view.widgets.overflow_bar import OverflowBar @@ -49,22 +51,24 @@ from negpy.desktop.view.styles.templates import ICON_BUTTON_WIDTH, labeled_action, tool_toggle from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.granular_settings_dialog import GranularSettingsDialog, open_paste_dialog +from negpy.desktop.view.widgets.roll_settings_dialog import RollSettingsDialog +from negpy.services.assets import rolls +from negpy.services.assets.gear import GearProfiles +from negpy.services.assets.gear_match import GearMatch, folder_name_for_active_context, match_gear_for_folder +from negpy.services.assets.presets import is_valid_preset_name from negpy.infrastructure.filesystem.watcher import FolderWatchService from negpy.infrastructure.loaders.helpers import get_supported_raw_wildcards from negpy.desktop.view.sidebar.library_tree import LibraryTree from negpy.desktop.view.widgets.collapsible import CollapsibleSection, make_section from negpy.desktop.view.widgets.file_dialogs import last_open_folder, pick_start_dir -from negpy.services.assets.library import folder_counts +from negpy.services.assets.library import folder_counts, folder_label _UNBOUNDED_HEIGHT = 16777215 # QWIDGETSIZE_MAX — Qt's "no maximum" -# With both sections open the panel splits 40/60: the tree is for finding a roll and the -# sheet is where the work happens, so the frames get the larger half. -_LIBRARY_SHARE, _FRAMES_SHARE = 2, 3 - - -def _folder_label(path: str) -> str: - return os.path.basename(path.rstrip(os.sep)) or path +# With both sections open the panel starts 20/80: the tree is for finding a roll, glanced +# at occasionally, while the sheet is where the work happens and wants the room. The +# splitter's handle can move this default any time. +_LIBRARY_SHARE, _FRAMES_SHARE = 1, 4 class _ThumbnailDelegate(QStyledItemDelegate): @@ -372,9 +376,8 @@ class FileBrowser(QWidget): """ file_selected = pyqtSignal(str) - library_requested = pyqtSignal(bool) # reveal the library (arg: ask for a folder if unset) - browse_requested = pyqtSignal(str) # reveal this folder in the tree - sort_changed = pyqtSignal() # the folder tree follows the sheet's sort + library_requested = pyqtSignal(bool) # reveal the library (arg: import a first roll if unset) + sort_changed = pyqtSignal() # the roll list follows the sheet's sort def __init__(self, controller: AppController): super().__init__() @@ -414,21 +417,23 @@ def _init_ui(self) -> None: icon_size = QSize(16, 16) btn_height = 28 - toolbar_row = OverflowBar(height=btn_height, spacing=4) - - self.library_btn = QToolButton() - self.library_btn.setIcon(qta.icon("fa5s.book-open", color=THEME.text_primary)) - self.library_btn.setToolTip("Library — browse the folder your scans live in") - - self.add_files_btn = QToolButton() - self.add_files_btn.setIcon(qta.icon("fa5s.file-import", color=THEME.text_primary)) - self.add_files_btn.setToolTip("Add files") - self.add_folder_btn = QToolButton() - self.add_folder_btn.setIcon(qta.icon("fa5s.folder-plus", color=THEME.text_primary)) - self.add_folder_btn.setToolTip("Add folder") + # No top-level toolbar: every action lives in the row of the section it acts on -- + # Library's own +/refresh corner, or film_strip_toolbar next to the loaded frames. + self.film_strip_toolbar = OverflowBar(height=btn_height, spacing=4) + + # One button for both: Add Files and Add Folder are two pickers for the same job + # (put pictures in this session), not two different actions worth their own icons. + self.add_btn = QToolButton() + self.add_btn.setIcon(qta.icon("fa5s.file-import", color=THEME.text_primary)) + self.add_btn.setToolTip("Add pictures or a folder to this session") + self.add_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + add_menu = QMenu(self.add_btn) + add_menu.addAction("Add Files…").triggered.connect(self.prompt_add_files) + add_menu.addAction("Add Folder…").triggered.connect(self.prompt_add_folder) + self.add_btn.setMenu(add_menu) self.unload_btn = QToolButton() self.unload_btn.setIcon(qta.icon("fa5s.times-circle", color=THEME.text_primary)) - self.unload_btn.setToolTip("Clear All…") + self.unload_btn.setToolTip("Unload…") self.hot_folder_btn = QToolButton() self.hot_folder_btn.setCheckable(True) @@ -472,6 +477,18 @@ def _init_ui(self) -> None: self.apply_btn.setToolTip("Apply settings from the current frame to selected frames or the whole roll") self.apply_btn.clicked.connect(self._open_apply_dialog) + self.roll_settings_btn = QToolButton() + self.roll_settings_btn.setIcon(qta.icon("fa5s.tags", color=THEME.text_primary)) + self.roll_settings_btn.setToolTip( + "Roll Settings — tag gear, capture and process metadata across the current frame, a selection or the whole roll" + ) + self.roll_settings_btn.clicked.connect(self._open_roll_settings_dialog) + + self.save_roll_btn = QToolButton() + self.save_roll_btn.setIcon(qta.icon("fa5s.folder", color=THEME.roll_virtual)) + self.save_roll_btn.setToolTip("Save these frames as a roll — a named, reopenable group, not tied to a folder") + self.save_roll_btn.clicked.connect(self._on_save_roll_clicked) + # Sheet filter dropdown self.sheet_btn = QToolButton() self.sheet_btn.setToolTip("Sheet — filter the contact sheet by triage mark") @@ -519,44 +536,46 @@ def _init_ui(self) -> None: self.sort_btn.setMenu(sort_menu) for btn in ( - self.library_btn, - self.add_files_btn, - self.add_folder_btn, + self.add_btn, self.unload_btn, self.hot_folder_btn, self.rgb_scan_btn, self.half_frame_btn, self.half_frame_menu_btn, self.apply_btn, + self.roll_settings_btn, + self.save_roll_btn, self.sheet_btn, - self.sort_btn, ): btn.setIconSize(icon_size) btn.setFixedHeight(btn_height) btn.setCursor(Qt.CursorShape.PointingHandCursor) - # OverflowBar rather than a QHBoxLayout: a plain row made the whole session panel - # unshrinkable below every button laid end to end, so each new tool widened it for good. + # Sort joins LibraryTree's own +/refresh corner row instead of a top-level toolbar: + # mini-button sized (20x20), matching that row's own convention. + self.sort_btn.setFixedSize(20, 20) + self.sort_btn.setCursor(Qt.CursorShape.PointingHandCursor) + for widget, label in ( - (self.library_btn, "Library"), - (self.add_files_btn, "Add files"), - (self.add_folder_btn, "Add folder"), - (self.unload_btn, "Clear All…"), + (self.save_roll_btn, "Save as Roll…"), + (None, None), + (self.add_btn, "Add"), (None, None), (self.hot_folder_btn, "Hot Folder"), + (None, None), (self.rgb_scan_btn, "Trichrome Scan"), (self.half_frame_btn, "Half Frame"), (self.half_frame_menu_btn, "Half Frame actions"), (self.apply_btn, "Apply settings"), + (self.roll_settings_btn, "Roll Settings"), (None, None), + (self.unload_btn, "Unload…"), (self.sheet_btn, "Sheet filter"), - (self.sort_btn, "Sort"), ): if widget is None: - toolbar_row.add_separator(self._create_separator()) + self.film_strip_toolbar.add_separator(self._create_separator()) else: - toolbar_row.add_button(widget, label) - layout.addWidget(toolbar_row) + self.film_strip_toolbar.add_button(widget, label) saved_sort = self.session.repo.get_global_setting("file_sort_order") or "name" saved_desc = self.session.repo.get_global_setting("file_sort_descending") or False @@ -595,15 +614,14 @@ def _init_ui(self) -> None: search_row.addWidget(self.regex_btn) search_row.addWidget(self.library_search_btn) - # Thumbnail size lives here rather than the toolbar row above, to keep that row's overflow - # menu to file actions. + # Built here (Film Strip's thumbnail grid needs a starting value below) but added to + # the Film Strip section's own tally row, since it only ever affects that grid. saved_cell = self.session.repo.get_global_setting("thumbnail_cell_size") or THUMB_CELL_DEFAULT self.thumb_size_slider = QSlider(Qt.Orientation.Horizontal) self.thumb_size_slider.setRange(THUMB_CELL_MIN, THUMB_CELL_MAX) self.thumb_size_slider.setValue(ThumbnailGridView._clamp_target(int(saved_cell))) self.thumb_size_slider.setFixedWidth(72) self.thumb_size_slider.setToolTip("Thumbnail size — smaller fits more columns in the panel") - search_row.addWidget(self.thumb_size_slider) # Above both sections: one box that filters the frames and searches the library, so it # belongs to neither and stays reachable when either is folded away. layout.addLayout(search_row) @@ -632,50 +650,152 @@ def _init_ui(self) -> None: self.empty_label.setVisible(False) self.empty_label.linkActivated.connect(lambda _: self._clear_frame_filters()) - self.library_tree = LibraryTree(self.controller) + self.library_tree = LibraryTree(self.controller, leading_widgets=(self.sort_btn,)) self.library_section = self._make_section("Library", "library", "fa5s.folder-open", self.library_tree) frames = QWidget() frames_layout = QVBoxLayout(frames) frames_layout.setContentsMargins(0, 0, 0, 0) frames_layout.setSpacing(4) - frames_layout.addWidget(self.tally_label) + frames_layout.addWidget(self.film_strip_toolbar) + tally_row = QHBoxLayout() + tally_row.addWidget(self.tally_label, 1) + tally_row.addWidget(self.thumb_size_slider) + frames_layout.addLayout(tally_row) frames_layout.addWidget(self.list_view, 1) frames_layout.addWidget(self.empty_label, 1) self.frames_section = self._make_section("Film Strip", "frames", "fa5s.film", frames) - layout.addWidget(self.library_section) - layout.addWidget(self.frames_section) - # Absorbs the surplus when every section is collapsed, or Qt spreads it into the gaps - # between the rows above. Stretch 0 leaves an open section its share. + # Clearing the strip is how you start a roll you will build entirely by drag-drop, + # so it lives on the section header rather than its own toolbar button -- the + # header has room a wrapping toolbar row does not. + frames_menu = QMenu(self.frames_section) + frames_menu.addAction("New Roll…").triggered.connect(self._on_clear_all) + frames_menu.addAction("Reset Roll to Defaults…").triggered.connect(self._on_reset_roll) + self.frames_section.set_actions_menu( + frames_menu, + "New Roll clears the film strip so you can drag in a fresh batch of frames. " + "Reset Roll to Defaults undoes every loaded frame's edit at once.", + ) + + # A splitter, like the right panel's Analysis/Tabs one, so the boundary can be + # dragged; expanded sections still share it by _LIBRARY_SHARE/_FRAMES_SHARE. + self.sections_splitter = QSplitter(Qt.Orientation.Vertical) + self.sections_splitter.addWidget(self.library_section) + self.sections_splitter.addWidget(self.frames_section) + self.sections_splitter.setCollapsible(0, False) + self.sections_splitter.setCollapsible(1, False) + + saved_sizes = self.session.repo.get_global_setting("session_sections_splitter_sizes") + if isinstance(saved_sizes, list) and len(saved_sizes) == 2: + self.sections_splitter.setSizes([int(s) for s in saved_sizes]) + else: + self.sections_splitter.setSizes([120, 480]) + self.sections_splitter.splitterMoved.connect(self._on_sections_splitter_moved) + self._section_sizes = self.sections_splitter.sizes() + + for index, section in enumerate((self.library_section, self.frames_section)): + section.expanded_changed.connect(lambda expanded, i=index: self._on_section_toggled(i, expanded)) + self._on_section_toggled(index, section.toggle_button.isChecked()) + + # Absorbs the surplus when both sections are collapsed, or Qt spreads it above the + # splitter instead of below it (the splitter's own stretch factor, set to 0 in that + # case by _on_section_toggled, leaves this the only claimant on the leftover space). + layout.addWidget(self.sections_splitter, 1) layout.addStretch(0) - self._rebalance_sections() # Applied after list_view exists: the filter prunes the selection against the view. saved_sheet = self.session.repo.get_global_setting("sheet_filter") or "all" self._apply_sheet_filter(str(saved_sheet), save=False) def _make_section(self, title: str, key: str, icon: str, content: QWidget) -> CollapsibleSection: - section = make_section(self.session.repo, title, key, content, icon, default_expanded=True) - section.expanded_changed.connect(lambda _on: self._rebalance_sections()) - return section + return make_section(self.session.repo, title, key, content, icon, default_expanded=True) + + def _on_sections_splitter_moved(self, *_args) -> None: + self._section_sizes = self.sections_splitter.sizes() + self.session.repo.save_global_setting("session_sections_splitter_sizes", self._section_sizes) - def _rebalance_sections(self) -> None: - """Expanded sections share the panel; a collapsed one keeps only its header. + def resizeEvent(self, event) -> None: + super().resizeEvent(event) + if hasattr(self, "sections_splitter"): + self._rebalance_splitter_sizes() + + def _apply_section_constraints(self, index: int, expanded: bool) -> None: + """A collapsed pane is fixed to exactly its header height, so nothing -- a drag, + a resize -- can hand it more or less than that; an expanded pane is freed back to + its natural range.""" + section = (self.library_section, self.frames_section)[index] + share = (_LIBRARY_SHARE, _FRAMES_SHARE)[index] + if expanded: + section.setMinimumHeight(0) + section.setMaximumHeight(_UNBOUNDED_HEIGHT) + else: + section.setFixedHeight(section.toggle_button.height()) + self.sections_splitter.setStretchFactor(index, share if expanded else 0) + + def _on_section_toggled(self, index: int, expanded: bool) -> None: + """Pin a collapsed section's pane to its header and hand its space to the other + pane, restoring the last size when it reopens — same pattern as the right panel's + Analysis/Tabs splitter, generalized to two panes that can each collapse.""" + sections = (self.library_section, self.frames_section) + other = 1 - index + header = sections[index].toggle_button.height() + sizes = self.sections_splitter.sizes() + total = sum(sizes) or self.sections_splitter.height() + + if not expanded: + self._section_sizes[index] = sizes[index] + self._apply_section_constraints(index, expanded) + + # A QVBoxLayout stretch factor sizes the *widget*, not its content: a splitter both + # of whose panes are pinned small still gets stretched into the layout's full + # leftover space. Capping the splitter's own maximum height, mirroring the + # per-pane constraint above, is what actually keeps the leftover space out of it. + other_header = sections[other].toggle_button.height() + if expanded or sections[other].toggle_button.isChecked(): + self.sections_splitter.setMaximumHeight(_UNBOUNDED_HEIGHT) + else: + self.sections_splitter.setMaximumHeight(header + self.sections_splitter.handleWidth() + other_header) + if total <= 0: + return - Stretch alone is not enough — a collapsed section would still be handed leftover - space — so its height is pinned to the header until it opens again. + want = min(max(self._section_sizes[index], header), max(header, total - header)) if expanded else header + other_want = max(other_header, total - want) if sections[other].toggle_button.isChecked() else other_header + new_sizes = [0, 0] + new_sizes[index] = want + new_sizes[other] = other_want + self.sections_splitter.setSizes(new_sizes) + self._section_sizes = self.sections_splitter.sizes() + + def _rebalance_splitter_sizes(self) -> None: + """Reassert the split on a window resize: a collapsed pane stays pinned to its + header; expanded panes keep their current size ratio, scaled to the new total. + Needed because a QSplitter's own resize handling redistributes new space by + stretch factor, and with two independently-collapsible panes that factor is 0 + for both whenever both happen to be collapsed. """ - layout = self.layout() - for section, share in ((self.library_section, _LIBRARY_SHARE), (self.frames_section, _FRAMES_SHARE)): - expanded = section.toggle_button.isChecked() - layout.setStretchFactor(section, share if expanded else 0) - section.setMaximumHeight(_UNBOUNDED_HEIGHT if expanded else section.toggle_button.height()) + sections = (self.library_section, self.frames_section) + total = self.sections_splitter.height() or sum(self.sections_splitter.sizes()) + if total <= 0: + return + current = self.sections_splitter.sizes() + headers = [s.toggle_button.height() for s in sections] + expanded = [s.toggle_button.isChecked() for s in sections] + collapsed_total = sum(h for h, e in zip(headers, expanded) if not e) + remaining = max(0, total - collapsed_total) + expanded_weight_total = sum(w for w, e in zip(current, expanded) if e) + sizes = [] + for h, e, w in zip(headers, expanded, current): + if e and expanded_weight_total: + sizes.append(round(remaining * w / expanded_weight_total)) + elif e: + sizes.append(remaining) + else: + sizes.append(h) + self.sections_splitter.setSizes(sizes) def _connect_signals(self) -> None: - self.library_btn.clicked.connect(lambda: self.library_requested.emit(True)) - self.add_files_btn.clicked.connect(self.prompt_add_files) - self.add_folder_btn.clicked.connect(self.prompt_add_folder) + self.library_tree.folder_roll_created.connect(self._maybe_suggest_gear) self.unload_btn.clicked.connect(self._on_unload_clicked) self.list_view.clicked.connect(self._on_item_clicked) self.list_view.doubleClicked.connect(self._on_item_double_clicked) @@ -705,60 +825,6 @@ def _connect_signals(self) -> None: del_shortcut.setContext(Qt.ShortcutContext.WidgetShortcut) del_shortcut.activated.connect(self._on_delete_key) - def load_folder(self, path: str, add_to_session: bool = False) -> None: - """Load one folder's images, asking first. - - Listing a folder is free and belongs to the library tree; this is the expensive - half, and an accepted prompt is the only thing that starts the hashing pass. - """ - images, _ = folder_counts(path) - if not images: - self.controller.set_status(f"No images directly in “{_folder_label(path)}”", 4000) - return - if not self._confirm_load(images, _folder_label(path)): - return - self.controller.open_library_folder(path, add_to_session=add_to_session) - - def load_folders(self, paths, add_to_session: bool = False) -> None: - """Load one folder, or a whole selection of them at once.""" - paths = [p for p in paths if p] - if len(paths) == 1: - self.load_folder(paths[0], add_to_session=add_to_session) - return - if not paths: - return - - counted = [(p, folder_counts(p)[0]) for p in paths] - loadable = [p for p, n in counted if n] - total = sum(n for _, n in counted) - if not loadable: - self.controller.set_status("Those folders have no images in them", 4000) - return - if not self._confirm_load(total, f"{len(loadable)} folders"): - return - self.controller.open_library_folders(loadable, add_to_session=add_to_session) - - def _confirm_load(self, image_count: int, label: str) -> bool: - if self.session.repo.get_global_setting("library_autoload_folders", False): - return True - - n = image_count - box = QMessageBox(self) - box.setIcon(QMessageBox.Icon.Question) - box.setWindowTitle("Load Roll") - box.setText(f"Load {n} image{'s' if n != 1 else ''} from “{label}”?") - box.setInformativeText("They are hashed and thumbnailed on load, which takes a moment on a large roll.") - remember = QCheckBox("Always load without asking") - box.setCheckBox(remember) - load = box.addButton("Load", QMessageBox.ButtonRole.AcceptRole) - box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole) - box.exec() - if box.clickedButton() is not load: - return False - if remember.isChecked(): - self.session.repo.save_global_setting("library_autoload_folders", True) - return True - def search_library(self) -> None: """Run the box's query against the library folders instead of the loaded roll.""" self.controller.request_library_search(self.search_input.text()) @@ -778,24 +844,42 @@ def _on_files_changed(self) -> None: self.sync_ui() def _on_unload_clicked(self) -> None: - count = len(self.session.state.selected_indices) - if count > 1: - if confirm_unload(self, count=count): - self.session.remove_selected_files() - else: - self._on_clear_all() + """Same as the context menu's Unload…: always targets the selection -- at least + the active frame, ordinarily -- never the whole roll. Opening a different roll + already replaces the film strip, so wiping everything is not something this + button needs to reach for; Clear All for the rare "go back to empty" case lives + in the empty-space context menu instead.""" + self._on_remove_from_menu() def _on_clear_all(self) -> None: - """Drop every loaded frame. The empty-space menu always means *all*, unlike - the toolbar button, which clears the selection when one is active.""" + """Drop every loaded frame, from the empty-space context menu.""" if confirm_unload(self, clear_all=True): self.session.clear_files() + def _on_reset_roll(self) -> None: + """Reset every visible frame back to its own defaults, from the Film Strip + header's ⋮ menu.""" + count = len(self.session.asset_model.visible_actual_indices_ordered()) + if count and confirm_reset_roll(self, count): + self.controller.request_reset_roll() + + def _on_save_roll_clicked(self) -> None: + """Save whatever the Film Strip currently holds as a named, reopenable roll -- + a library search's results, a hand-picked selection, or a folder roll's extras.""" + name, ok = QInputDialog.getText(self, "Save as Roll", "Name:") + name = name.strip() + if not ok or not name: + return + if not is_valid_preset_name(name): + QMessageBox.warning(self, "Roll Name", 'A roll name cannot contain / \\ : * ? " < > | or start or end with a dot.') + return + if self.controller.create_roll_from_session(name): + self.library_tree.reload() + self._update_tally() + def _update_unload_button(self) -> None: - if len(self.session.state.selected_indices) > 1: - self.unload_btn.setToolTip("Clear selected") - else: - self.unload_btn.setToolTip("Clear All…") + multi = len(self.session.state.selected_indices) > 1 + self.unload_btn.setToolTip("Unload Selected…" if multi else "Unload…") def _sync_half_frame_menu(self) -> None: state = self.session.state @@ -950,9 +1034,21 @@ def _update_tally(self) -> None: text += f" · {keepers} keeper{'s' if keepers != 1 else ''}" if rejected: text += f" · {rejected} rejected" + roll_name = self._active_roll_name() + if roll_name: + text = f"{roll_name} — {text}" self.tally_label.setText(text) self.tally_label.setVisible(True) + def _active_roll_name(self) -> str: + """The roll the Film Strip's frames came from, if any -- shown ahead of the + tally so it stays visible without opening Library to check.""" + roll_id = self.session.state.active_roll_id + if not roll_id: + return "" + entry = rolls.roll_for_id(self.session.repo, roll_id) + return entry.get("name", "") if entry else "" + def _update_empty_state(self) -> None: """Swap the strip for a message when a filter leaves it with nothing to show.""" files = self.session.state.uploaded_files @@ -1127,8 +1223,8 @@ def prompt_add_folder(self) -> None: self.open_or_browse(folder) def open_or_browse(self, folder: str) -> None: - """Load a folder's images, or — when it only holds subfolders — reveal it in the - library tree so its subfolders are one click away. + """Load a folder's images into the session, or point at Library's own import + when it only holds subfolders. Picking the one directory everything lives under used to dead-end on "no supported assets found", because the importer looks in that folder and not @@ -1138,7 +1234,11 @@ def open_or_browse(self, folder: str) -> None: if images: self.controller.request_asset_discovery([folder], auto_open=True, announce_rgb=True) elif subfolders: - self.browse_requested.emit(folder) + self.controller.set_status( + f"No images directly in “{folder_label(folder)}” — use Library's Import Subfolders as Rolls for its " + f"{subfolders} subfolder{'s' if subfolders != 1 else ''}", + 5000, + ) self.controller.set_status(f"No images directly in that folder — showing its {subfolders} subfolders", 5000) else: self.controller.set_status("That folder has no images in it", 4000) @@ -1213,6 +1313,70 @@ def _open_apply_dialog(self) -> None: if dlg.exec() == QDialog.DialogCode.Accepted: self.session.sync_selected_settings(dlg.selected(), dlg.bounds_flags(), dlg.scope()) + def _open_roll_settings_dialog(self) -> None: + """The tag-icon button: always opens, and silently pre-fills a gear match too + (only when Gear is not already set) -- the automatic suggestion at import time + is one moment among several this same guess is useful in.""" + detected = self._detect_gear_suggestion(self._folder_name_for_gear_suggestion()) + dlg = self._build_roll_settings_dialog() + if dlg is None: + return + if detected is not None: + dlg.apply_detected_gear(camera_id=detected.camera_id, film_stock_id=detected.film_stock_id) + self._exec_roll_settings_dialog(dlg) + + def _build_roll_settings_dialog(self) -> Optional[RollSettingsDialog]: + state = self.session.state + src = state.selected_file_idx + if src == -1: + return None + visible = self.session.asset_model.visible_actual_indices() + sel_targets = len([i for i in set(state.selected_indices) if i != src and i in visible]) + roll_targets = len([i for i in visible if i != src]) + return RollSettingsDialog(self, state.config, GearProfiles.load_library(), sel_count=sel_targets, roll_count=roll_targets) + + def _exec_roll_settings_dialog(self, dlg: RollSettingsDialog) -> None: + if dlg.exec() != QDialog.DialogCode.Accepted: + return + rows = dlg.selected_rows() + if not rows: + return + if self.controller.session.apply_preset_fields(dlg.selected_config(), rows, dlg.scope()): + self.controller.request_render() + + def _folder_name_for_gear_suggestion(self) -> str: + return folder_name_for_active_context(self.session.state, self.session.repo) + + def _detect_gear_suggestion(self, folder_name: str) -> Optional[GearMatch]: + """The gear match for *folder_name*, restricted to whichever of camera/film + stock the current frame does not already carry -- checked independently, so an + unrelated camera already set (carried from another frame, tagged by hand) does + not also block a film-stock match that is otherwise free to suggest. None when + there is nothing left to offer.""" + if not folder_name: + return None + meta = self.session.state.config.metadata + detected = match_gear_for_folder(folder_name, GearProfiles.load_library()) + result = GearMatch( + camera_id="" if meta.camera_id else detected.camera_id, + film_stock_id="" if meta.film_stock_id else detected.film_stock_id, + ) + return result if result.any() else None + + def _maybe_suggest_gear(self, folder_path: str) -> None: + """A folder just became a roll for the first time: offer Roll Settings pre-filled + from a folder-name match against the gear library, ticked but never applied + without the user pressing Apply. Silent when nothing matches -- checked before + building the dialog, so a folder with nothing to suggest never pops one up.""" + detected = self._detect_gear_suggestion(folder_label(folder_path)) + if detected is None: + return + dlg = self._build_roll_settings_dialog() + if dlg is None: + return + dlg.apply_detected_gear(camera_id=detected.camera_id, film_stock_id=detected.film_stock_id) + self._exec_roll_settings_dialog(dlg) + def _build_session_menu(self) -> QMenu: """Mirrors the panel toolbar's add/clear tools, for a right click on empty space.""" icon_color = THEME.text_primary diff --git a/negpy/desktop/view/sidebar/library_tree.py b/negpy/desktop/view/sidebar/library_tree.py index 82e9d8afe..a5b9ed0c1 100644 --- a/negpy/desktop/view/sidebar/library_tree.py +++ b/negpy/desktop/view/sidebar/library_tree.py @@ -1,14 +1,15 @@ -import os - import qtawesome as qta from PyQt6.QtCore import Qt, pyqtSignal from PyQt6.QtGui import QColor, QKeySequence, QShortcut from PyQt6.QtWidgets import ( QAbstractItemView, + QDialog, QFileDialog, QHBoxLayout, QHeaderView, + QInputDialog, QMenu, + QMessageBox, QToolButton, QTreeWidget, QTreeWidgetItem, @@ -16,54 +17,40 @@ QWidget, ) +from negpy.desktop.view.confirm import confirm_delete_named, confirm_delete_several, confirm_load_roll +from negpy.desktop.view.widgets.rename_roll_dialog import RenameRollDialog from negpy.desktop.view.styles.templates import hint_label from negpy.desktop.view.styles.theme import THEME -from negpy.services.assets.library import folder_counts, summarize_counts - -_PATH_ROLE = Qt.ItemDataRole.UserRole -_IS_ROOT_ROLE = Qt.ItemDataRole.UserRole + 1 -_MTIME_ROLE = Qt.ItemDataRole.UserRole + 2 -_PLACEHOLDER = "__unpopulated__" - +from negpy.services.assets import rolls +from negpy.services.assets.library import folder_counts, folder_label, summarize_counts +from negpy.services.assets.presets import is_valid_preset_name -def _subdirectories(path: str) -> list: - try: - entries = [e for e in os.scandir(path) if e.is_dir() and not e.name.startswith(".")] - except OSError: - return [] - return entries - - -def _has_subdirectory(path: str) -> bool: - try: - return any(e.is_dir() and not e.name.startswith(".") for e in os.scandir(path)) - except OSError: - return False +_ROLL_ID_ROLE = Qt.ItemDataRole.UserRole class LibraryTree(QWidget): - """The folder tree of the user's library roots, read straight from disk. + """The library: every Roll imported or built, in one flat, flat-colored list. - NegPy owns nothing here: a folder is a folder on the filesystem, so reorganizing - in Finder needs no reconciling, and nothing in this panel ever moves a file. - Children are read on expand — a root pointing at a large archive costs one - directory listing, not a crawl. + A folder is only ever an import source, never something browsed live: importing + recognizes it (or, for a parent full of scan folders, recognizes each immediate + subfolder as its own roll in one pass), and from then on the roll -- not the path + -- is what is opened, renamed or deleted. NegPy owns nothing on disk here; + re-importing after Finder reorganizes a folder just re-reads its count. - Click selects (ctrl/shift builds a set), double-click or Enter opens. Loading a - roll is the expensive step, so it waits for the second click rather than firing - while you are still picking which folders you meant. + Click selects, double-click or Enter opens a roll -- opening is the expensive + step, so it waits for the second click. """ - folders_activated = pyqtSignal(list) # paths to open — one folder, or a whole selection - folders_appended = pyqtSignal(list) # "Add to Session": load without replacing - roots_changed = pyqtSignal() + rolls_changed = pyqtSignal() # a roll was imported, renamed or deleted + folder_roll_created = pyqtSignal(str) # a folder was recognized as a roll for the first time - def __init__(self, controller): + def __init__(self, controller, leading_widgets: tuple[QWidget, ...] = ()): super().__init__() self.controller = controller self.repo = controller.session.repo self._sort_order = "name" self._sort_descending = False + self._leading_widgets = leading_widgets self._init_ui() self.reload() @@ -77,19 +64,27 @@ def _init_ui(self) -> None: header.setSpacing(4) header.addStretch(1) - self.add_root_btn = QToolButton() - self.add_root_btn.setIcon(qta.icon("fa5s.plus", color=THEME.text_primary)) - self.add_root_btn.setToolTip("Add a library folder") - self.add_root_btn.setFixedSize(20, 20) - self.add_root_btn.clicked.connect(self.add_root) + # Sized by the caller to match this row's own mini-button convention. + for widget in self._leading_widgets: + header.addWidget(widget) + + self.import_btn = QToolButton() + self.import_btn.setIcon(qta.icon("fa5s.plus", color=THEME.text_primary)) + self.import_btn.setToolTip("Import a folder as a roll") + self.import_btn.setFixedSize(20, 20) + self.import_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + import_menu = QMenu(self.import_btn) + import_menu.addAction("Import Folder as a Roll…").triggered.connect(self.prompt_import_folder) + import_menu.addAction("Import Subfolders as Rolls…").triggered.connect(self.prompt_import_subfolders) + self.import_btn.setMenu(import_menu) self.refresh_btn = QToolButton() self.refresh_btn.setIcon(qta.icon("fa5s.sync-alt", color=THEME.text_primary)) - self.refresh_btn.setToolTip("Re-read the folders from disk") + self.refresh_btn.setToolTip("Re-read every roll's frame count from disk") self.refresh_btn.setFixedSize(20, 20) - self.refresh_btn.clicked.connect(self._on_refresh) + self.refresh_btn.clicked.connect(self.reload) - header.addWidget(self.add_root_btn) + header.addWidget(self.import_btn) header.addWidget(self.refresh_btn) layout.addLayout(header) @@ -97,6 +92,10 @@ def _init_ui(self) -> None: self.tree.setColumnCount(2) self.tree.setHeaderHidden(True) self.tree.setUniformRowHeights(True) + # Every roll is a top-level item, never a child -- the branch/twisty gutter Qt + # reserves by default has nothing to show and only pushes the icon right. + self.tree.setRootIsDecorated(False) + self.tree.setIndentation(0) self.tree.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection) self.tree.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.tree.header().setStretchLastSection(False) @@ -106,11 +105,10 @@ def _init_ui(self) -> None: f"QTreeWidget::item:selected {{ background: {THEME.accent_primary}; color: {THEME.text_on_accent}; }}" f"QTreeWidget::item:hover:!selected {{ background: {THEME.surface_hover_faint}; }}" ) - self.tree.itemExpanded.connect(self._on_expanded) self.tree.itemDoubleClicked.connect(self._on_double_clicked) self.tree.itemSelectionChanged.connect(self._recolor_counts) self.tree.customContextMenuRequested.connect(self._show_context_menu) - # Scoped shortcuts rather than an event filter: a filter object that outlives the tree, or + # A scoped shortcut rather than an event filter: a filter object that outlives the tree, or # is collected before it, aborts Qt during teardown. for key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): shortcut = QShortcut(QKeySequence(key), self.tree) @@ -118,47 +116,58 @@ def _init_ui(self) -> None: shortcut.activated.connect(self.open_selection) layout.addWidget(self.tree, 1) - self.empty_label = hint_label("Add a folder to browse your library") + self.empty_label = hint_label("Import a folder to build your library") self.empty_label.setAlignment(Qt.AlignmentFlag.AlignCenter) layout.addWidget(self.empty_label) - # --- roots ------------------------------------------------------------- + # --- importing ----------------------------------------------------------- - def roots(self) -> list[str]: - saved = self.repo.get_global_setting("library_roots", []) or [] - return [p for p in saved if isinstance(p, str)] + def prompt_import_folder(self) -> bool: + """Import Folder as a Roll: recognize one folder and open it. - def primary_root(self) -> str: - return next((r for r in self.roots() if os.path.isdir(r)), "") - - def _save_roots(self, roots: list[str]) -> None: - self.repo.save_global_setting("library_roots", roots) - self.reload() - self.roots_changed.emit() - - def add_root(self, path: str = "") -> str: - """Add a library folder, asking for one when not given. Returns the path added.""" - if not path: - start = self.repo.get_global_setting("last_open_folder", "") or "" - path = QFileDialog.getExistingDirectory(self, "Choose your library folder", start) + Returns whether anything was imported, so the empty-state prompt (Library + button with nothing in the library yet) knows whether it can proceed. + """ + start = self.repo.get_global_setting("last_open_folder", "") or "" + path = QFileDialog.getExistingDirectory(self, "Import Folder as a Roll", start) if not path: - return "" - roots = self.roots() - if path not in roots: - self._save_roots([path, *roots]) - return path - - def remove_root(self, path: str) -> None: - self._save_roots([p for p in self.roots() if p != path]) - - def _on_refresh(self) -> None: + return False + self.repo.save_global_setting("last_open_folder", path) + images, _ = folder_counts(path) + if not images: + self.controller.set_status(f"No images directly in “{folder_label(path)}”", 4000) + return False + if not confirm_load_roll(self, self.repo, images, folder_label(path)): + return False + is_new = rolls.folder_roll_id_for_path(self.repo, path) is None + self.controller.open_library_folder(path) + self.reload() + self.rolls_changed.emit() + if is_new: + self.folder_roll_created.emit(path) + return True + + def prompt_import_subfolders(self) -> bool: + """Import Subfolders as Rolls: every immediate subfolder of a chosen parent + becomes its own roll, one level only, without opening any of them.""" + start = self.repo.get_global_setting("last_open_folder", "") or "" + parent = QFileDialog.getExistingDirectory(self, "Import Subfolders as Rolls", start) + if not parent: + return False + self.repo.save_global_setting("last_open_folder", parent) + roll_ids = self.controller.import_subfolders_as_rolls(parent) + if not roll_ids: + self.controller.set_status(f"No subfolders found in “{folder_label(parent)}”", 4000) + return False + self.controller.set_status(f"Imported {len(roll_ids)} roll{'s' if len(roll_ids) != 1 else ''}", 3000) self.reload() - self.roots_changed.emit() + self.rolls_changed.emit() + return True - # --- tree -------------------------------------------------------------- + # --- rolls ----------------------------------------------------------------- def set_sort(self, order: str, descending: bool) -> None: - """Order folders the way the film strip orders frames.""" + """Order rolls the way the film strip orders frames.""" if (order, descending) == (self._sort_order, self._sort_descending): return self._sort_order = order @@ -167,155 +176,132 @@ def set_sort(self, order: str, descending: bool) -> None: def _sorted(self, entries: list) -> list: if self._sort_order == "date": - return sorted(entries, key=lambda e: _entry_mtime(e), reverse=self._sort_descending) - return sorted(entries, key=lambda e: e.name.lower(), reverse=self._sort_descending) + return sorted(entries, key=lambda pair: pair[1].get("created_at", 0.0), reverse=self._sort_descending) + return sorted(entries, key=lambda pair: pair[1].get("name", "").casefold(), reverse=self._sort_descending) def reload(self) -> None: - expanded = self._expanded_paths() - selected = self._selected_paths() + selected = self._selected_roll_id() self.tree.clear() - roots = self.roots() - self.empty_label.setVisible(not roots) - for root in roots: - self.tree.addTopLevelItem(self._make_item(root, os.path.basename(root.rstrip(os.sep)) or root, is_root=True)) - self._restore(expanded, selected) - - def _make_item(self, path: str, label: str, is_root: bool = False, mtime: float = 0.0) -> QTreeWidgetItem: - images, subfolders = folder_counts(path) - item = QTreeWidgetItem([label, summarize_counts(images, subfolders)]) - item.setData(0, _PATH_ROLE, path) - item.setData(0, _IS_ROOT_ROLE, is_root) - item.setData(0, _MTIME_ROLE, mtime) - item.setIcon(0, qta.icon("fa5s.folder", color=THEME.text_secondary)) + entries = self._sorted(rolls.all_rolls_sorted(self.repo)) + self.empty_label.setVisible(not entries) + for roll_id, entry in entries: + self.tree.addTopLevelItem(self._make_item(roll_id, entry)) + if selected: + self._select_roll(selected) + + def _make_item(self, roll_id: str, entry: dict) -> QTreeWidgetItem: + is_folder = entry.get("kind") == "folder" + item = QTreeWidgetItem([entry.get("name", ""), summarize_counts(self._frame_count(entry), 0)]) + item.setData(0, _ROLL_ID_ROLE, roll_id) + item.setIcon(0, qta.icon("fa5s.folder", color=THEME.mode_c41 if is_folder else THEME.roll_virtual)) item.setForeground(1, QColor(THEME.text_muted)) - item.setToolTip(0, path) - if subfolders: - item.addChild(QTreeWidgetItem([_PLACEHOLDER, ""])) + item.setToolTip(0, entry.get("folder_path", "") if is_folder else "Built from a search or a hand-picked set of frames") return item - def _on_expanded(self, item: QTreeWidgetItem) -> None: - if item.childCount() != 1 or item.child(0).text(0) != _PLACEHOLDER: - return - item.takeChildren() - for entry in self._sorted(_subdirectories(item.data(0, _PATH_ROLE))): - item.addChild(self._make_item(entry.path, entry.name, mtime=_entry_mtime(entry))) + def _frame_count(self, entry: dict) -> int: + if entry.get("kind") == "folder": + images, _ = folder_counts(entry.get("folder_path", "")) + return images + len(entry.get("extra_paths") or ()) + return len(entry.get("member_paths") or ()) - def _walk(self, item: QTreeWidgetItem, visit) -> None: - visit(item) - for i in range(item.childCount()): - self._walk(item.child(i), visit) + def _selected_roll_id(self): + item = self.tree.currentItem() + return item.data(0, _ROLL_ID_ROLE) if item is not None else None - def _each_top_level(self, visit) -> None: - for i in range(self.tree.topLevelItemCount()): - self._walk(self.tree.topLevelItem(i), visit) - - def _expanded_paths(self) -> set: - found: set = set() - self._each_top_level(lambda item: item.isExpanded() and found.add(item.data(0, _PATH_ROLE))) - return found - - def _selected_paths(self) -> set: - return {item.data(0, _PATH_ROLE) for item in self.tree.selectedItems()} - - def _restore(self, expanded: set, selected: set) -> None: - if not expanded and not selected: - return - - def walk(item: QTreeWidgetItem) -> None: - if item.data(0, _PATH_ROLE) in expanded: - item.setExpanded(True) # populates through _on_expanded - if item.data(0, _PATH_ROLE) in selected: - item.setSelected(True) - for i in range(item.childCount()): - walk(item.child(i)) - - for i in range(self.tree.topLevelItemCount()): - walk(self.tree.topLevelItem(i)) - - def reveal(self, path: str) -> None: - """Select and expand a folder, scrolling it into view. - - Walks down from its root expanding as it goes — children only exist once their - parent has been expanded, so a deep folder cannot simply be searched for. - """ - target = os.path.normpath(path) + def _select_roll(self, roll_id) -> None: for i in range(self.tree.topLevelItemCount()): item = self.tree.topLevelItem(i) - root = os.path.normpath(item.data(0, _PATH_ROLE)) - if target != root and not target.startswith(root + os.sep): - continue - relative = os.path.relpath(target, root) - for part in [] if relative == "." else relative.split(os.sep): - item.setExpanded(True) # populates through _on_expanded - child = next((item.child(j) for j in range(item.childCount()) if item.child(j).text(0) == part), None) - if child is None: - break - item = child - self.tree.clearSelection() - item.setSelected(True) - self.tree.setCurrentItem(item) - item.setExpanded(True) - self.tree.scrollToItem(item) - return - - def select_parent(self) -> None: - """Move the selection one folder up — the tree's answer to "go up".""" - current = self.tree.currentItem() - parent = current.parent() if current is not None else None - if parent is None: - return - self.tree.clearSelection() - parent.setSelected(True) - self.tree.setCurrentItem(parent) + if item.data(0, _ROLL_ID_ROLE) == roll_id: + item.setSelected(True) + self.tree.setCurrentItem(item) + return def _recolor_counts(self) -> None: # A per-item brush is out of a stylesheet's reach, so the count column has to be repainted # by hand or it stays grey on the accent red. - self._each_top_level( - lambda item: item.setForeground(1, QColor(THEME.text_on_accent) if item.isSelected() else QColor(THEME.text_muted)) - ) + for i in range(self.tree.topLevelItemCount()): + item = self.tree.topLevelItem(i) + item.setForeground(1, QColor(THEME.text_on_accent) if item.isSelected() else QColor(THEME.text_muted)) # --- opening ----------------------------------------------------------- - def selected_paths(self) -> list[str]: - return [item.data(0, _PATH_ROLE) for item in self.tree.selectedItems() if item.data(0, _PATH_ROLE)] - - def _activate(self, item: QTreeWidgetItem) -> None: - """Open a row: the whole selection when the row is part of one, else just it.""" - path = item.data(0, _PATH_ROLE) - selected = self.selected_paths() - paths = selected if path in selected and len(selected) > 1 else [path] - self.folders_activated.emit(paths) - def _on_double_clicked(self, item: QTreeWidgetItem, _column: int) -> None: - self._activate(item) + self.controller.open_roll(item.data(0, _ROLL_ID_ROLE)) def open_selection(self) -> None: - """Enter: open every selected folder.""" - selected = self.selected_paths() - if selected: - self.folders_activated.emit(selected) + """Enter: open the current roll.""" + item = self.tree.currentItem() + if item is not None: + self.controller.open_roll(item.data(0, _ROLL_ID_ROLE)) + + def _selected_roll_items(self) -> list[tuple]: + return [(item.data(0, _ROLL_ID_ROLE), item.text(0)) for item in self.tree.selectedItems()] def _show_context_menu(self, pos) -> None: item = self.tree.itemAt(pos) menu = QMenu(self) + menu.setToolTipsVisible(True) if item is not None: - path = item.data(0, _PATH_ROLE) - selected = self.selected_paths() - paths = selected if path in selected and len(selected) > 1 else [path] - label = f"Open {len(paths)} folders" if len(paths) > 1 else "Open Folder" - menu.addAction(label).triggered.connect(lambda: self.folders_activated.emit(paths)) - menu.addAction("Add to Session").triggered.connect(lambda: self.folders_appended.emit(paths)) + roll_id = item.data(0, _ROLL_ID_ROLE) + selection = self._selected_roll_items() + if len(selection) > 1 and roll_id in dict(selection): + menu.addAction(f"Delete {len(selection)} Rolls…").triggered.connect(lambda: self._delete_rolls(selection)) + else: + name = item.text(0) + menu.addAction("Open").triggered.connect(lambda: self.controller.open_roll(roll_id)) + menu.addAction("Rename…").triggered.connect(lambda: self._rename_roll(roll_id, name)) + menu.addAction("Delete…").triggered.connect(lambda: self._delete_roll(roll_id, name)) menu.addSeparator() - if item.data(0, _IS_ROOT_ROLE): - menu.addAction("Remove from Library").triggered.connect(lambda: self.remove_root(path)) - menu.addAction("Add Library Folder…").triggered.connect(lambda: self.add_root()) - menu.addAction("Refresh").triggered.connect(self._on_refresh) + menu.addAction("Import Folder as a Roll…").triggered.connect(self.prompt_import_folder) + menu.addAction("Import Subfolders as Rolls…").triggered.connect(self.prompt_import_subfolders) menu.exec(self.tree.viewport().mapToGlobal(pos)) + def _rename_roll(self, roll_id: str, current_name: str) -> None: + entry = rolls.roll_for_id(self.repo, roll_id) + is_folder = bool(entry) and entry.get("kind") == "folder" + + if is_folder: + dlg = RenameRollDialog(current_name, self) + if dlg.exec() != QDialog.DialogCode.Accepted: + return + name, rename_folder = dlg.name(), dlg.rename_folder() + else: + name, ok = QInputDialog.getText(self, "Rename Roll", "Name:", text=current_name) + name, rename_folder = name.strip(), False + if not ok: + return + + if not name or (name == current_name and not rename_folder): + return + if not is_valid_preset_name(name): + QMessageBox.warning(self, "Roll Name", 'A roll name cannot contain / \\ : * ? " < > | or start or end with a dot.') + return -def _entry_mtime(entry) -> float: - try: - return entry.stat().st_mtime - except OSError: - return 0.0 + if rename_folder: + if not self.controller.request_rename_roll(roll_id, name, True): + QMessageBox.warning( + self, + "Rename Roll", + "Could not rename the folder on disk — check that no other folder already has that name, " + "and that you have permission to rename it here.", + ) + return + else: + rolls.rename_roll(self.repo, roll_id, name) + + self.reload() + self.rolls_changed.emit() + + def _delete_roll(self, roll_id: str, name: str) -> None: + if confirm_delete_named(self, "Roll", name, informative="This only forgets the roll — nothing on disk is touched."): + rolls.delete_roll(self.repo, roll_id) + self.reload() + self.rolls_changed.emit() + + def _delete_rolls(self, selection: list[tuple]) -> None: + names = [name for _roll_id, name in selection] + if confirm_delete_several(self, "Roll", names, informative="This only forgets the roll records — nothing on disk is touched."): + for roll_id, _name in selection: + rolls.delete_roll(self.repo, roll_id) + self.reload() + self.rolls_changed.emit() diff --git a/negpy/desktop/view/sidebar/metadata.py b/negpy/desktop/view/sidebar/metadata.py index 15a704af6..b139e1a96 100644 --- a/negpy/desktop/view/sidebar/metadata.py +++ b/negpy/desktop/view/sidebar/metadata.py @@ -3,11 +3,11 @@ from typing import Optional from PyQt6.QtCore import QTimer from PyQt6.QtWidgets import ( - QCheckBox, QComboBox, QHBoxLayout, QLabel, QLineEdit, + QScrollArea, QToolButton, QVBoxLayout, QWidget, @@ -21,12 +21,12 @@ ) from negpy.desktop.view.shortcut_registry import tooltip_with_shortcut from negpy.desktop.view.sidebar.base import BaseSidebar -from negpy.desktop.view.styles.templates import field_label, hint_label, wrap_tooltip +from negpy.desktop.view.styles.templates import field_label, hint_label, section_subheader, wrap_tooltip from negpy.desktop.view.styles.fonts import mono_font_family from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.collapsible import CollapsibleSection, make_section from negpy.desktop.view.widgets.description_fields_dialog import DescriptionFieldsDialog -from negpy.desktop.view.widgets.gear_library_dialog import GearLibraryDialog +from negpy.desktop.view.widgets.gear_catalog_dialog import resolve_other_gear_pick from negpy.desktop.view.widgets.location_picker_dialog import LocationPickerDialog from negpy.desktop.view.widgets.searchable_gear_combo import SearchableGearCombo from negpy.features.metadata.capture import ( @@ -41,7 +41,13 @@ place_summary, ) from negpy.features.metadata.exif_read import extract_scan_from_exif -from negpy.features.metadata.gear_logic import metadata_from_gear, metadata_from_process, metadata_from_scan_setup +from negpy.features.metadata.gear_logic import ( + OTHER_ID, + metadata_from_gear, + metadata_from_process, + metadata_from_scan_setup, + own_gear_entries, +) from negpy.features.metadata.gear_models import GearLibrary from negpy.features.metadata.models import ( DEFAULT_DESCRIPTION_FIELDS, @@ -56,10 +62,12 @@ ) from negpy.features.metadata.payload import build_metadata_payload from negpy.services.assets.gear import GearProfiles +from negpy.services.assets.gear_match import folder_name_for_active_context, match_gear_for_folder from negpy.services.assets.presets import MetadataPresets PUSH_PULL_OPTIONS = [PUSH_PULL_LABELS[v] for v in PUSH_PULL_VALUES] _LOAD_TOOLTIP = "Write the selected preset's fields onto this frame" + _CLEAR_TOOLTIPS = { "gear_clear_btn": ("Clear the camera, lens and film stock selections", "metadata_clear_gear"), "process_clear_btn": ( @@ -93,20 +101,10 @@ def _init_ui(self) -> None: self._exif_locked = {"exposure": True} self._description_fields: tuple[str, ...] = conf.description_fields or DEFAULT_DESCRIPTION_FIELDS - self.protect_check = QCheckBox("Protect original metadata") - self.protect_check.setChecked(conf.protect_original_metadata) - self.protect_check.setToolTip( - "When enabled, NegPy copies EXIF and XMP from the source file onto exports " - "without adding or changing metadata. Gear and process fields are ignored." - ) - self.layout.addWidget(self.protect_check) - - self.sync_check = QCheckBox("Sync custom metadata to all files in batch export") - self.sync_check.setChecked(conf.sync_to_batch) - self.sync_check.setToolTip( - "Batch and preset exports write this frame's capture, gear and process values to every file, instead of each file's own." - ) - self.layout.addWidget(self.sync_check) + self.metadata_title_label = section_subheader("Metadata") + self.layout.addWidget(self.metadata_title_label) + self.metadata_scope_hint = hint_label("Applies to the current frame only.") + self.layout.addWidget(self.metadata_scope_hint) self._metadata_controls = QWidget() controls = QVBoxLayout(self._metadata_controls) @@ -124,16 +122,12 @@ def _init_ui(self) -> None: load_row.addWidget(self.metadata_preset_load_btn) presets.addLayout(load_row) - self.manage_btn = self._labeled_action( - "fa5s.cog", " Manage…", "Save, edit and delete metadata presets, cameras, lenses and film stocks" - ) - presets.addWidget(self.manage_btn) self._refresh_metadata_presets() - controls.addWidget(self._card("Metadata Presets", "presets", preset_body, "fa5s.magic")) + controls.addWidget(self._card("Metadata Presets", "presets", preset_body, "fa5s.magic", collapsible=False)) # ── ANALOG GEAR ────────────────────────────────────────────────── gear_body, gear = self._card_body() - gear.addWidget(hint_label("Type in any field to search the gear library.")) + gear.addWidget(hint_label("Searches the gear you've declared as your own. Pick Other… for the full catalog.")) gear.addWidget(field_label("Camera")) self.camera_combo = SearchableGearCombo(placeholder="Search cameras…") @@ -150,8 +144,17 @@ def _init_ui(self) -> None: self.film_stock_combo.setToolTip("Film stock used for the original capture. Click and type to search.") gear.addWidget(self.film_stock_combo) + gear_actions_row = QHBoxLayout() + gear_actions_row.setSpacing(THEME.space_sm) self.gear_clear_btn = self._labeled_action("", "Clear", "Empty camera, lens and film stock") - gear.addWidget(self.gear_clear_btn) + gear_actions_row.addWidget(self.gear_clear_btn) + self.gear_infer_btn = self._labeled_action( + "fa5s.magic", + "Infer from folder name", + "Fill camera, film stock, ISO and capture date from this roll's folder name", + ) + gear_actions_row.addWidget(self.gear_infer_btn) + gear.addLayout(gear_actions_row) controls.addWidget(self._card("Analog Gear", "gear", gear_body, "fa5s.camera-retro")) # ── CAPTURE ────────────────────────────────────────────────────── @@ -302,9 +305,15 @@ def _init_ui(self) -> None: self.exposure_edit = self._make_exif_field("exposure", exp) controls.addWidget(self._card("Exposure", "exposure", exp_body, "fa5s.stopwatch")) + self._gear_combo_category = { + id(self.camera_combo): "cameras", + id(self.lens_combo): "lenses", + id(self.film_stock_combo): "film_stocks", + id(self.process_combo): "processes", + id(self.scan_setup_combo): "scan_setups", + } self._refresh_gear_combos() controls.addStretch() - self.layout.addWidget(self._metadata_controls, 1) # ── METADATA PREVIEW ───────────────────────────────────────────── self.preview_content = QWidget() @@ -328,8 +337,14 @@ def _init_ui(self) -> None: self.preview_empty = hint_label("Select gear or enter process metadata to see a preview.") preview_layout.addWidget(self.preview_empty) - self.preview_section = self._card("Metadata Preview", "preview", self.preview_content, "fa5s.eye") + # Preview pinned above the per-frame cards, which scroll in their own area below it -- + # same pattern as Frame's Analysis section pinned above its tabs. + self.preview_section = self._card("Metadata Preview", "preview", self.preview_content, "fa5s.eye", collapsible=False) self.layout.addWidget(self.preview_section) + self._metadata_scroll_area = QScrollArea() + self._metadata_scroll_area.setWidgetResizable(True) + self._metadata_scroll_area.setWidget(self._metadata_controls) + self.layout.addWidget(self._metadata_scroll_area, 1) # After every card: the tooltips it fills in span all of them. self.apply_shortcut_tooltips() @@ -342,8 +357,16 @@ def _card_body(self) -> tuple[QWidget, QVBoxLayout]: layout.setSpacing(THEME.space_md) return body, layout - def _card(self, title: str, key: str, content: QWidget, icon_name: str) -> CollapsibleSection: - return make_section(self.controller.session.repo, title, f"metadata_{key}", content, icon_name, default_expanded=True) + def _card(self, title: str, key: str, content: QWidget, icon_name: str, *, collapsible: bool = True) -> CollapsibleSection: + return make_section( + self.controller.session.repo, + title, + f"metadata_{key}", + content, + icon_name, + default_expanded=True, + collapsible=collapsible, + ) def _make_exif_field(self, key: str, layout: QVBoxLayout) -> QLineEdit: row = QHBoxLayout() @@ -369,7 +392,6 @@ def _make_exif_field(self, key: str, layout: QVBoxLayout) -> QLineEdit: def _set_metadata_controls_enabled(self, enabled: bool) -> None: self._metadata_controls.setEnabled(enabled) self.description_fields_btn.setEnabled(enabled) - self.sync_check.setEnabled(enabled) def _apply_lock_style(self, edit: QLineEdit, locked: bool) -> None: if locked: @@ -396,15 +418,14 @@ def _toggle_exif_lock(self, key: str, edit: QLineEdit, btn: QToolButton, checked self._mark_dirty() def _connect_signals(self) -> None: - self.protect_check.toggled.connect(self._on_protect_toggled) self.description_fields_btn.clicked.connect(self._open_description_fields) self.gear_clear_btn.clicked.connect(self._on_gear_clear) + self.gear_infer_btn.clicked.connect(self._on_gear_infer_from_folder) self.process_clear_btn.clicked.connect(self._on_process_clear) self.scan_clear_btn.clicked.connect(self._on_scanning_clear) self.camera_combo.selection_changed.connect(self._on_gear_changed) self.lens_combo.selection_changed.connect(self._on_gear_changed) self.film_stock_combo.selection_changed.connect(self._on_gear_changed) - self.manage_btn.clicked.connect(self._open_gear_library) self.metadata_preset_combo.selection_changed.connect(self._update_metadata_preset_tooltip) self.metadata_preset_load_btn.clicked.connect(self._on_metadata_preset_load) @@ -426,22 +447,10 @@ def _connect_signals(self) -> None: self.scanning_edit.textChanged.connect(self._on_scanning_edited) self.capture_roll_edit.textChanged.connect(self._mark_dirty) self.capture_frame_edit.textChanged.connect(self._mark_dirty) - self.sync_check.toggled.connect(self._mark_dirty) self.exposure_edit.textChanged.connect(self._mark_dirty) self.controller.session.file_selected.connect(self._on_file_selected) - def _on_protect_toggled(self, checked: bool) -> None: - self._set_metadata_controls_enabled(not checked) - self.update_config_section( - "metadata", - persist=True, - render=False, - readback_metrics=False, - protect_original_metadata=checked, - ) - self._schedule_preview() - def _open_description_fields(self) -> None: dlg = DescriptionFieldsDialog(self._description_fields, self) if dlg.exec() != dlg.DialogCode.Accepted: @@ -480,40 +489,27 @@ def should_refresh(combo: SearchableGearCombo) -> bool: seen[id(combo)] = key return True - if should_refresh(self.camera_combo): - self.camera_combo.set_gear_items( - library.cameras, - conf.camera_id or "", - lambda c: c.resolved_display_name, - ) - - if should_refresh(self.lens_combo): - self.lens_combo.set_gear_items( - library.lenses, - conf.lens_id or "", - lambda lens: lens.resolved_display_name, - ) - - if should_refresh(self.film_stock_combo): - self.film_stock_combo.set_gear_items( - library.film_stocks, - conf.film_stock_id or "", - lambda stock: stock.resolved_display_name, - ) - - if should_refresh(self.process_combo): - self.process_combo.set_gear_items( - library.processes, - conf.process_id or "", - lambda process: process.resolved_display_name, - ) - - if should_refresh(self.scan_setup_combo): - self.scan_setup_combo.set_gear_items( - library.scan_setups, - conf.scanning_id or "", - lambda setup: setup.resolved_display_name, - ) + combos = ( + (self.camera_combo, library.cameras, conf.camera_id or ""), + (self.lens_combo, library.lenses, conf.lens_id or ""), + (self.film_stock_combo, library.film_stocks, conf.film_stock_id or ""), + (self.process_combo, library.processes, conf.process_id or ""), + (self.scan_setup_combo, library.scan_setups, conf.scanning_id or ""), + ) + for combo, items, selected_id in combos: + if should_refresh(combo): + self._set_own_gear_items(combo, items, selected_id) + + def _set_own_gear_items(self, combo: SearchableGearCombo, items, selected_id: str) -> None: + """Personal gear only, plus the currently selected item even if it is a bundled + catalog pick made before this filter existed. Other… is the escape hatch back to + the full catalog, so the default search never returns gear the user doesn't own.""" + entries, search_text = own_gear_entries(items, selected_id) + combo.set_labeled_items( + entries, + selected_id, + search_fn=lambda label, item_id: search_text.get(item_id, label.casefold()), + ) def _gear_selected_id(self, combo: SearchableGearCombo, conf) -> str: return { @@ -525,15 +521,35 @@ def _gear_selected_id(self, combo: SearchableGearCombo, conf) -> str: }.get(id(combo)) or "" def _on_process_selected(self, *_args) -> None: + self._resolve_other_pick(self.process_combo) self._dirty = False self._apply_metadata_config(metadata_from_process(self.state.config.metadata, self._gear_library, self.process_combo.selected_id())) def _on_scan_setup_selected(self, *_args) -> None: + self._resolve_other_pick(self.scan_setup_combo) self._dirty = False self._apply_metadata_config( metadata_from_scan_setup(self.state.config.metadata, self._gear_library, self.scan_setup_combo.selected_id()) ) + def _resolve_other_pick(self, combo: SearchableGearCombo) -> None: + """Other… resolves to a real personal item before the caller applies the + selection: picking a catalog model clones it into the user's own gear, Add + Custom starts a blank one, cancelling reverts to what was selected before.""" + if combo.selected_id() != OTHER_ID: + return + previous = self._gear_selected_id(combo, self.state.config.metadata) + category = self._gear_combo_category[id(combo)] + library = self._gear_library + new_item = resolve_other_gear_pick(self, category, library) + if new_item is None: + combo.set_selected_id(previous) + return + setattr(library, category, [*getattr(library, category), new_item]) + GearProfiles.save_library(library) + self._refresh_gear_combos(force=True) + combo.set_selected_id(new_item.id) + def _on_dev_time_changed(self, text: str) -> None: self._flag_invalid(self.dev_time_edit, bool(text.strip()) and parse_dev_time(text) is None) self._on_process_edited() @@ -593,6 +609,32 @@ def _on_gear_clear(self) -> None: ) self._apply_metadata_config(cleared) + def _on_gear_infer_from_folder(self) -> None: + """Matches the active roll's folder name (or the current frame's own folder, + with no roll active) against your gear -- fills whichever of camera, film + stock, film ISO and capture date is not already set, same match Roll + Settings offers on import.""" + folder_name = folder_name_for_active_context(self.state, self.controller.session.repo) + if not folder_name: + self.controller.set_status("No folder to infer gear from", 2000, kind="warning") + return + detected = match_gear_for_folder(folder_name, self._gear_library) + meta = self.state.config.metadata + kwargs = {} + if not meta.camera_id and detected.camera_id: + kwargs["camera_id"] = detected.camera_id + if not meta.film_stock_id and detected.film_stock_id: + kwargs["film_stock_id"] = detected.film_stock_id + updated = metadata_from_gear(meta, self._gear_library, **kwargs) if kwargs else meta + if not updated.film_iso and detected.iso: + updated = replace(updated, film_iso=detected.iso) + if not updated.capture_date and detected.capture_date: + updated = replace(updated, capture_date=detected.capture_date) + if updated == meta: + self.controller.set_status(f"Nothing in “{folder_name}” matches your gear, ISO or date", 2000, kind="warning") + return + self._apply_metadata_config(updated) + def _clear_fields(self, fields: tuple[str, ...]) -> None: defaults = MetadataConfig() self._dirty = False @@ -606,15 +648,16 @@ def _on_scanning_clear(self) -> None: def _on_gear_changed(self, *_args) -> None: sender = self.sender() - kwargs: dict = {} if sender is self.camera_combo: - kwargs["camera_id"] = self.camera_combo.selected_id() + combo, field = self.camera_combo, "camera_id" elif sender is self.lens_combo: - kwargs["lens_id"] = self.lens_combo.selected_id() + combo, field = self.lens_combo, "lens_id" elif sender is self.film_stock_combo: - kwargs["film_stock_id"] = self.film_stock_combo.selected_id() + combo, field = self.film_stock_combo, "film_stock_id" else: return + self._resolve_other_pick(combo) + kwargs: dict = {field: combo.selected_id()} new_meta = metadata_from_gear( self.state.config.metadata, @@ -666,18 +709,6 @@ def _on_metadata_preset_load(self) -> None: merged = apply_selected_fields(preset_config(data), self.state.config, rows) self._apply_metadata_config(merged.metadata) - def _open_gear_library(self) -> None: - # The dialog holds the config it was given, so the debounce has to land first or a - # preset saved from "the current frame" misses the edit that is still pending. - self.update_timer.stop() - self._persist_all_metadata_settings() - dlg = GearLibraryDialog(self._gear_library, parent=self, current_config=self.state.config) - dlg.library_changed.connect(self._on_library_changed) - dlg.presets_changed.connect(self._refresh_metadata_presets) - if dlg.exec(): - self._on_library_changed() - self._refresh_metadata_presets() - def _on_library_changed(self) -> None: self._gear_library = GearProfiles.load_library() self._refresh_gear_combos() @@ -819,7 +850,6 @@ def _persist_all_metadata_settings(self) -> None: scanning=self.scanning_edit.text().strip(), capture_roll=self.capture_roll_edit.text().strip(), capture_frame=capture_frame, - sync_to_batch=self.sync_check.isChecked(), exposure_override=exposure_override, ) @@ -831,7 +861,6 @@ def sync_ui(self) -> None: self.block_signals(True) try: - self.protect_check.setChecked(conf.protect_original_metadata) self._set_metadata_controls_enabled(not conf.protect_original_metadata) self._refresh_gear_combos() @@ -852,7 +881,6 @@ def sync_ui(self) -> None: self.scanning_edit.setText(conf.scanning) self.capture_roll_edit.setText(conf.capture_roll) self.capture_frame_edit.setText("" if conf.capture_frame is None else str(conf.capture_frame)) - self.sync_check.setChecked(conf.sync_to_batch) self._description_fields = conf.description_fields or DEFAULT_DESCRIPTION_FIELDS if conf.exposure_override: @@ -925,7 +953,6 @@ def _preview_metadata_config(self): process_temperature_c=self._dev_temp_value(), scanning_id=self.scan_setup_combo.selected_id(), scanning=self.scanning_edit.text().strip(), - sync_to_batch=self.sync_check.isChecked(), exposure_override=exposure_override, description_fields=self._description_fields, ) diff --git a/negpy/desktop/view/sidebar/right_panel.py b/negpy/desktop/view/sidebar/right_panel.py index ad69fb767..5b1107f08 100644 --- a/negpy/desktop/view/sidebar/right_panel.py +++ b/negpy/desktop/view/sidebar/right_panel.py @@ -23,15 +23,29 @@ from negpy.desktop.view.styles.theme import THEME from negpy.desktop.view.widgets.charts import PhotometricCurveWidget, StepWedgeWidget, ZoneStripWidget from negpy.desktop.view.widgets.collapsible import make_section +from negpy.desktop.view.widgets.gear_library_panel import GearLibraryPanel from negpy.desktop.view.widgets.stats import DensitometerRow, NegativeStatsWidget, ZonePlacementRows from negpy.desktop.view.widgets.overflow_bar import OverflowBar +# ControlsPanel sections built into the Roll tab (_build_roll_page), not a Frame sub-tab -- +# reveal_section routes these to the Roll group instead of Frame's inner tab switcher. +_ROLL_SECTION_ATTRS = frozenset({"sensor_section", "demosaic_section", "process_section", "roll_section"}) + class RightPanel(QWidget): """ - Right sidebar panel: a sticky (collapsible) Analysis section pinned at the top, - above an icon-only tab switcher hosting the workflow control groups - (Setup / Tone / Color / Finish) plus Export / Metadata / Scan. + Right sidebar panel: a flat tab switcher across Roll / Frame / Metadata / Gear / + Export / Scan. Roll leads, since it's what you settle before working a frame: + Calibration and Demosaic decide how the rig's files decode, Normalization sets a + shared exposure baseline, Roll Analysis is its batch meter, Presets stores reusable + field sets -- none of it is a per-frame edit. Frame holds a sticky Analysis section + pinned above the per-image workflow control groups (Geometry / Tone / Color / + Finish), Favorites and History -- every tab that changes what the canvas shows for + the one loaded frame. Metadata pins its own Preview above its per-frame cards the + same way Frame pins Analysis. Gear pins its own Items/Presets switcher the same + way; Export and Scan are plain pages, with no pinned section. Export sits after + Gear, not Scan: every roll ends with an export, but few ever touch Scan at all -- + it captures new film, not something already in the session. """ def __init__(self, controller: AppController): @@ -48,7 +62,91 @@ def _init_ui(self) -> None: layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) - # Sticky Analysis section (collapsible, pinned at top) + def wrap_scroll(widget: QWidget) -> QScrollArea: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(widget) + return scroll + + frame_page = self._build_frame_page(wrap_scroll) + roll_page = self._build_roll_page() + + self.export_sidebar = ExportSidebar(self.controller) + self.metadata_sidebar = MetadataSidebar(self.controller) + self.gear_panel = GearLibraryPanel(current_config_fn=lambda: self.controller.state.config) + self.gear_panel.library_changed.connect(self.metadata_sidebar._on_library_changed) + self.gear_panel.presets_changed.connect(self.metadata_sidebar._refresh_metadata_presets) + + from negpy.desktop.view.sidebar.scan import ScanSidebar + from negpy.desktop.view.sidebar.scanlight import ScanlightSidebar + + self.scan_sidebar = ScanSidebar(self.controller) + self.scanlight_sidebar = ScanlightSidebar(self.controller) + self.scan_page = self._build_scan_page() + + # (key, icon_name, tooltip, content_widget) + group_specs = [ + ("roll", "mdi6.film", "Roll", roll_page), + ("frame", "fa5s.image", "Frame", frame_page), + ("metadata", "fa5s.tags", "Metadata", self.metadata_sidebar), + ("gear", "fa5s.toolbox", "Gear", self.gear_panel), + ("export", "fa5s.file-export", "Export", self.export_sidebar), + ("scan", "fa5s.camera-retro", "Scan", self.scan_page), + ] + + # Icon-only tab switcher; spills into a » menu when the panel is narrowed + self.group_switcher = OverflowBar(tile=True, height=38, min_item=36) + self.group_stack = QStackedWidget() + self.group_stack.setContentsMargins(0, 0, 0, 0) + + self._group_buttons: list[QPushButton] = [] + self._group_keys: list[str] = [] + self._group_icons: list[str] = [] + self._group_tooltips: list[str] = [] + self._active_group = 0 + self._scan_group_index = -1 + + for i, (key, icon_name, tooltip, content) in enumerate(group_specs): + btn = QPushButton() + btn.setObjectName("right_tab_btn") + btn.setIcon(qta.icon(icon_name, color=THEME.text_secondary)) + btn.setIconSize(QSize(18, 18)) + btn.setToolTip(tooltip) + btn.setCheckable(True) + btn.setFixedHeight(38) + btn.clicked.connect(lambda _checked=False, idx=i: self._switch_group(idx)) + self.group_switcher.add_button(btn, tooltip) + + # Frame, Metadata and Gear manage their own scrolling (a pinned section or subtab + # switcher above a scroll area); the other pages are one control column each, so + # the page itself needs it. + page = content if key in ("frame", "metadata", "gear") else wrap_scroll(content) + self.group_stack.addWidget(page) + self._group_buttons.append(btn) + self._group_keys.append(key) + self._group_icons.append(icon_name) + self._group_tooltips.append(tooltip) + if key == "scan": + self._scan_group_index = i + + layout.addWidget(self.group_switcher) + layout.addWidget(self.group_stack, 1) + + self.apply_shortcut_tooltips() + + repo = self.controller.session.repo + saved_group = repo.get_global_setting("right_panel_group", 0) + self._switch_group(saved_group if isinstance(saved_group, int) and 0 <= saved_group < len(self._group_buttons) else 0) + + def _build_frame_page(self, wrap_scroll) -> QWidget: + """Sticky Analysis section pinned above a second, inner tab switcher for the + per-image workflow control groups, Favorites and History -- everything that + changes what the canvas shows for the one loaded frame.""" + page = QWidget() + page_layout = QVBoxLayout(page) + page_layout.setContentsMargins(0, 0, 0, 0) + page_layout.setSpacing(0) + analysis_content = QWidget() analysis_layout = QVBoxLayout(analysis_content) analysis_layout.setContentsMargins(5, 5, 5, 5) @@ -78,32 +176,12 @@ def _init_ui(self) -> None: ) analysis_expanded = self.analysis_section.toggle_button.isChecked() - def wrap_scroll(widget: QWidget) -> QScrollArea: - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setWidget(widget) - return scroll - # Tab content widgets self.controls_panel = ControlsPanel(self.controller) self.favourites_sidebar = FavouritesSidebar(self.controller, self.controls_panel) - self.export_sidebar = ExportSidebar(self.controller) - self.metadata_sidebar = MetadataSidebar(self.controller) self.history_panel = HistoryPanel(self.controller) - from negpy.desktop.view.sidebar.scan import ScanSidebar - - self.scan_sidebar = ScanSidebar(self.controller) - - from negpy.desktop.view.sidebar.scanlight import ScanlightSidebar - - self.scanlight_sidebar = ScanlightSidebar(self.controller) - - # One "Scan" tab hosting both the SANE scanner and the RGB-Scan capture as collapsible - # sections, like the "Color: Lab, Toning" tab. - self.scan_page = self._build_scan_page() - - # Tab descriptors: the workflow control-group pages first, then Export, Metadata and Scan. + # Tab descriptors: the workflow control-group pages, then Favorites and History. # (key, icon_name, tooltip, content_widget, [section_attrs]) tab_specs = [ (page["key"], page["icon_name"], page["tooltip"], page["widget"], page["sections"]) for page in self.controls_panel.pages @@ -111,9 +189,6 @@ def wrap_scroll(widget: QWidget) -> QScrollArea: tab_specs += [ ("favourites", "fa5s.star", "Favorites", self.favourites_sidebar, []), ("history", "fa5s.history", "History", self.history_panel, []), - ("export", "fa5s.file-export", "Export", self.export_sidebar, []), - ("metadata", "fa5s.tags", "Metadata", self.metadata_sidebar, []), - ("scan", "fa5s.camera-retro", "Scan", self.scan_page, []), ] # Icon-only tab switcher; spills into a » menu when the panel is narrowed @@ -130,7 +205,6 @@ def wrap_scroll(widget: QWidget) -> QScrollArea: self._tab_sections: dict[int, list[str]] = {} self._tab_edited: list[bool] = [] self._active_index = 0 - self._scan_index = -1 for i, (key, icon_name, tooltip, content, section_attrs) in enumerate(tab_specs): btn = QPushButton() @@ -154,8 +228,6 @@ def wrap_scroll(widget: QWidget) -> QScrollArea: self._tab_sections[i] = section_attrs for attr in section_attrs: self._section_tab_index[attr] = i - if key == "scan": - self._scan_index = i # Tabs (switcher + stack) live in the bottom splitter pane tabs_container = QWidget() @@ -188,13 +260,45 @@ def wrap_scroll(widget: QWidget) -> QScrollArea: if not analysis_expanded: self._resize_splitter_for_analysis(False) - layout.addWidget(self.splitter, 1) - - self.apply_shortcut_tooltips() + page_layout.addWidget(self.splitter, 1) saved_tab = repo.get_global_setting("right_panel_tab", 0) self._switch_tab(saved_tab if isinstance(saved_tab, int) and 0 <= saved_tab < len(self._tab_buttons) else 0) + return page + + def _build_roll_page(self) -> QWidget: + """Facts the whole roll shares, not one frame's own edit: what rig scanned it and + how (Calibration, Demosaic), the roll's shared exposure baseline (Normalization), + Roll Analysis's batch meter, and reusable edit presets. Film mode leads, same as + it always has, since it decides which of the others even apply.""" + cp = self.controls_panel + page = QWidget() + page_layout = QVBoxLayout(page) + page_layout.setContentsMargins(0, 0, 0, 0) + page_layout.setSpacing(8) + page_layout.addWidget(cp.process_sidebar.mode_bar) + for section in (cp.sensor_section, cp.demosaic_section, cp.process_section, cp.roll_section, cp.presets_section): + page_layout.addWidget(section) + page_layout.addStretch(1) + return page + + def _build_scan_page(self) -> QWidget: + """The 'Scan' tab hosts two collapsible sections (like Frame's Color tab): the + SANE flatbed/film scanner on top, the RGB-Scan trichromatic capture below.""" + repo = self.controller.session.repo + self.scan_sane_section = make_section(repo, "Film Scanner", "scan_sane", self.scan_sidebar, "fa5s.camera-retro", False) + self.scan_rgb_section = make_section(repo, "Camera Scanning", "scan_rgb", self.scanlight_sidebar, "fa5s.camera", True) + + page = QWidget() + page_layout = QVBoxLayout(page) + page_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + page_layout.setContentsMargins(0, 0, 0, 0) + page_layout.setSpacing(8) + page_layout.addWidget(self.scan_sane_section) + page_layout.addWidget(self.scan_rgb_section) + return page + def show_analysis_help(self) -> None: from negpy.desktop.view.widgets.section_help_dialog import SectionHelpDialog @@ -214,28 +318,15 @@ def _resize_splitter_for_analysis(self, expanded: bool) -> None: top = max(1, self.analysis_section.sizeHint().height()) self.splitter.setSizes([top, max(0, total - top)]) - def _build_scan_page(self) -> QWidget: - """The 'Scan' tab hosts two collapsible sections (like Color's Lab / Toning): the - SANE flatbed/film scanner on top, the RGB-Scan trichromatic capture below.""" - repo = self.controller.session.repo - self.scan_sane_section = make_section(repo, "Film Scanner", "scan_sane", self.scan_sidebar, "fa5s.camera-retro", False) - self.scan_rgb_section = make_section(repo, "Camera Scanning", "scan_rgb", self.scanlight_sidebar, "fa5s.camera", True) - - page = QWidget() - page_layout = QVBoxLayout(page) - page_layout.setAlignment(Qt.AlignmentFlag.AlignTop) - page_layout.setContentsMargins(0, 0, 0, 0) - page_layout.setSpacing(8) - page_layout.addWidget(self.scan_sane_section) - page_layout.addWidget(self.scan_rgb_section) - return page - def apply_shortcut_tooltips(self) -> None: """Append the current keyboard shortcut (action id `tab_`) to each tab tooltip, - and pass the call on to the panels that own bound controls of their own.""" + and pass the call on to the panel that owns bound controls of its own.""" for btn, key, base in zip(self._tab_buttons, self._tab_keys, self._tab_tooltips): btn.setToolTip(tooltip_with_shortcut(base, f"tab_{key}")) + for btn, key, base in zip(self._group_buttons, self._group_keys, self._group_tooltips): + btn.setToolTip(tooltip_with_shortcut(base, f"tab_{key}")) self.metadata_sidebar.apply_shortcut_tooltips() + self.gear_panel.apply_shortcut_tooltips() def _connect_signals(self) -> None: self.controller.image_updated.connect(self._update_analysis) @@ -248,6 +339,8 @@ def _connect_signals(self) -> None: self.zone_placement.apply_clicked.connect(self.controller.apply_zone_placement) self.zone_placement.remove_clicked.connect(self.controller.remove_zone_pin) self.controller.tone_drag_changed.connect(self.curve_widget.set_active_param) + self.controls_panel.modified_synced.connect(self._sync_tab_edited) + # These two sync_ui calls scan gear/template files; never per drag tick. self._sync_debounce = QTimer() self._sync_debounce.setSingleShot(True) @@ -255,7 +348,6 @@ def _connect_signals(self) -> None: self._sync_debounce.timeout.connect(self.export_sidebar.sync_ui) self._sync_debounce.timeout.connect(self.metadata_sidebar.sync_ui) self.controller.config_updated.connect(self._sync_debounce.start) - self.controls_panel.modified_synced.connect(self._sync_tab_edited) def _sync_tab_edited(self) -> None: """Mark control-group tabs whose sections have edits (corner dot, like edited sliders).""" @@ -296,9 +388,18 @@ def _switch_tab(self, index: int) -> None: self.controller.set_active_tool(self._suspended_retouch_tool) self._suspended_retouch_tool = None + def _switch_group(self, index: int) -> None: + self._active_group = index + self.controller.session.repo.save_global_setting("right_panel_group", index) + self.group_stack.setCurrentIndex(index) + for i, btn in enumerate(self._group_buttons): + btn.setChecked(i == index) + btn.setIcon(qta.icon(self._group_icons[i], color="white" if i == index else THEME.text_secondary)) + self.group_switcher.set_pinned(index) + # Trigger device detection and a gating refresh when the Scan tab is selected. It hosts # both the SANE scanner and the RGB-Scan capture as collapsible sections. - if index == self._scan_index: + if index == self._scan_group_index: if hasattr(self.scan_sidebar, "on_activated"): self.scan_sidebar.on_activated() if hasattr(self.scanlight_sidebar, "on_activated"): @@ -306,14 +407,26 @@ def _switch_tab(self, index: int) -> None: def reveal_section(self, section_attr: str) -> None: """Switch to the tab containing the given ControlsPanel section.""" + if section_attr in _ROLL_SECTION_ATTRS: + self._switch_group(self._group_keys.index("roll")) + return idx = self._section_tab_index.get(section_attr) if idx is not None: + self._switch_group(self._group_keys.index("frame")) self._switch_tab(idx) def show_tab_by_key(self, key: str) -> None: + if key in self._group_keys: + self._switch_group(self._group_keys.index(key)) + return if key in self._tab_keys: + self._switch_group(self._group_keys.index("frame")) self._switch_tab(self._tab_keys.index(key)) + def show_gear_subtab_by_key(self, key: str) -> None: + self._switch_group(self._group_keys.index("gear")) + self.gear_panel.show_subtab_by_key(key) + def scroll_to(self, widget: QWidget) -> None: """Ensure *widget* is visible within its enclosing scroll area.""" parent = widget.parent() diff --git a/negpy/desktop/view/sidebar/session_panel.py b/negpy/desktop/view/sidebar/session_panel.py index 4cd929822..71b224a72 100644 --- a/negpy/desktop/view/sidebar/session_panel.py +++ b/negpy/desktop/view/sidebar/session_panel.py @@ -59,48 +59,36 @@ def _init_ui(self) -> None: layout.addWidget(self.file_browser, 1) # Hidden until the user has a library: an empty section is just clutter. - self.file_browser.library_section.setVisible(bool(self.controller.library_roots())) + self.file_browser.library_section.setVisible(self.controller.has_rolls()) def _connect_signals(self) -> None: - # The tree navigates and the film strip loads: browsing folders is free and stays with the - # tree, while the prompt and the frames belong to the strip. - self.library_tree.folders_activated.connect(self.file_browser.load_folders) - self.library_tree.folders_appended.connect(lambda paths: self.file_browser.load_folders(paths, add_to_session=True)) - self.library_tree.roots_changed.connect(self._on_roots_changed) + self.library_tree.rolls_changed.connect(self._on_rolls_changed) self.file_browser.library_requested.connect(self.show_library) - self.file_browser.browse_requested.connect(self.library_tree.reveal) self.file_browser.sort_changed.connect(lambda: self.library_tree.set_sort(*self.file_browser.sort_choice())) self.controller.library_cleared.connect(self._on_library_cleared) def show_library(self, ask_if_unset: bool = True) -> None: - """Reveal the library's primary folder in the tree, asking for one if unset. + """Reveal the library section, importing a first roll when there isn't one yet. The panel's resting state: with nothing loaded there is nothing else to show, and a list of rolls beats a blank sheet. """ - primary = self.library_tree.primary_root() - if not primary: - if not ask_if_unset or not self.library_tree.add_root(): + if not self.controller.has_rolls(): + if not ask_if_unset or not self.library_tree.prompt_import_folder(): return - primary = self.library_tree.primary_root() self.file_browser.library_section.setVisible(True) self.file_browser.library_section.expand() - self.library_tree.reveal(primary) - def browse_parent(self) -> None: - """Alt+Up: move the tree's selection one folder up.""" - self.library_tree.select_parent() - - def _on_roots_changed(self) -> None: - # Folders moved or a root was added, so the cached walk describes a tree that no longer - # exists. - self.library_tree.reload() + def _on_rolls_changed(self) -> None: + # A roll was imported, renamed or deleted, so the cached search walk describes a + # library that no longer exists. self.controller.invalidate_library_walk() - if self.controller.library_roots(): + if self.controller.has_rolls(): self.file_browser.library_section.setVisible(True) def _on_library_cleared(self) -> None: - self._on_roots_changed() + self.library_tree.reload() + self.controller.invalidate_library_walk() self.file_browser.library_section.setVisible(False) def toggle_library_tree(self) -> None: diff --git a/negpy/desktop/view/styles/modern_dark.qss b/negpy/desktop/view/styles/modern_dark.qss index 94d0c094e..88ac16906 100644 --- a/negpy/desktop/view/styles/modern_dark.qss +++ b/negpy/desktop/view/styles/modern_dark.qss @@ -428,6 +428,11 @@ QPushButton#collapsible_reset_btn:hover { background: @bg_selected; border-radius: 3px; } +QPushButton#collapsible_reset_btn::menu-indicator { + /* An actions-menu button in this row (set_actions_menu) reads as one more plain icon, + like its info/reset siblings, not a split button. */ + width: 0px; +} QFrame#toolbar_container { background-color: @bg_dark; diff --git a/negpy/desktop/view/styles/theme.py b/negpy/desktop/view/styles/theme.py index ef197c8a4..4753dde89 100644 --- a/negpy/desktop/view/styles/theme.py +++ b/negpy/desktop/view/styles/theme.py @@ -114,6 +114,12 @@ class ThemeConfig: mode_bw: str = "#8C8C8C" mode_e6: str = "#4FB0D8" + # Library tree roll-kind icons: a folder roll shares the C-41 chip's amber; a virtual + # roll (built from a search or hand-picked set, no folder behind it) shares + # channel_red_text's coral -- vivid rather than accent_primary/error's darker, + # more saturated reds, which both carry their own meaning already. + roll_virtual: str = "#FF5A5A" + # Canvas background swatches canvas_bg_black: str = "#050505" canvas_bg_dark_grey: str = "#1C1C1C" diff --git a/negpy/desktop/view/widgets/collapsible.py b/negpy/desktop/view/widgets/collapsible.py index b067a786d..f9c0db3f5 100644 --- a/negpy/desktop/view/widgets/collapsible.py +++ b/negpy/desktop/view/widgets/collapsible.py @@ -3,6 +3,7 @@ QWidget, QVBoxLayout, QCheckBox, + QMenu, QPushButton, QFrame, QHBoxLayout, @@ -33,19 +34,24 @@ def __init__( background_widget: Optional[QWidget] = None, info: bool = False, select: bool = False, + collapsible: bool = True, parent=None, ): super().__init__(parent) self._title_text = title + self.collapsible = collapsible + if not collapsible: + expanded = True self.main_layout = QVBoxLayout(self) self.main_layout.setContentsMargins(0, 0, 0, 0) self.main_layout.setSpacing(0) self.toggle_button = QPushButton() - self.toggle_button.setCheckable(True) - self.toggle_button.setChecked(expanded) - self.toggle_button.setCursor(Qt.CursorShape.PointingHandCursor) + if collapsible: + self.toggle_button.setCheckable(True) + self.toggle_button.setChecked(expanded) + self.toggle_button.setCursor(Qt.CursorShape.PointingHandCursor) self.toggle_button.setFixedHeight(36) # Styled by the QPushButton#collapsible_header rules in modern_dark.qss. overlay="true" @@ -56,6 +62,9 @@ def __init__( btn_layout = QHBoxLayout(self.toggle_button) btn_layout.setContentsMargins(THEME.space_xl, 8, THEME.space_xl, 8) btn_layout.setSpacing(10) + # Kept typed (toggle_button.layout() widens to QLayout | None), for set_actions_menu + # to insert into later. + self._header_row = btn_layout # Nested in the header button like reset_btn, so ticking the section does not also # collapse it. Tristate is for display only: a click always resolves to all or none. @@ -106,10 +115,16 @@ def __init__( self.reset_btn.clicked.connect(self._on_reset_clicked) btn_layout.addWidget(self.reset_btn) - self.chevron_label = QLabel() - self.chevron_label.setStyleSheet("background: transparent;") - self._update_chevron(expanded) - btn_layout.addWidget(self.chevron_label) + # Lazily built by set_actions_menu(): most sections have nothing that belongs + # here, so no button exists until one asks for it. + self.actions_btn: Optional[QPushButton] = None + + self.chevron_label: Optional[QLabel] = None + if collapsible: + self.chevron_label = QLabel() + self.chevron_label.setStyleSheet("background: transparent;") + self._update_chevron(expanded) + btn_layout.addWidget(self.chevron_label) if background_widget: background_widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents) @@ -133,7 +148,8 @@ def __init__( self.main_layout.addWidget(self.content_area) - self.toggle_button.toggled.connect(self._on_toggle) + if collapsible: + self.toggle_button.toggled.connect(self._on_toggle) def set_content(self, widget: QWidget) -> None: # Plain QWidget content is painted #0D0D0D by the global `QWidget {}` QSS rule, covering @@ -143,6 +159,8 @@ def set_content(self, widget: QWidget) -> None: self.content_layout.addWidget(widget) def _update_chevron(self, expanded: bool) -> None: + if self.chevron_label is None: + return if expanded: self.chevron_label.setPixmap(qta.icon("fa5s.chevron-down", color=THEME.text_secondary).pixmap(12, 12)) else: @@ -188,9 +206,23 @@ def _on_toggle(self, checked: bool) -> None: self.expanded_changed.emit(checked) def expand(self) -> None: - if not self.toggle_button.isChecked(): + if self.collapsible and not self.toggle_button.isChecked(): self.toggle_button.setChecked(True) + def set_actions_menu(self, menu: QMenu, tooltip: str) -> None: + """An always-visible header menu button, for section-level housekeeping that + is not a settings reset (reset_btn) -- Film Strip's New Roll, for one.""" + if self.actions_btn is None: + self.actions_btn = QPushButton() + self.actions_btn.setIcon(qta.icon("fa5s.ellipsis-v", color=THEME.text_muted)) + self.actions_btn.setFixedSize(20, 20) + self.actions_btn.setIconSize(QSize(10, 10)) + self.actions_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.actions_btn.setObjectName("collapsible_reset_btn") + self._header_row.insertWidget(self._header_row.count() - 1, self.actions_btn) + self.actions_btn.setToolTip(tooltip) + self.actions_btn.setMenu(menu) + def make_section( repo, @@ -200,24 +232,31 @@ def make_section( icon_name: str, default_expanded: bool = False, background_widget: Optional[QWidget] = None, + collapsible: bool = True, ) -> CollapsibleSection: """The one way a sidebar builds a section: persisted under section_expanded_{key}, and the ⓘ guide present exactly when docs/USER_GUIDE.md carries a `panel:{key}` marker. The help - dialog is parented to the section, so it centres on the window the section is in.""" + dialog is parented to the section, so it centres on the window the section is in. + collapsible=False always expands and never reads or writes the persisted setting.""" from negpy.desktop.view.widgets.section_help_dialog import SectionHelpDialog, has_guide - setting = f"section_expanded_{key}" - persisted = repo.get_global_setting(setting) - expanded = default_expanded if persisted is None else bool(persisted) + if collapsible: + setting = f"section_expanded_{key}" + persisted = repo.get_global_setting(setting) + expanded = default_expanded if persisted is None else bool(persisted) + else: + expanded = True section = CollapsibleSection( title, expanded=expanded, icon=qta.icon(icon_name, color=THEME.text_hint), background_widget=background_widget, info=has_guide(key), + collapsible=collapsible, ) section.set_content(content) - section.expanded_changed.connect(lambda checked: repo.save_global_setting(setting, checked)) + if collapsible: + section.expanded_changed.connect(lambda checked: repo.save_global_setting(setting, checked)) if section.info_btn: section.info_requested.connect(lambda: SectionHelpDialog(key, title, section).exec()) return section diff --git a/negpy/desktop/view/widgets/database_dialog.py b/negpy/desktop/view/widgets/database_dialog.py index c4da11222..68ea55822 100644 --- a/negpy/desktop/view/widgets/database_dialog.py +++ b/negpy/desktop/view/widgets/database_dialog.py @@ -16,6 +16,7 @@ from negpy.desktop.view.styles.templates import pin_dialog_default from negpy.desktop.view.styles.theme import THEME from negpy.kernel.system.text import human_bytes +from negpy.services.assets.rolls import ROLLS_KEY, saved_rolls # (stat key, display label). The order is the display order, and a separator sits between # the per-image group and the reusable-tooling group. @@ -28,7 +29,7 @@ _TOOLING_ROWS = ( ("normalization_rolls", "Normalization rolls"), ("export_presets", "Export presets"), - ("library_roots", "Library folders"), + ("library_rolls", "Library rolls"), ("app_preferences", "App preferences"), ) @@ -164,7 +165,7 @@ def _refresh(self) -> None: self._size_label.setText("Could not read the database.") return stats["thumbnails"] = thumb_count - stats["library_roots"] = len(self.repo.get_global_setting("library_roots", []) or []) + stats["library_rolls"] = len(saved_rolls(self.repo)) for key, lbl in self._value_labels.items(): lbl.setText(f"{stats.get(key, 0):,}") db_bytes = stats.get("edits_db_bytes", 0) + stats.get("settings_db_bytes", 0) @@ -177,7 +178,7 @@ def _update_enabled(self, stats: dict) -> None: self.clear_edits_btn.setEnabled(edits > 0) self.reset_all_btn.setEnabled(total > 0) self.clear_thumbs_btn.setEnabled(stats.get("thumbnails", 0) > 0) - self.clear_library_btn.setEnabled(stats.get("library_roots", 0) > 0) + self.clear_library_btn.setEnabled(stats.get("library_rolls", 0) > 0) def _confirm(self, title: str, text: str, ok_label: str, informative: str = "This cannot be undone.") -> bool: box = QMessageBox(self) @@ -222,14 +223,15 @@ def _on_clear_thumbnails(self) -> None: def _on_clear_library(self) -> None: if not self._confirm( "Clear Library", - "Forget the folders your library points at?\n\n" - "Only the list of folders is cleared — the folders, your images and their edits are untouched.", + "Forget every roll in your library?\n\n" + "Only the roll records are cleared — the folders, your images and their edits are untouched.", "Clear Library", - "You can point the library at a folder again at any time.", + "You can import a folder as a roll again at any time.", ): return try: self.repo.save_global_setting("library_roots", []) + self.repo.save_global_setting(ROLLS_KEY, {}) except Exception as exc: QMessageBox.critical(self, "Clear Failed", f"Could not clear the library:\n{exc}") self.controller.library_cleared.emit() diff --git a/negpy/desktop/view/widgets/gear_catalog_dialog.py b/negpy/desktop/view/widgets/gear_catalog_dialog.py new file mode 100644 index 000000000..6622965c5 --- /dev/null +++ b/negpy/desktop/view/widgets/gear_catalog_dialog.py @@ -0,0 +1,98 @@ +"""Picker for the "+" flow on a Gear Library category: pick a built-in model to make +personal, or fall back to a custom entry.""" + +from __future__ import annotations + +from typing import Callable, Optional, Sequence + +from PyQt6.QtWidgets import QDialog, QHBoxLayout, QPushButton, QVBoxLayout + +from negpy.desktop.view.styles.templates import field_label, hint_label, pin_dialog_default +from negpy.desktop.view.styles.theme import THEME +from negpy.desktop.view.widgets.searchable_gear_combo import SearchableGearCombo +from negpy.features.metadata.gear_logic import ( + CATEGORY_SEARCH_PLACEHOLDER, + CATEGORY_SINGULAR, + GearItem, + blank_gear_item, + clone_into_personal, +) +from negpy.features.metadata.gear_models import GearLibrary + + +class GearCatalogDialog(QDialog): + """Search the shipped catalog for one category. Accepting with a pick returns its id + (the caller clones it into a personal copy); Add Custom accepts with no pick, asking + for a blank entry instead.""" + + def __init__(self, parent, singular: str, catalog: Sequence, label_fn: Callable, placeholder: str): + super().__init__(parent) + self._custom = False + self.setWindowTitle(f"Add {singular}") + self.setMinimumWidth(420) + + root = QVBoxLayout(self) + root.setContentsMargins(THEME.space_2xl, THEME.space_2xl, THEME.space_2xl, THEME.space_2xl) + root.setSpacing(THEME.space_xl) + + root.addWidget(field_label(singular)) + self.combo = SearchableGearCombo(placeholder=placeholder) + self.combo.set_gear_items(catalog, "", label_fn) + self.combo.selection_changed.connect(self._update_add_enabled) + root.addWidget(self.combo) + root.addWidget(hint_label(f"Pick the {singular.lower()} you own from the built-in list, or add a custom one.")) + + root.addLayout(self._build_footer()) + self._update_add_enabled() + + def _build_footer(self) -> QHBoxLayout: + row = QHBoxLayout() + custom_btn = QPushButton("Add Custom") + custom_btn.clicked.connect(self._pick_custom) + row.addWidget(custom_btn) + row.addStretch() + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + self.add_btn = QPushButton("Add") + self.add_btn.clicked.connect(self.accept) + row.addWidget(cancel_btn) + row.addWidget(self.add_btn) + pin_dialog_default(self.add_btn, cancel_btn, custom_btn) + return row + + def _pick_custom(self) -> None: + self._custom = True + self.accept() + + def _update_add_enabled(self, *_args) -> None: + self.add_btn.setEnabled(bool(self.combo.selected_id())) + + def wants_custom(self) -> bool: + return self._custom + + def selected_id(self) -> str: + return "" if self._custom else self.combo.selected_id() + + +def resolve_other_gear_pick(parent, category: str, library: GearLibrary) -> Optional[GearItem]: + """The Other… row's flow, shared by every gear combo that defaults to personal gear: + search the shipped catalog for this category, clone a pick into a personal item, or + start a blank custom one. Returns the item to add to the library, or None if + cancelled -- the caller still owns saving it and refreshing its own combo.""" + items = getattr(library, category) + catalog = [item for item in items if item.is_bundled] + if not catalog: + return blank_gear_item(category) + dlg = GearCatalogDialog( + parent, + CATEGORY_SINGULAR[category], + catalog, + lambda item: item.resolved_display_name, + CATEGORY_SEARCH_PLACEHOLDER[category], + ) + if dlg.exec() != QDialog.DialogCode.Accepted: + return None + if dlg.wants_custom(): + return blank_gear_item(category) + picked = next((item for item in catalog if item.id == dlg.selected_id()), None) + return clone_into_personal(picked) if picked is not None else None diff --git a/negpy/desktop/view/widgets/gear_library_dialog.py b/negpy/desktop/view/widgets/gear_library_panel.py similarity index 58% rename from negpy/desktop/view/widgets/gear_library_dialog.py rename to negpy/desktop/view/widgets/gear_library_panel.py index 74c56bb9a..14e2d9c4b 100644 --- a/negpy/desktop/view/widgets/gear_library_dialog.py +++ b/negpy/desktop/view/widgets/gear_library_panel.py @@ -1,16 +1,22 @@ -"""Modal dialog for managing the analog gear library.""" +"""Panel for managing the analog gear library -- a persistent tab, not a dialog: the +library is reference data you build up over time, reached exactly as often as Export +or Metadata, not something opened, changed once and dismissed. + +Two subtabs: Items (physical gear) and Presets (saved metadata field sets). They share +one GearLibrary but otherwise diverge -- Items has categories and a bundled/personal +Catalog toggle that a preset has no equivalent for -- so each gets its own widget.""" from __future__ import annotations from dataclasses import replace +from typing import Callable, Optional import qtawesome as qta -from PyQt6.QtCore import pyqtSignal +from PyQt6.QtCore import QSize, pyqtSignal from PyQt6.QtWidgets import ( QComboBox, QDialog, QDoubleSpinBox, - QFormLayout, QHBoxLayout, QLabel, QLineEdit, @@ -18,7 +24,9 @@ QListWidgetItem, QMessageBox, QPushButton, + QScrollArea, QSpinBox, + QStackedWidget, QVBoxLayout, QWidget, ) @@ -32,14 +40,24 @@ selected_flat_dict, ) from negpy.desktop.view.confirm import confirm_delete_named -from negpy.desktop.view.styles.templates import dialog_pane_qss, field_label, hint_label, pane_header_qss, pin_dialog_default +from negpy.desktop.view.sidebar.base import install_wheel_guards +from negpy.desktop.view.shortcut_registry import tooltip_with_shortcut +from negpy.desktop.view.styles.templates import field_label, hint_label, icon_button, section_subheader, tool_toggle, wrap_tooltip from negpy.desktop.view.styles.theme import THEME +from negpy.desktop.view.widgets.gear_catalog_dialog import GearCatalogDialog, resolve_other_gear_pick from negpy.desktop.view.widgets.granular_settings_dialog import GranularSettingsDialog +from negpy.desktop.view.widgets.overflow_bar import OverflowBar +from negpy.domain.models import WorkspaceConfig from negpy.features.metadata.gear_logic import ( + CATEGORY_SINGULAR, + OTHER_ID, + blank_gear_item, + clone_into_personal, matches_gear_filter, metadata_from_gear, metadata_from_process, metadata_from_scan_setup, + own_gear_entries, ) from negpy.features.metadata.capture import DEV_TIME_HINT, format_dev_time, format_temperature, parse_dev_time, parse_temperature from negpy.features.metadata.gear_models import ( @@ -63,12 +81,15 @@ ("film_stocks", "Film Stocks"), ("processes", "Process"), ("scan_setups", "Scanning"), - ("metadata_presets", "Presets"), ] -# Metadata presets are files of stored values, not library records: the form pane -# shows what one holds and the field picker edits it. -_PRESETS = "metadata_presets" +_CATEGORY_ICONS: dict[str, str] = { + "cameras": "fa5s.camera", + "lenses": "mdi6.camera-iris", + "film_stocks": "fa5s.film", + "processes": "fa5s.flask", + "scan_setups": "mdi6.scanner", +} _CATEGORY_FIELDS: dict[str, frozenset[str]] = { "cameras": frozenset({"display_name", "make", "model", "notes"}), @@ -76,7 +97,6 @@ "film_stocks": frozenset({"display_name", "manufacturer", "stock_name", "iso", "format", "color_type", "notes"}), "processes": frozenset({"display_name", "developer", "dilution", "push_pull", "dev_time", "dev_temp", "notes"}), "scan_setups": frozenset({"display_name", "scanning", "notes"}), - _PRESETS: frozenset(), } _CATEGORY_SEARCH_PLACEHOLDER = { @@ -85,9 +105,26 @@ "film_stocks": "Search film stocks…", "processes": "Search processes…", "scan_setups": "Search scan setups…", - _PRESETS: "Search presets…", } +_CATEGORY_SINGULAR = CATEGORY_SINGULAR + +_CATEGORY_PLURAL_NOUN = { + "cameras": "cameras", + "lenses": "lenses", + "film_stocks": "film stocks", + "processes": "processes", + "scan_setups": "scan setups", +} + +_ADD_TOOLTIPS = { + key: f"Add a {singular.lower()} you own — pick one from the built-in list, or enter your own" + for key, singular in _CATEGORY_SINGULAR.items() +} + +_DELETE_TOOLTIPS = {key: f"Remove this {singular.lower()} from your gear" for key, singular in _CATEGORY_SINGULAR.items()} +_DELETE_BUNDLED_TOOLTIP = "Built-in gear can't be removed — duplicate it to make your own copy" + _PRESET_ROW_WIDGETS: dict[str, tuple[str, ...]] = { "metadata.camera_id": ("camera", "lens", "film_stock", "format", "format_other"), @@ -102,101 +139,76 @@ def _push_pull_index(value: int) -> int: return PUSH_PULL_VALUES.index(value) if value in PUSH_PULL_VALUES else PUSH_PULL_VALUES.index(0) -class GearLibraryDialog(QDialog): +class GearItemsPanel(QWidget): + """Cameras, lenses, film stocks, processes and scan setups: one searchable, + user-extendable list per category, shared by Roll Settings, Metadata and every + other picker in the app that offers gear. The item list defaults to what the user + has added; the Catalog toggle brings the shipped reference models back into view.""" + library_changed = pyqtSignal() - presets_changed = pyqtSignal() - def __init__(self, library: GearLibrary | None = None, parent=None, current_config=None): + def __init__(self, library: GearLibrary, parent=None): super().__init__(parent) - self._library = library or GearProfiles.load_library() - self._current_config = current_config + self._library = library self._category = "cameras" self._selected_idx = -1 self._list_items: list = [] self._updating = False + # Off by default: the list shows personal gear only until this is switched on. + self._show_bundled = False - self.setWindowTitle("Library") - self.resize(820, 560) self._init_ui() self._select_category("cameras") - pin_dialog_default(self._close_btn, scope=self) def library(self) -> GearLibrary: return self._library def _init_ui(self) -> None: - root = QHBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) - - # Category list - left = QWidget() - left.setFixedWidth(140) - left.setStyleSheet(dialog_pane_qss()) - left_layout = QVBoxLayout(left) - left_layout.setContentsMargins(8, 8, 8, 8) - - cat_label = QLabel("LIBRARY") - cat_label.setStyleSheet(pane_header_qss()) - left_layout.addWidget(cat_label) - - self.category_list = QListWidget() + root = QVBoxLayout(self) + root.setContentsMargins(THEME.space_xl, 0, THEME.space_xl, 5) + root.setSpacing(THEME.space_lg) + + root.addWidget(section_subheader("My Gear")) + root.addWidget(hint_label("Cameras, lenses, film stocks and processes you own. Catalog shows the full shipped list.")) + + root.addWidget(field_label("Category")) + self.category_list = QComboBox() for key, label in _CATEGORIES: - self.category_list.addItem(QListWidgetItem(label)) - self.category_list.setProperty("keys", [k for k, _ in _CATEGORIES]) - self.category_list.currentRowChanged.connect(self._on_category_changed) - left_layout.addWidget(self.category_list) - root.addWidget(left) - - # Item list - mid = QWidget() - mid.setFixedWidth(220) - mid.setStyleSheet(dialog_pane_qss()) - mid_layout = QVBoxLayout(mid) - mid_layout.setContentsMargins(8, 8, 8, 8) - - self.items_label = QLabel("ITEMS") - self.items_label.setStyleSheet(pane_header_qss()) - mid_layout.addWidget(self.items_label) + self.category_list.addItem(qta.icon(_CATEGORY_ICONS[key], color=THEME.text_primary), label, key) + root.addWidget(self.category_list) self.item_search = QLineEdit() self.item_search.setPlaceholderText("Search cameras…") self.item_search.textChanged.connect(self._on_item_search_changed) - mid_layout.addWidget(self.item_search) + root.addWidget(self.item_search) self.item_list = QListWidget() + self.item_list.setMaximumHeight(160) self.item_list.currentRowChanged.connect(self._on_item_changed) - mid_layout.addWidget(self.item_list) + root.addWidget(self.item_list) + + self.empty_hint = hint_label() + self.empty_hint.setVisible(False) + root.addWidget(self.empty_hint) btn_row = QHBoxLayout() - self.add_btn = QPushButton() - self.add_btn.setIcon(qta.icon("fa5s.plus", color=THEME.text_primary)) - self.add_btn.setToolTip("Add item") + btn_row.setSpacing(THEME.space_sm) + self.add_btn = icon_button("fa5s.plus", "Add item") self.add_btn.clicked.connect(self._add_item) - self.dup_btn = QPushButton() - self.dup_btn.setIcon(qta.icon("fa5s.copy", color=THEME.text_primary)) - self.dup_btn.setToolTip("Duplicate") + self.dup_btn = icon_button("fa5s.copy", "Duplicate") self.dup_btn.clicked.connect(self._duplicate_item) - self.edit_btn = QPushButton() - self.edit_btn.setIcon(qta.icon("fa5s.pen", color=THEME.text_primary)) - self.edit_btn.setToolTip("Rename the preset, or change which fields it stores") - self.edit_btn.clicked.connect(self._edit_preset) - self.del_btn = QPushButton() - self.del_btn.setIcon(qta.icon("fa5s.trash-alt", color=THEME.text_primary)) - self.del_btn.setToolTip("Delete") + self.del_btn = icon_button("fa5s.trash-alt", "Delete") self.del_btn.clicked.connect(self._delete_item) - for b in (self.add_btn, self.dup_btn, self.edit_btn, self.del_btn): - b.setFixedWidth(36) + for b in (self.add_btn, self.dup_btn, self.del_btn): btn_row.addWidget(b) btn_row.addStretch() - mid_layout.addLayout(btn_row) - - root.addWidget(mid) + self.show_catalog_btn = tool_toggle("fa5s.list-ul", "Catalog", "Show the built-in catalog alongside your own gear") + self.show_catalog_btn.toggled.connect(self._on_show_catalog_toggled) + btn_row.addWidget(self.show_catalog_btn) + root.addLayout(btn_row) - # Form: a single layout, with rows shown and hidden per category, never removeRow. - right = QWidget() - right_layout = QVBoxLayout(right) - right_layout.setContentsMargins(16, 16, 16, 16) + # Form: a single layout, with rows shown and hidden per category, never removed. + root.addWidget(section_subheader("DETAILS")) self.display_name_edit = QLineEdit() self.make_edit = QLineEdit() @@ -253,69 +265,452 @@ def _init_ui(self) -> None: self.color_combo.currentIndexChanged.connect(self._on_form_changed) self.push_pull_combo.currentIndexChanged.connect(self._on_form_changed) - self.form_panel = QWidget() - self.form_layout = QFormLayout(self.form_panel) - self.form_layout.setSpacing(8) - self._form_rows: dict[str, tuple[QLabel, QWidget]] = {} - self._register_form_row("display_name", "Display name", self.display_name_edit) - self._register_form_row("make", "Make", self.make_edit) - self._register_form_row("model", "Model", self.model_edit) - self._register_form_row("lens_model", "Lens model", self.lens_model_edit) - self._register_form_row("focal", "Focal length", self.focal_spin) - self._register_form_row("aperture", "Max aperture", self.aperture_spin) - self._register_form_row("manufacturer", "Manufacturer", self.manufacturer_edit) - self._register_form_row("stock_name", "Stock name", self.stock_name_edit) - self._register_form_row("iso", "ISO", self.iso_spin) - self._register_form_row("format", "Format", self.format_combo) - self._register_form_row("color_type", "Color type", self.color_combo) - self._register_form_row("developer", "Developer", self.developer_edit) - self._register_form_row("dilution", "Dilution", self.dilution_edit) - self._register_form_row("push_pull", "Push / Pull", self.push_pull_combo) - self._register_form_row("dev_time", "Time", self.dev_time_edit) - self._register_form_row("dev_temp", "Temperature (°C)", self.dev_temp_edit) - self._register_form_row("scanning", "Scanning", self.scanning_edit) - self._register_form_row("notes", "Notes", self.notes_edit) + self.form_panel = QWidget() + self.form_layout = QVBoxLayout(self.form_panel) + self.form_layout.setContentsMargins(0, 0, 0, 0) + self.form_layout.setSpacing(THEME.space_md) + self._form_rows: dict[str, tuple[QLabel, QWidget]] = {} + self._register_form_row("display_name", "Display name", self.display_name_edit) + self._register_form_row("make", "Make", self.make_edit) + self._register_form_row("model", "Model", self.model_edit) + self._register_form_row("lens_model", "Lens model", self.lens_model_edit) + self._register_form_row("focal", "Focal length", self.focal_spin) + self._register_form_row("aperture", "Max aperture", self.aperture_spin) + self._register_form_row("manufacturer", "Manufacturer", self.manufacturer_edit) + self._register_form_row("stock_name", "Stock name", self.stock_name_edit) + self._register_form_row("iso", "ISO", self.iso_spin) + self._register_form_row("format", "Format", self.format_combo) + self._register_form_row("color_type", "Color type", self.color_combo) + self._register_form_row("developer", "Developer", self.developer_edit) + self._register_form_row("dilution", "Dilution", self.dilution_edit) + self._register_form_row("push_pull", "Push / Pull", self.push_pull_combo) + self._register_form_row("dev_time", "Time", self.dev_time_edit) + self._register_form_row("dev_temp", "Temperature (°C)", self.dev_temp_edit) + self._register_form_row("scanning", "Scanning", self.scanning_edit) + self._register_form_row("notes", "Notes", self.notes_edit) + + root.addWidget(self.form_panel) + root.addStretch() + + self.category_list.currentIndexChanged.connect(self._on_category_changed) + install_wheel_guards(self) + + def _register_form_row(self, key: str, label_text: str, widget: QWidget) -> None: + label = field_label(label_text) + self.form_layout.addWidget(label) + self.form_layout.addWidget(widget) + self._form_rows[key] = (label, widget) + + def _show_form_for_category(self, category: str) -> None: + visible = _CATEGORY_FIELDS[category] + for key, (label, widget) in self._form_rows.items(): + show = key in visible + label.setVisible(show) + widget.setVisible(show) + + def _current_items(self) -> list: + return getattr(self._library, self._category) + + def _set_current_items(self, items: list) -> None: + setattr(self._library, self._category, items) + + def _select_category(self, key: str) -> None: + idx = self.category_list.findData(key) + if idx < 0: + return + if self.category_list.currentIndex() == idx: + self._on_category_changed(idx) + else: + self.category_list.setCurrentIndex(idx) + + def _on_category_changed(self, index: int) -> None: + if index < 0: + return + self._category = self.category_list.itemData(index) + self._show_bundled = False + self.show_catalog_btn.blockSignals(True) + self.show_catalog_btn.setChecked(False) + self.show_catalog_btn.blockSignals(False) + self.item_search.blockSignals(True) + self.item_search.clear() + self.item_search.setPlaceholderText(_CATEGORY_SEARCH_PLACEHOLDER.get(self._category, "Search…")) + self.item_search.blockSignals(False) + self._rebuild_item_list() + self._show_form_for_category(self._category) + self._update_del_tooltip() + self.add_btn.setToolTip(wrap_tooltip(_ADD_TOOLTIPS.get(self._category, "Add item"))) + + def _on_show_catalog_toggled(self, checked: bool) -> None: + self._show_bundled = checked + self._rebuild_item_list() + + def _on_item_search_changed(self, _text: str) -> None: + self._rebuild_item_list() + + def _visible_items(self, all_items: list) -> list: + """The default list is personal gear only; the catalog toggle brings the + bundled reference entries back in.""" + return all_items if self._show_bundled else [item for item in all_items if not item.is_bundled] + + def _rebuild_item_list(self, *, select_id: str | None = None) -> None: + all_items = self._current_items() + selected_id = select_id + if selected_id is None and 0 <= self._selected_idx < len(all_items): + selected_id = all_items[self._selected_idx].id + + query = self.item_search.text().strip() + candidates = self._visible_items(all_items) + visible = [item for item in candidates if matches_gear_filter(item, query)] + + self._list_items = visible + self.item_list.blockSignals(True) + self.item_list.clear() + for item in visible: + self.item_list.addItem(QListWidgetItem(item.resolved_display_name)) + + row = -1 + if visible: + if selected_id: + row = next((i for i, item in enumerate(visible) if item.id == selected_id), -1) + if row < 0 and select_id is not None: + row = next((i for i, item in enumerate(visible) if item.id == select_id), 0) + elif row < 0 and not query: + row = 0 + self.item_list.setCurrentRow(row) + self.item_list.blockSignals(False) + + # An empty personal list reads as "nothing here yet", not a blank box. + no_personal_gear = not candidates and not query + self.empty_hint.setVisible(no_personal_gear) + if no_personal_gear: + self.empty_hint.setText(f"You haven't added any {_CATEGORY_PLURAL_NOUN.get(self._category, 'items')} yet.") + self.item_list.setVisible(not no_personal_gear) + + if not visible and not query: + self._selected_idx = -1 + self._clear_form() + elif row >= 0: + self._on_item_changed(row) + + def _on_item_changed(self, row: int) -> None: + if row < 0 or row >= len(self._list_items): + self._selected_idx = -1 + self._set_form_editable(True) + self._clear_form() + self._update_del_tooltip() + return + item = self._list_items[row] + all_items = self._current_items() + self._selected_idx = next(i for i, candidate in enumerate(all_items) if candidate.id == item.id) + self._set_form_editable(not item.is_bundled) + self._populate_form(item) + self._update_del_tooltip() + + def _update_del_tooltip(self) -> None: + item = self._selected_item() + bundled = item is not None and item.is_bundled + text = _DELETE_BUNDLED_TOOLTIP if bundled else _DELETE_TOOLTIPS.get(self._category, "Delete") + self.del_btn.setToolTip(wrap_tooltip(text)) + + def _selected_item(self): + items = self._current_items() + return items[self._selected_idx] if 0 <= self._selected_idx < len(items) else None + + def _parsed_or_kept(self, edit: QLineEdit, parse, current): + """Every keystroke saves, so a half-typed "9:" must not erase the stored value. + Blank is an explicit clear; unreadable text keeps what is stored and marks the field.""" + text = edit.text().strip() + if not text: + self._mark_invalid(edit, False) + return None + value = parse(text) + self._mark_invalid(edit, value is None) + return current if value is None else value + + def _mark_invalid(self, edit: QLineEdit, invalid: bool) -> None: + edit.setStyleSheet(f"border: 1px solid {THEME.error};" if invalid else "") + + def _set_form_editable(self, enabled: bool) -> None: + for _label, widget in self._form_rows.values(): + widget.setEnabled(enabled) + self.del_btn.setEnabled(enabled) + + def _populate_form(self, item) -> None: + self._updating = True + try: + if isinstance(item, Camera): + self.display_name_edit.setText(item.display_name) + self.make_edit.setText(item.make) + self.model_edit.setText(item.model) + self.notes_edit.setText(item.notes) + elif isinstance(item, Lens): + self.display_name_edit.setText(item.display_name) + self.make_edit.setText(item.make) + self.lens_model_edit.setText(item.lens_model) + self.focal_spin.setValue(item.focal_length_mm or 0) + self.aperture_spin.setValue(item.max_aperture or 0) + self.notes_edit.setText(item.notes) + elif isinstance(item, DevelopmentProcess): + self.display_name_edit.setText(item.display_name) + self.developer_edit.setText(item.developer) + self.dilution_edit.setText(item.dilution) + self.push_pull_combo.setCurrentIndex(_push_pull_index(item.push_pull)) + self.dev_time_edit.setText(format_dev_time(item.time_seconds)) + self.dev_temp_edit.setText(format_temperature(item.temperature_c)) + self._mark_invalid(self.dev_time_edit, False) + self._mark_invalid(self.dev_temp_edit, False) + self.notes_edit.setText(item.notes) + elif isinstance(item, ScanSetup): + self.display_name_edit.setText(item.display_name) + self.scanning_edit.setText(item.scanning) + self.notes_edit.setText(item.notes) + elif isinstance(item, FilmStock): + self.display_name_edit.setText(item.display_name) + self.manufacturer_edit.setText(item.manufacturer) + self.stock_name_edit.setText(item.stock_name) + self.iso_spin.setValue(item.iso) + idx = self.format_combo.findText(item.format.value) + if idx >= 0: + self.format_combo.setCurrentIndex(idx) + idx = self.color_combo.findText(item.color_type.value) + if idx >= 0: + self.color_combo.setCurrentIndex(idx) + self.notes_edit.setText(item.notes) + finally: + self._updating = False + + def _clear_form(self) -> None: + self._updating = True + try: + for w in ( + self.display_name_edit, + self.make_edit, + self.model_edit, + self.lens_model_edit, + self.manufacturer_edit, + self.stock_name_edit, + self.developer_edit, + self.dilution_edit, + self.dev_time_edit, + self.dev_temp_edit, + self.scanning_edit, + self.notes_edit, + ): + w.clear() + self.focal_spin.setValue(0) + self.aperture_spin.setValue(0) + self.iso_spin.setValue(100) + finally: + self._updating = False + + def _on_form_changed(self, *_args) -> None: + if self._updating or self._selected_idx < 0: + return + items = list(self._current_items()) + item = items[self._selected_idx] + + if isinstance(item, Camera): + item.display_name = self.display_name_edit.text().strip() + item.make = self.make_edit.text().strip() + item.model = self.model_edit.text().strip() + item.notes = self.notes_edit.text().strip() + elif isinstance(item, Lens): + item.display_name = self.display_name_edit.text().strip() + item.make = self.make_edit.text().strip() + item.lens_model = self.lens_model_edit.text().strip() + item.focal_length_mm = self.focal_spin.value() or None + item.max_aperture = self.aperture_spin.value() or None + item.notes = self.notes_edit.text().strip() + elif isinstance(item, DevelopmentProcess): + item.display_name = self.display_name_edit.text().strip() + item.developer = self.developer_edit.text().strip() + item.dilution = self.dilution_edit.text().strip() + item.push_pull = PUSH_PULL_VALUES[self.push_pull_combo.currentIndex()] + item.time_seconds = self._parsed_or_kept(self.dev_time_edit, parse_dev_time, item.time_seconds) + item.temperature_c = self._parsed_or_kept(self.dev_temp_edit, parse_temperature, item.temperature_c) + item.notes = self.notes_edit.text().strip() + elif isinstance(item, ScanSetup): + item.display_name = self.display_name_edit.text().strip() + item.scanning = self.scanning_edit.text().strip() + item.notes = self.notes_edit.text().strip() + elif isinstance(item, FilmStock): + item.display_name = self.display_name_edit.text().strip() + item.manufacturer = self.manufacturer_edit.text().strip() + item.stock_name = self.stock_name_edit.text().strip() + item.iso = self.iso_spin.value() + item.format = FilmFormat(self.format_combo.currentText()) + item.color_type = FilmColorType(self.color_combo.currentText()) + item.notes = self.notes_edit.text().strip() + + items[self._selected_idx] = item + self._set_current_items(items) + list_row = next((i for i, visible in enumerate(self._list_items) if visible.id == item.id), -1) + list_entry = self.item_list.item(list_row) if list_row >= 0 else None + if list_entry is not None: + list_entry.setText(item.resolved_display_name) + GearProfiles.save_library(self._library) + self.library_changed.emit() + + def _add_item(self) -> None: + catalog = [item for item in self._current_items() if item.is_bundled] + if not catalog: + self._add_custom_item() + return + dlg = GearCatalogDialog( + self, + _CATEGORY_SINGULAR[self._category], + catalog, + lambda item: item.resolved_display_name, + _CATEGORY_SEARCH_PLACEHOLDER[self._category], + ) + if dlg.exec() != QDialog.DialogCode.Accepted: + return + if dlg.wants_custom(): + self._add_custom_item() + return + picked = next((c for c in catalog if c.id == dlg.selected_id()), None) + if picked is not None: + self._clone_into_personal(picked) + + def _add_custom_item(self) -> None: + """Blank-record escape hatch, offered by the catalog dialog when the user's own + gear isn't in the shipped list.""" + item = blank_gear_item(self._category) + items = list(self._current_items()) + items.append(item) + self._set_current_items(items) + GearProfiles.save_library(self._library) + self._rebuild_item_list(select_id=item.id) + self.library_changed.emit() + self.display_name_edit.setFocus() + self.display_name_edit.selectAll() + + def _clone_into_personal(self, source) -> None: + dup = clone_into_personal(source) + items = list(self._current_items()) + items.append(dup) + self._set_current_items(items) + GearProfiles.save_library(self._library) + self._rebuild_item_list(select_id=dup.id) + self.library_changed.emit() + + def _duplicate_item(self) -> None: + if self._selected_idx < 0: + return + items = self._current_items() + self._clone_into_personal(items[self._selected_idx]) + + def _delete_item(self) -> None: + if self._selected_idx < 0: + return + items = self._current_items() + item = items[self._selected_idx] + if item.is_bundled: + return + kind = {"film_stocks": "Film Stock", "scan_setups": "Scan Setup"}.get(self._category, dict(_CATEGORIES)[self._category].rstrip("s")) + if not confirm_delete_named(self, kind, item.resolved_display_name): + return + items = list(self._current_items()) + del items[self._selected_idx] + self._set_current_items(items) + GearProfiles.save_library(self._library) + self._rebuild_item_list() + self.library_changed.emit() + + +class GearPresetsPanel(QWidget): + """Saved metadata field sets, stored as files rather than library records: the form + pane shows what one holds and the field picker edits it. Its gear combos default to + personal gear, same as the Metadata tab, with Other… reaching the full catalog.""" + + library_changed = pyqtSignal() + presets_changed = pyqtSignal() + + def __init__(self, library: GearLibrary, current_config_fn: Optional[Callable[[], Optional[WorkspaceConfig]]] = None, parent=None): + super().__init__(parent) + self._library = library + # A getter, not a snapshot: this panel is built once and stays live for the + # whole session, so "save preset from the current frame" needs whichever frame + # is current *when Add is clicked*, not whichever was current at construction. + self._current_config_fn = current_config_fn or (lambda: None) + self._selected_idx = -1 + self._list_items: list[str] = [] + self._updating = False + + self._init_ui() + self._rebuild_item_list() + + def library(self) -> GearLibrary: + return self._library + + def _init_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(THEME.space_xl, 0, THEME.space_xl, 5) + root.setSpacing(THEME.space_lg) + + root.addWidget(section_subheader("Presets")) + root.addWidget(hint_label("Saved metadata field sets, applied to any frame from the Metadata tab.")) + + self.item_search = QLineEdit() + self.item_search.setPlaceholderText("Search presets…") + self.item_search.textChanged.connect(self._on_item_search_changed) + root.addWidget(self.item_search) + + self.item_list = QListWidget() + self.item_list.setMaximumHeight(160) + self.item_list.currentRowChanged.connect(self._on_item_changed) + root.addWidget(self.item_list) + + self.empty_hint = hint_label() + self.empty_hint.setVisible(False) + root.addWidget(self.empty_hint) + + btn_row = QHBoxLayout() + btn_row.setSpacing(THEME.space_sm) + self.add_btn = icon_button("fa5s.plus", "Store the current frame's metadata as a preset") + self.add_btn.clicked.connect(self._add_item) + self.dup_btn = icon_button("fa5s.copy", "Duplicate") + self.dup_btn.clicked.connect(self._duplicate_item) + self.edit_btn = icon_button("fa5s.pen", "Rename the preset, or change which fields it stores") + self.edit_btn.clicked.connect(self._edit_preset) + self.del_btn = icon_button("fa5s.trash-alt", "Delete this preset") + self.del_btn.clicked.connect(self._delete_item) + for b in (self.add_btn, self.dup_btn, self.edit_btn, self.del_btn): + btn_row.addWidget(b) + btn_row.addStretch() + root.addLayout(btn_row) - right_layout.addWidget(self.form_panel) + root.addWidget(section_subheader("DETAILS")) self.preset_panel = QWidget() preset_layout = QVBoxLayout(self.preset_panel) preset_layout.setContentsMargins(0, 0, 0, 0) - preset_layout.setSpacing(8) + preset_layout.setSpacing(THEME.space_lg) self.preset_name_label = QLabel() self.preset_name_label.setStyleSheet(f"color: {THEME.text_primary}; font-weight: bold;") - self.preset_form_layout = QFormLayout() - self.preset_form_layout.setSpacing(8) + self.preset_form_layout = QVBoxLayout() + self.preset_form_layout.setSpacing(THEME.space_md) self._preset_rows: dict[str, tuple[QLabel, QWidget]] = {} self._build_preset_form() - self.preset_fields_layout = QFormLayout() - self.preset_fields_layout.setSpacing(8) + self.preset_fields_layout = QVBoxLayout() + self.preset_fields_layout.setSpacing(THEME.space_md) self.preset_empty_label = QLabel("This preset stores nothing.") self.preset_empty_label.setStyleSheet(f"color: {THEME.text_secondary};") preset_layout.addWidget(self.preset_name_label) preset_layout.addLayout(self.preset_form_layout) preset_layout.addLayout(self.preset_fields_layout) preset_layout.addWidget(self.preset_empty_label) - notes_row = QFormLayout() - notes_row.setSpacing(8) + preset_layout.addWidget(field_label("Notes")) self.preset_notes_edit = QLineEdit() self.preset_notes_edit.setPlaceholderText("Notes for this preset") self.preset_notes_edit.textChanged.connect(self._on_preset_notes_changed) - notes_row.addRow(field_label("Notes"), self.preset_notes_edit) - preset_layout.addLayout(notes_row) + preset_layout.addWidget(self.preset_notes_edit) preset_layout.addWidget(hint_label("The pen chooses which fields a preset stores; these edit their values.")) - self.preset_panel.setVisible(False) - right_layout.addWidget(self.preset_panel) - right_layout.addStretch() + root.addWidget(self.preset_panel) + root.addStretch() - close_row = QHBoxLayout() - close_row.addStretch() - self._close_btn = QPushButton("Close") - self._close_btn.clicked.connect(self.accept) - close_row.addWidget(self._close_btn) - right_layout.addLayout(close_row) - - root.addWidget(right) + self.add_btn.setEnabled(self._current_config_fn() is not None) + install_wheel_guards(self) def _build_preset_form(self) -> None: self.preset_camera_combo = SearchableGearCombo(placeholder="Search cameras…") @@ -362,9 +757,25 @@ def _build_preset_form(self) -> None: ("exposure", "Exposure", self.preset_exposure_edit), ): row_label = field_label(label) - self.preset_form_layout.addRow(row_label, widget) + self.preset_form_layout.addWidget(row_label) + self.preset_form_layout.addWidget(widget) self._preset_rows[key] = (row_label, widget) + self._preset_combo_category = { + id(self.preset_camera_combo): "cameras", + id(self.preset_lens_combo): "lenses", + id(self.preset_film_combo): "film_stocks", + id(self.preset_process_combo): "processes", + id(self.preset_scan_combo): "scan_setups", + } + self._preset_combo_field = { + id(self.preset_camera_combo): lambda meta: meta.camera_id, + id(self.preset_lens_combo): lambda meta: meta.lens_id, + id(self.preset_film_combo): lambda meta: meta.film_stock_id, + id(self.preset_process_combo): lambda meta: meta.process_id, + id(self.preset_scan_combo): lambda meta: meta.scanning_id, + } + # A library pick re-resolves everything read from it; a typed value unlinks the pick, # exactly as the Metadata panel behaves. for combo, handler in ( @@ -389,75 +800,21 @@ def _build_preset_form(self) -> None: self.preset_format_combo.currentIndexChanged.connect(self._on_preset_value_changed) self.preset_push_combo.currentIndexChanged.connect(self._on_preset_value_changed) - def _register_form_row(self, key: str, label_text: str, widget: QWidget) -> None: - label = field_label(label_text) - self.form_layout.addRow(label, widget) - self._form_rows[key] = (label, widget) + def on_activated(self) -> None: + """Re-check whether a frame is open every time this subtab is shown, since it + may have changed while Items was active.""" + self.add_btn.setEnabled(self._current_config_fn() is not None) - def _show_form_for_category(self, category: str) -> None: - visible = _CATEGORY_FIELDS[category] - for key, (label, widget) in self._form_rows.items(): - show = key in visible - label.setVisible(show) - widget.setVisible(show) + def refresh_gear_combos(self) -> None: + """Personal gear added on Items must reach an already-open preset's combos + without the user reselecting it.""" + name = self._selected_preset() + if name: + self._populate_preset(name) - def _current_items(self) -> list: - if self._category == "cameras": - return self._library.cameras - if self._category == "lenses": - return self._library.lenses - if self._category == "film_stocks": - return self._library.film_stocks - if self._category == "processes": - return self._library.processes - if self._category == "scan_setups": - return self._library.scan_setups + def _current_items(self) -> list[str]: return sorted(MetadataPresets.list_presets()) - def _set_current_items(self, items: list) -> None: - if self._category == "cameras": - self._library.cameras = items - elif self._category == "lenses": - self._library.lenses = items - elif self._category == "film_stocks": - self._library.film_stocks = items - elif self._category == "processes": - self._library.processes = items - elif self._category == "scan_setups": - self._library.scan_setups = items - - def _item_label(self, item) -> str: - """A preset is its own name; a library record has a resolved one.""" - if isinstance(item, str): - return item - return item.resolved_display_name - - def _item_id(self, item) -> str: - return item if isinstance(item, str) else item.id - - def _select_category(self, key: str) -> None: - for i, (k, _) in enumerate(_CATEGORIES): - if k == key: - self.category_list.setCurrentRow(i) - break - - def _on_category_changed(self, row: int) -> None: - if row < 0: - return - self._category = _CATEGORIES[row][0] - self.item_search.blockSignals(True) - self.item_search.clear() - self.item_search.setPlaceholderText(_CATEGORY_SEARCH_PLACEHOLDER.get(self._category, "Search…")) - self.item_search.blockSignals(False) - self._rebuild_item_list() - self._show_form_for_category(self._category) - is_presets = self._category == _PRESETS - self.form_panel.setVisible(not is_presets) - self.preset_panel.setVisible(is_presets) - self.edit_btn.setVisible(is_presets) - self.add_btn.setEnabled(not is_presets or self._current_config is not None) - self.add_btn.setToolTip("Store the current frame's metadata as a preset" if is_presets else "Add item") - def _on_item_search_changed(self, _text: str) -> None: self._rebuild_item_list() @@ -465,55 +822,55 @@ def _rebuild_item_list(self, *, select_id: str | None = None) -> None: all_items = self._current_items() selected_id = select_id if selected_id is None and 0 <= self._selected_idx < len(all_items): - selected_id = self._item_id(all_items[self._selected_idx]) + selected_id = all_items[self._selected_idx] query = self.item_search.text().strip() - visible = [item for item in all_items if self._matches(item, query)] + visible = [name for name in all_items if query.casefold() in name.casefold()] self._list_items = visible self.item_list.blockSignals(True) self.item_list.clear() - for item in visible: - self.item_list.addItem(QListWidgetItem(self._item_label(item))) + for name in visible: + self.item_list.addItem(QListWidgetItem(name)) row = -1 if visible: if selected_id: - row = next((i for i, item in enumerate(visible) if self._item_id(item) == selected_id), -1) + row = next((i for i, name in enumerate(visible) if name == selected_id), -1) if row < 0 and select_id is not None: - row = next((i for i, item in enumerate(visible) if self._item_id(item) == select_id), 0) + row = next((i for i, name in enumerate(visible) if name == select_id), 0) elif row < 0 and not query: row = 0 self.item_list.setCurrentRow(row) self.item_list.blockSignals(False) + no_presets = not all_items and not query + self.empty_hint.setVisible(no_presets) + if no_presets: + self.empty_hint.setText("You haven't added any presets yet.") + self.item_list.setVisible(not no_presets) + if not visible and not query: self._selected_idx = -1 self._clear_form() elif row >= 0: self._on_item_changed(row) - def _matches(self, item, query: str) -> bool: - if isinstance(item, str): - return query.strip().casefold() in item.casefold() - return matches_gear_filter(item, query) - def _on_item_changed(self, row: int) -> None: if row < 0 or row >= len(self._list_items): self._selected_idx = -1 - self._set_form_editable(True) self._clear_form() return - item = self._list_items[row] - item_id = self._item_id(item) + name = self._list_items[row] all_items = self._current_items() - self._selected_idx = next(i for i, candidate in enumerate(all_items) if self._item_id(candidate) == item_id) - self._set_form_editable(isinstance(item, str) or not item.is_bundled) - self._populate_form(item) + self._selected_idx = next(i for i, candidate in enumerate(all_items) if candidate == name) + self._populate_preset(name) + + def _selected_preset(self) -> str: + items = self._current_items() + return items[self._selected_idx] if 0 <= self._selected_idx < len(items) else "" def _parsed_or_kept(self, edit: QLineEdit, parse, current): - """Every keystroke saves, so a half-typed "9:" must not erase the stored value. - Blank is an explicit clear; unreadable text keeps what is stored and marks the field.""" text = edit.text().strip() if not text: self._mark_invalid(edit, False) @@ -525,58 +882,6 @@ def _parsed_or_kept(self, edit: QLineEdit, parse, current): def _mark_invalid(self, edit: QLineEdit, invalid: bool) -> None: edit.setStyleSheet(f"border: 1px solid {THEME.error};" if invalid else "") - def _set_form_editable(self, enabled: bool) -> None: - for _label, widget in self._form_rows.values(): - widget.setEnabled(enabled) - self.del_btn.setEnabled(enabled) - - def _populate_form(self, item) -> None: - if isinstance(item, str): - self._populate_preset(item) - return - self._updating = True - try: - if isinstance(item, Camera): - self.display_name_edit.setText(item.display_name) - self.make_edit.setText(item.make) - self.model_edit.setText(item.model) - self.notes_edit.setText(item.notes) - elif isinstance(item, Lens): - self.display_name_edit.setText(item.display_name) - self.make_edit.setText(item.make) - self.lens_model_edit.setText(item.lens_model) - self.focal_spin.setValue(item.focal_length_mm or 0) - self.aperture_spin.setValue(item.max_aperture or 0) - self.notes_edit.setText(item.notes) - elif isinstance(item, DevelopmentProcess): - self.display_name_edit.setText(item.display_name) - self.developer_edit.setText(item.developer) - self.dilution_edit.setText(item.dilution) - self.push_pull_combo.setCurrentIndex(_push_pull_index(item.push_pull)) - self.dev_time_edit.setText(format_dev_time(item.time_seconds)) - self.dev_temp_edit.setText(format_temperature(item.temperature_c)) - self._mark_invalid(self.dev_time_edit, False) - self._mark_invalid(self.dev_temp_edit, False) - self.notes_edit.setText(item.notes) - elif isinstance(item, ScanSetup): - self.display_name_edit.setText(item.display_name) - self.scanning_edit.setText(item.scanning) - self.notes_edit.setText(item.notes) - elif isinstance(item, FilmStock): - self.display_name_edit.setText(item.display_name) - self.manufacturer_edit.setText(item.manufacturer) - self.stock_name_edit.setText(item.stock_name) - self.iso_spin.setValue(item.iso) - idx = self.format_combo.findText(item.format.value) - if idx >= 0: - self.format_combo.setCurrentIndex(idx) - idx = self.color_combo.findText(item.color_type.value) - if idx >= 0: - self.color_combo.setCurrentIndex(idx) - self.notes_edit.setText(item.notes) - finally: - self._updating = False - def _populate_preset(self, name: str) -> None: data = MetadataPresets.load_preset(name) or {} stored = {r.id for r in rows_for_keys(data, "metadata")} @@ -587,11 +892,11 @@ def _populate_preset(self, name: str) -> None: try: self.preset_name_label.setText(name) self.preset_notes_edit.setText(preset_notes(data)) - self.preset_camera_combo.set_gear_items(self._library.cameras, meta.camera_id, lambda c: c.resolved_display_name) - self.preset_lens_combo.set_gear_items(self._library.lenses, meta.lens_id, lambda x: x.resolved_display_name) - self.preset_film_combo.set_gear_items(self._library.film_stocks, meta.film_stock_id, lambda f: f.resolved_display_name) - self.preset_process_combo.set_gear_items(self._library.processes, meta.process_id, lambda p: p.resolved_display_name) - self.preset_scan_combo.set_gear_items(self._library.scan_setups, meta.scanning_id, lambda x: x.resolved_display_name) + self._set_own_gear_items(self.preset_camera_combo, self._library.cameras, meta.camera_id) + self._set_own_gear_items(self.preset_lens_combo, self._library.lenses, meta.lens_id) + self._set_own_gear_items(self.preset_film_combo, self._library.film_stocks, meta.film_stock_id) + self._set_own_gear_items(self.preset_process_combo, self._library.processes, meta.process_id) + self._set_own_gear_items(self.preset_scan_combo, self._library.scan_setups, meta.scanning_id) self.preset_format_combo.setCurrentText(format_label(meta.format)) self.preset_format_other_edit.setText(meta.format_other) self.preset_developer_edit.setText(meta.developer) @@ -612,16 +917,23 @@ def _populate_preset(self, name: str) -> None: self._updating = False # Rows with no editor: per-frame decisions, shown as they are stored. - while self.preset_fields_layout.rowCount(): - self.preset_fields_layout.removeRow(0) + self._clear_preset_fields_layout() read_only = [(label, value) for label, value in preset_values(data, "metadata") if not self._is_editable_row(label, stored)] for label, value in read_only: value_label = QLabel(value) value_label.setWordWrap(True) value_label.setStyleSheet(f"color: {THEME.text_secondary};") - self.preset_fields_layout.addRow(field_label(label), value_label) + self.preset_fields_layout.addWidget(field_label(label)) + self.preset_fields_layout.addWidget(value_label) self.preset_empty_label.setVisible(not stored) + def _clear_preset_fields_layout(self) -> None: + while self.preset_fields_layout.count(): + item = self.preset_fields_layout.takeAt(0) + widget = item.widget() if item is not None else None + if widget is not None: + widget.deleteLater() + def _is_editable_row(self, label: str, stored: set[str]) -> bool: for row in rows_by_id().values(): if row.label == label and row.section == "metadata": @@ -655,6 +967,10 @@ def _preset_meta(self): return preset_config(data).metadata if data else None def _on_preset_gear_changed(self, *_args) -> None: + # No sender-based dispatch: called directly (no live signal) by callers that + # just set a combo's id and want the write-through, not just a real pick. + for combo in (self.preset_camera_combo, self.preset_lens_combo, self.preset_film_combo): + self._resolve_other_preset_pick(combo) meta = None if self._updating else self._preset_meta() if meta is None: return @@ -669,15 +985,48 @@ def _on_preset_gear_changed(self, *_args) -> None: ) def _on_preset_process_picked(self, *_args) -> None: + self._resolve_other_preset_pick(self.preset_process_combo) meta = None if self._updating else self._preset_meta() if meta is not None: self._write_preset(metadata_from_process(meta, self._library, self.preset_process_combo.selected_id())) def _on_preset_scan_picked(self, *_args) -> None: + self._resolve_other_preset_pick(self.preset_scan_combo) meta = None if self._updating else self._preset_meta() if meta is not None: self._write_preset(metadata_from_scan_setup(meta, self._library, self.preset_scan_combo.selected_id())) + def _set_own_gear_items(self, combo: SearchableGearCombo, items, selected_id: str) -> None: + """Personal gear only, plus the currently selected item even if it is a bundled + catalog pick made before this filter existed. Other… is the escape hatch back to + the full catalog, so the default search never returns gear the user doesn't own.""" + entries, search_text = own_gear_entries(items, selected_id) + combo.set_labeled_items( + entries, + selected_id, + search_fn=lambda label, item_id: search_text.get(item_id, label.casefold()), + ) + + def _resolve_other_preset_pick(self, combo: SearchableGearCombo) -> None: + """Other… resolves to a real personal item before the caller writes the + selection: picking a catalog model clones it into the user's own gear, Add + Custom starts a blank one, cancelling reverts to what was selected before.""" + if combo.selected_id() != OTHER_ID or self._updating: + return + category = self._preset_combo_category[id(combo)] + meta = self._preset_meta() + previous = self._preset_combo_field[id(combo)](meta) if meta is not None else "" + new_item = resolve_other_gear_pick(self, category, self._library) + if new_item is None: + self._set_own_gear_items(combo, getattr(self._library, category), previous) + return + items = list(getattr(self._library, category)) + items.append(new_item) + setattr(self._library, category, items) + GearProfiles.save_library(self._library) + self.library_changed.emit() + self._set_own_gear_items(combo, getattr(self._library, category), new_item.id) + def _on_preset_value_changed(self, *_args) -> None: meta = None if self._updating else self._preset_meta() if meta is None: @@ -737,86 +1086,8 @@ def _clear_form(self) -> None: self.preset_notes_edit.clear() finally: self._updating = False - while self.preset_fields_layout.rowCount(): - self.preset_fields_layout.removeRow(0) + self._clear_preset_fields_layout() self.preset_empty_label.setVisible(False) - self._updating = True - try: - for w in ( - self.display_name_edit, - self.make_edit, - self.model_edit, - self.lens_model_edit, - self.manufacturer_edit, - self.stock_name_edit, - self.developer_edit, - self.dilution_edit, - self.dev_time_edit, - self.dev_temp_edit, - self.scanning_edit, - self.notes_edit, - ): - w.clear() - self.focal_spin.setValue(0) - self.aperture_spin.setValue(0) - self.iso_spin.setValue(100) - finally: - self._updating = False - - def _on_form_changed(self, *_args) -> None: - # Presets have no form: their pane is a summary, and the picker writes the file. - if self._updating or self._selected_idx < 0 or self._category == _PRESETS: - return - items = list(self._current_items()) - item = items[self._selected_idx] - - if isinstance(item, Camera): - item.display_name = self.display_name_edit.text().strip() - item.make = self.make_edit.text().strip() - item.model = self.model_edit.text().strip() - item.notes = self.notes_edit.text().strip() - elif isinstance(item, Lens): - item.display_name = self.display_name_edit.text().strip() - item.make = self.make_edit.text().strip() - item.lens_model = self.lens_model_edit.text().strip() - item.focal_length_mm = self.focal_spin.value() or None - item.max_aperture = self.aperture_spin.value() or None - item.notes = self.notes_edit.text().strip() - elif isinstance(item, DevelopmentProcess): - item.display_name = self.display_name_edit.text().strip() - item.developer = self.developer_edit.text().strip() - item.dilution = self.dilution_edit.text().strip() - item.push_pull = PUSH_PULL_VALUES[self.push_pull_combo.currentIndex()] - item.time_seconds = self._parsed_or_kept(self.dev_time_edit, parse_dev_time, item.time_seconds) - item.temperature_c = self._parsed_or_kept(self.dev_temp_edit, parse_temperature, item.temperature_c) - item.notes = self.notes_edit.text().strip() - elif isinstance(item, ScanSetup): - item.display_name = self.display_name_edit.text().strip() - item.scanning = self.scanning_edit.text().strip() - item.notes = self.notes_edit.text().strip() - elif isinstance(item, FilmStock): - item.display_name = self.display_name_edit.text().strip() - item.manufacturer = self.manufacturer_edit.text().strip() - item.stock_name = self.stock_name_edit.text().strip() - item.iso = self.iso_spin.value() - item.format = FilmFormat(self.format_combo.currentText()) - item.color_type = FilmColorType(self.color_combo.currentText()) - item.notes = self.notes_edit.text().strip() - - items[self._selected_idx] = item - self._set_current_items(items) - list_row = next((i for i, visible in enumerate(self._list_items) if self._item_id(visible) == item.id), -1) - list_entry = self.item_list.item(list_row) if list_row >= 0 else None - if list_entry is not None: - list_entry.setText(self._item_label(item)) - GearProfiles.save_library(self._library) - self.library_changed.emit() - - def _selected_preset(self) -> str: - items = self._current_items() - if self._category != _PRESETS or not (0 <= self._selected_idx < len(items)): - return "" - return str(items[self._selected_idx]) def _name_is_usable(self, name: str, replacing: str = "") -> bool: """A preset name is a filename, and a rename onto another preset would replace it.""" @@ -834,11 +1105,12 @@ def _name_is_usable(self, name: str, replacing: str = "") -> bool: == QMessageBox.StandardButton.Yes ) - def _new_preset_from_frame(self) -> None: + def _add_item(self) -> None: """A preset is the current frame's metadata, minus the fields left unticked.""" - if self._current_config is None: + current_config = self._current_config_fn() + if current_config is None: return - dlg = GranularSettingsDialog(self, self._current_config, "current metadata", ask_name=True, exclude_sections=NON_METADATA_SECTIONS) + dlg = GranularSettingsDialog(self, current_config, "current metadata", ask_name=True, exclude_sections=NON_METADATA_SECTIONS) dlg.setWindowTitle("New Metadata Preset") # As when editing: which fields to store is the choice, so every row is on offer. dlg.show_unchanged_settings() @@ -847,7 +1119,7 @@ def _new_preset_from_frame(self) -> None: name = dlg.name().strip() if not self._name_is_usable(name): return - MetadataPresets.save_preset(name, selected_flat_dict(self._current_config, dlg.selected())) + MetadataPresets.save_preset(name, selected_flat_dict(current_config, dlg.selected())) self._rebuild_item_list(select_id=name) self.presets_changed.emit() @@ -878,78 +1150,118 @@ def _edit_preset(self) -> None: self._rebuild_item_list(select_id=new_name) self.presets_changed.emit() - def _add_item(self) -> None: - if self._category == _PRESETS: - self._new_preset_from_frame() - return - if self._category == "cameras": - item = Camera(make="New", model="Camera") - elif self._category == "lenses": - item = Lens(lens_model="New lens") - elif self._category == "processes": - item = DevelopmentProcess(display_name="New process") - elif self._category == "scan_setups": - item = ScanSetup(display_name="New scan setup") - else: - item = FilmStock(stock_name="New stock") - items = list(self._current_items()) - items.append(item) - self._set_current_items(items) - GearProfiles.save_library(self._library) - self._rebuild_item_list(select_id=item.id) - self.library_changed.emit() - def _duplicate_item(self) -> None: if self._selected_idx < 0: return - if self._category == _PRESETS: - name = self._selected_preset() - data = MetadataPresets.load_preset(name) if name else None - if data is None: - return - existing = set(MetadataPresets.list_presets()) - copy_name = next( - f"{name} copy{'' if i == 1 else f' {i}'}" - for i in range(1, 100) - if f"{name} copy{'' if i == 1 else f' {i}'}" not in existing - ) - MetadataPresets.save_preset(copy_name, data) - self._rebuild_item_list(select_id=copy_name) - self.presets_changed.emit() + name = self._selected_preset() + data = MetadataPresets.load_preset(name) if name else None + if data is None: return - import copy - - items = list(self._current_items()) - dup = copy.deepcopy(items[self._selected_idx]) - from negpy.features.metadata.gear_models import _new_id - - dup.id = _new_id() - dup.is_bundled = False - items.append(dup) - self._set_current_items(items) - GearProfiles.save_library(self._library) - self._rebuild_item_list(select_id=dup.id) - self.library_changed.emit() + existing = set(MetadataPresets.list_presets()) + copy_name = next( + f"{name} copy{'' if i == 1 else f' {i}'}" for i in range(1, 100) if f"{name} copy{'' if i == 1 else f' {i}'}" not in existing + ) + MetadataPresets.save_preset(copy_name, data) + self._rebuild_item_list(select_id=copy_name) + self.presets_changed.emit() def _delete_item(self) -> None: if self._selected_idx < 0: return - items = self._current_items() - kind = {"metadata_presets": "Preset", "film_stocks": "Film Stock", "scan_setups": "Scan Setup"}.get( - self._category, dict(_CATEGORIES)[self._category].rstrip("s") - ) - if not confirm_delete_named(self, kind, self._item_label(items[self._selected_idx])): - return - if self._category == _PRESETS: - name = self._selected_preset() - if name: - MetadataPresets.delete_preset(name) - self._rebuild_item_list() - self.presets_changed.emit() + name = self._selected_preset() + if not confirm_delete_named(self, "Preset", name): return - items = list(self._current_items()) - del items[self._selected_idx] - self._set_current_items(items) - GearProfiles.save_library(self._library) + MetadataPresets.delete_preset(name) self._rebuild_item_list() - self.library_changed.emit() + self.presets_changed.emit() + + +class GearLibraryPanel(QWidget): + """Items and Presets, as two subtabs over one gear library. Items holds physical + gear (Cameras, Lenses, Film Stocks, Process, Scanning), with a bundled/personal + Catalog toggle; Presets holds saved metadata field sets, which have no bundled + counterpart, so the toggle lives on Items only.""" + + library_changed = pyqtSignal() + presets_changed = pyqtSignal() + + def __init__( + self, + library: GearLibrary | None = None, + parent=None, + current_config_fn: Optional[Callable[[], Optional[WorkspaceConfig]]] = None, + ): + super().__init__(parent) + self._library = library or GearProfiles.load_library() + + self.items = GearItemsPanel(self._library) + self.presets = GearPresetsPanel(self._library, current_config_fn) + self.items.library_changed.connect(self.library_changed.emit) + self.items.library_changed.connect(self.presets.refresh_gear_combos) + self.presets.library_changed.connect(self.library_changed.emit) + self.presets.presets_changed.connect(self.presets_changed.emit) + + self._init_ui() + + def library(self) -> GearLibrary: + return self._library + + def _init_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # (key, icon_name, tooltip, content_widget) + specs = [ + ("items", "fa5s.toolbox", "Items", self.items), + ("presets", "fa5s.magic", "Presets", self.presets), + ] + + self.switcher = OverflowBar(tile=True, height=38, min_item=36) + self.stack = QStackedWidget() + self._sub_buttons: list[QPushButton] = [] + self._sub_keys: list[str] = [] + self._sub_icons: list[str] = [] + self._sub_tooltips: list[str] = [] + + for i, (key, icon_name, tooltip, content) in enumerate(specs): + btn = QPushButton() + btn.setObjectName("right_tab_btn") + btn.setIcon(qta.icon(icon_name, color=THEME.text_secondary)) + btn.setIconSize(QSize(18, 18)) + btn.setToolTip(wrap_tooltip(tooltip)) + btn.setCheckable(True) + btn.setFixedHeight(38) + btn.clicked.connect(lambda _checked=False, idx=i: self._switch_subtab(idx)) + self.switcher.add_button(btn, tooltip) + self._sub_buttons.append(btn) + self._sub_keys.append(key) + self._sub_icons.append(icon_name) + self._sub_tooltips.append(tooltip) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(content) + self.stack.addWidget(scroll) + + layout.addWidget(self.switcher) + layout.addWidget(self.stack, 1) + self.apply_shortcut_tooltips() + self._switch_subtab(0) + + def _switch_subtab(self, index: int) -> None: + self.stack.setCurrentIndex(index) + for i, btn in enumerate(self._sub_buttons): + btn.setChecked(i == index) + btn.setIcon(qta.icon(self._sub_icons[i], color="white" if i == index else THEME.text_secondary)) + self.switcher.set_pinned(index) + if index == 1: + self.presets.on_activated() + + def show_subtab_by_key(self, key: str) -> None: + if key in self._sub_keys: + self._switch_subtab(self._sub_keys.index(key)) + + def apply_shortcut_tooltips(self) -> None: + for btn, key, base in zip(self._sub_buttons, self._sub_keys, self._sub_tooltips): + btn.setToolTip(tooltip_with_shortcut(base, f"tab_gear_{key}")) diff --git a/negpy/desktop/view/widgets/granular_settings_dialog.py b/negpy/desktop/view/widgets/granular_settings_dialog.py index 1b3a06101..c8980d13d 100644 --- a/negpy/desktop/view/widgets/granular_settings_dialog.py +++ b/negpy/desktop/view/widgets/granular_settings_dialog.py @@ -25,6 +25,48 @@ def _triplet(values) -> str: return " / ".join(f"{v:g}" for v in values) +class ScopeRadios: + """The radios build_scope_row built, and the scope they resolve to.""" + + __slots__ = ("current", "sel", "roll") + + def __init__(self, current: QRadioButton | None, sel: QRadioButton, roll: QRadioButton) -> None: + self.current = current + self.sel = sel + self.roll = roll + + def value(self) -> str: + if self.current is not None and self.current.isChecked(): + return "current" + return "selection" if self.sel.isChecked() else "roll" + + +def build_scope_row(parent: QWidget, sel_count: int, roll_count: int, show_current: bool = False) -> tuple[QHBoxLayout, ScopeRadios]: + """Current/Selected/Whole roll radio row, shared by every dialog that applies settings + to more than the active frame.""" + row = QHBoxLayout() + group = QButtonGroup(parent) + current: QRadioButton | None = None + if show_current: + current = QRadioButton("Current frame") + group.addButton(current) + row.addWidget(current) + sel = QRadioButton(f"Selected frames ({sel_count})") + sel.setEnabled(sel_count > 0) + roll = QRadioButton(f"Whole roll ({roll_count})") + roll.setEnabled(roll_count > 0) + group.addButton(sel) + group.addButton(roll) + if current is not None: + current.setChecked(True) + else: + (sel if sel_count > 0 else roll).setChecked(True) + row.addWidget(sel) + row.addWidget(roll) + row.addStretch() + return row, ScopeRadios(current, sel, roll) + + class GranularSettingsDialog(QDialog): """Per-setting picker for paste / apply-to-many. Lists one collapsible section per edit area, each setting with a checkbox and its value. Settings still at @@ -54,6 +96,7 @@ def __init__( self._checks: list[tuple[QCheckBox, SettingRow, bool, QWidget]] = [] self._sections: list[tuple[QWidget, int]] = [] self._section_rows: list[tuple[CollapsibleSection, tuple[str, ...]]] = [] + self._scope_radios: ScopeRadios | None = None self._preselect_ids = preselect_ids self._bounds_luma: QCheckBox | None = None self._bounds_color: QCheckBox | None = None @@ -102,25 +145,7 @@ def __init__( self._update_apply_enabled() def _build_scope_row(self, sel_count: int, roll_count: int, show_current: bool = False) -> QHBoxLayout: - row = QHBoxLayout() - self.scope_group = QButtonGroup(self) - if show_current: - self.current_radio = QRadioButton("Current frame") - self.scope_group.addButton(self.current_radio) - row.addWidget(self.current_radio) - self.sel_radio = QRadioButton(f"Selected frames ({sel_count})") - self.sel_radio.setEnabled(sel_count > 0) - self.roll_radio = QRadioButton(f"Whole roll ({roll_count})") - self.roll_radio.setEnabled(roll_count > 0) - self.scope_group.addButton(self.sel_radio) - self.scope_group.addButton(self.roll_radio) - if show_current: - self.current_radio.setChecked(True) - else: - (self.sel_radio if sel_count > 0 else self.roll_radio).setChecked(True) - row.addWidget(self.sel_radio) - row.addWidget(self.roll_radio) - row.addStretch() + row, self._scope_radios = build_scope_row(self, sel_count, roll_count, show_current) return row def _build_mode_row(self) -> QHBoxLayout: @@ -310,11 +335,8 @@ def _update_apply_enabled(self) -> None: self.apply_btn.setEnabled(enabled) def _on_apply(self) -> None: - if hasattr(self, "sel_radio"): - if getattr(self, "current_radio", None) is not None and self.current_radio.isChecked(): - self._scope = "current" - else: - self._scope = "selection" if self.sel_radio.isChecked() else "roll" + if self._scope_radios is not None: + self._scope = self._scope_radios.value() self.accept() def selected(self) -> list[SettingRow]: diff --git a/negpy/desktop/view/widgets/rename_roll_dialog.py b/negpy/desktop/view/widgets/rename_roll_dialog.py new file mode 100644 index 000000000..5f3003ea8 --- /dev/null +++ b/negpy/desktop/view/widgets/rename_roll_dialog.py @@ -0,0 +1,49 @@ +from PyQt6.QtWidgets import QCheckBox, QDialog, QDialogButtonBox, QLineEdit, QVBoxLayout + +from negpy.desktop.view.styles.templates import field_label, hint_label, pin_button_box + + +class RenameRollDialog(QDialog): + """Renames a folder roll: its display name always, and -- only if asked -- the + actual folder on disk too. The checkbox starts unchecked every time; it is never + remembered, since a disk rename is a bigger deal than a library-only one. + """ + + def __init__(self, current_name: str, parent=None): + super().__init__(parent) + self.setWindowTitle("Rename Roll") + self.setMinimumWidth(340) + + root = QVBoxLayout(self) + root.setContentsMargins(12, 12, 12, 12) + root.setSpacing(8) + + root.addWidget(field_label("Name")) + self.name_edit = QLineEdit(current_name) + self.name_edit.selectAll() + root.addWidget(self.name_edit) + + self.rename_folder_check = QCheckBox("Also rename the folder on disk") + root.addWidget(self.rename_folder_check) + root.addWidget( + hint_label( + "Renames the actual folder, in place. Inside a cloud-sync folder (Dropbox, " + "iCloud, OneDrive), a sync client may see this as a delete and re-upload " + "rather than a rename.", + kind="warning", + ) + ) + + buttons = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + pin_button_box(buttons) + root.addWidget(buttons) + + self.name_edit.setFocus() + + def name(self) -> str: + return self.name_edit.text().strip() + + def rename_folder(self) -> bool: + return self.rename_folder_check.isChecked() diff --git a/negpy/desktop/view/widgets/roll_settings_dialog.py b/negpy/desktop/view/widgets/roll_settings_dialog.py new file mode 100644 index 000000000..230406248 --- /dev/null +++ b/negpy/desktop/view/widgets/roll_settings_dialog.py @@ -0,0 +1,487 @@ +"""Batch metadata tagging: edit gear, capture and process fields once, then apply +them to the current frame, a selection, or the whole roll.""" + +from dataclasses import replace + +import qtawesome as qta +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QToolButton, + QVBoxLayout, + QWidget, +) + +from negpy.desktop.settings_catalog import CATALOG, SettingRow, preset_config, rows_for_keys +from negpy.desktop.view.styles.templates import field_label, hint_label, pin_dialog_default +from negpy.desktop.view.styles.theme import THEME +from negpy.desktop.view.widgets.granular_settings_dialog import ScopeRadios, build_scope_row +from negpy.desktop.view.widgets.location_picker_dialog import LocationPickerDialog +from negpy.desktop.view.widgets.searchable_gear_combo import SearchableGearCombo +from negpy.domain.models import WorkspaceConfig +from negpy.features.metadata.capture import ( + CAPTURE_DATE_HINT, + DEV_TIME_HINT, + format_dev_time, + format_temperature, + parse_capture_date, + parse_coords, + parse_dev_time, + parse_temperature, + place_summary, +) +from negpy.features.metadata.gear_logic import metadata_from_gear, metadata_from_process, metadata_from_scan_setup +from negpy.features.metadata.gear_models import GearLibrary +from negpy.features.metadata.models import FORMAT_OPTIONS, MetadataConfig, PUSH_PULL_LABELS, PUSH_PULL_VALUES, format_label, format_value +from negpy.services.assets.presets import MetadataPresets + +_METADATA_ROWS: dict[str, SettingRow] = {row.label: row for title, rows in CATALOG if title == "Metadata" for row in rows} +_SIMPLE_GROUPS = ("Exposure Override", "Sync To Batch", "Protect Original Metadata", "Description Fields") + + +def _fmt(value) -> str: + if isinstance(value, bool): + return "on" if value else "off" + if isinstance(value, (tuple, list)): + return ", ".join(str(v) for v in value) if value else "—" + return str(value) if value else "—" + + +class RollSettingsDialog(QDialog): + """Groups mirror the Metadata section of settings_catalog.CATALOG, so the rows this + hands to apply_preset_fields are the catalog's own -- no separate grouping rule to + keep in sync. Editable groups hold real field widgets, kept in a scratch MetadataConfig + updated the same way the Metadata panel updates its own (metadata_from_gear and friends); + the rest carry the active frame's own value, ticked in or out like Apply Settings.""" + + def __init__( + self, + parent, + source_config: WorkspaceConfig, + gear_library: GearLibrary, + *, + sel_count: int, + roll_count: int, + ) -> None: + super().__init__(parent) + self.setWindowTitle("Roll Settings") + self.resize(440, 640) + self._library = gear_library + self._meta = source_config.metadata + self._checks: dict[str, QCheckBox] = {} + self._simple_labels: dict[str, QLabel] = {} + self._scope_radios: ScopeRadios + + root = QVBoxLayout(self) + root.setContentsMargins(THEME.space_2xl, THEME.space_2xl, THEME.space_2xl, THEME.space_2xl) + root.setSpacing(THEME.space_xl) + + root.addWidget(hint_label("Set values once, then apply the ticked groups to the current frame, a selection or the whole roll.")) + root.addLayout(self._build_preset_row()) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QScrollArea.Shape.NoFrame) + body = QWidget() + col = QVBoxLayout(body) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(THEME.space_lg) + col.addWidget(self._build_gear_group()) + col.addWidget(self._build_capture_date_group()) + col.addWidget(self._build_place_group()) + col.addWidget(self._build_process_group()) + col.addWidget(self._build_scanning_group()) + col.addWidget(self._build_roll_group()) + for label in _SIMPLE_GROUPS: + col.addWidget(self._build_simple_group(label)) + col.addStretch() + scroll.setWidget(body) + root.addWidget(scroll, 1) + + scope_row, self._scope_radios = build_scope_row(self, sel_count, roll_count, show_current=True) + # A roll-wide tag is the common case; current-frame-only is the exception a + # user reaches for by hand, not the other way around. + if roll_count > 0: + self._scope_radios.roll.setChecked(True) + root.addLayout(scope_row) + root.addLayout(self._build_footer()) + + # ── group scaffolding ─────────────────────────────────────────────── + + def _group(self, label: str, content: QWidget) -> QWidget: + row = _METADATA_ROWS[label] + default = MetadataConfig() + edited = any(getattr(self._meta, f) != getattr(default, f) for f in row.fields) + box = QCheckBox(label) + box.setChecked(edited) + self._checks[label] = box + wrapper = QWidget() + col = QVBoxLayout(wrapper) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(THEME.space_sm) + col.addWidget(box) + inset = QWidget() + inset_layout = QVBoxLayout(inset) + inset_layout.setContentsMargins(20, 0, 0, 0) + inset_layout.addWidget(content) + col.addWidget(inset) + return wrapper + + def _set_meta(self, group: str, **kwargs) -> None: + self._meta = replace(self._meta, **kwargs) + self._checks[group].setChecked(True) + + def _build_simple_group(self, label: str) -> QWidget: + row = _METADATA_ROWS[label] + value = _fmt(getattr(self._meta, row.fields[0]) if len(row.fields) == 1 else tuple(getattr(self._meta, f) for f in row.fields)) + lbl = QLabel(value) + lbl.setStyleSheet(f"color: {THEME.text_hint};") + self._simple_labels[label] = lbl + return self._group(label, lbl) + + # ── editable groups ───────────────────────────────────────────────── + + def _build_gear_group(self) -> QWidget: + body = QWidget() + col = QVBoxLayout(body) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(THEME.space_md) + + col.addWidget(field_label("Camera")) + self.camera_combo = SearchableGearCombo(placeholder="Search cameras…") + self.camera_combo.set_gear_items(self._library.cameras, self._meta.camera_id or "", lambda c: c.resolved_display_name) + self.camera_combo.selection_changed.connect(lambda _id: self._on_gear_changed("camera_id", self.camera_combo)) + col.addWidget(self.camera_combo) + + col.addWidget(field_label("Lens")) + self.lens_combo = SearchableGearCombo(placeholder="Search lenses…") + self.lens_combo.set_gear_items(self._library.lenses, self._meta.lens_id or "", lambda x: x.resolved_display_name) + self.lens_combo.selection_changed.connect(lambda _id: self._on_gear_changed("lens_id", self.lens_combo)) + col.addWidget(self.lens_combo) + + col.addWidget(field_label("Film stock")) + self.film_stock_combo = SearchableGearCombo(placeholder="Search film stocks…") + self.film_stock_combo.set_gear_items(self._library.film_stocks, self._meta.film_stock_id or "", lambda x: x.resolved_display_name) + self.film_stock_combo.selection_changed.connect(lambda _id: self._on_gear_changed("film_stock_id", self.film_stock_combo)) + col.addWidget(self.film_stock_combo) + + # The stock carries the film's format, so Format lives in the Gear row, matching + # GEAR_FIELDS (settings_catalog applies it as a unit with camera/lens/film stock). + col.addWidget(field_label("Format")) + self.format_combo = QComboBox() + self.format_combo.addItems(FORMAT_OPTIONS) + self.format_combo.setCurrentText(format_label(self._meta.format)) + self.format_combo.currentTextChanged.connect(self._on_format_changed) + col.addWidget(self.format_combo) + + self.format_other_edit = QLineEdit() + self.format_other_edit.setPlaceholderText("e.g. 6×7") + self.format_other_edit.setText(self._meta.format_other) + self.format_other_edit.setVisible(self._meta.format == "Other") + self.format_other_edit.textEdited.connect(lambda t: self._set_meta("Gear", format_other=t.strip())) + col.addWidget(self.format_other_edit) + + return self._group("Gear", body) + + def apply_detected_gear(self, **gear_ids: str) -> None: + """Pre-fill Gear from a folder-name match, ticked like any other edit -- Cancel + discards it, Apply writes it, same as filling the fields by hand.""" + gear_ids = {k: v for k, v in gear_ids.items() if v} + if not gear_ids: + return + self._meta = metadata_from_gear(self._meta, self._library, **gear_ids) + self._checks["Gear"].setChecked(True) + self._reload_widgets() + + def _on_gear_changed(self, field: str, combo: SearchableGearCombo) -> None: + self._meta = metadata_from_gear(self._meta, self._library, **{field: combo.selected_id()}) + self.format_combo.setCurrentText(format_label(self._meta.format)) + self.format_other_edit.setText(self._meta.format_other) + self.format_other_edit.setVisible(self._meta.format == "Other") + self._checks["Gear"].setChecked(True) + + def _on_format_changed(self, text: str) -> None: + value = format_value(text) + self.format_other_edit.setVisible(value == "Other") + self._set_meta("Gear", format=value) + + def _build_capture_date_group(self) -> QWidget: + self.capture_date_edit = QLineEdit() + self.capture_date_edit.setPlaceholderText(CAPTURE_DATE_HINT) + self.capture_date_edit.setText(self._meta.capture_date) + self.capture_date_edit.setToolTip( + "When the frames were shot. Give only what you know: a year, a year and month, " + "a date, or a date and time. An offset like +02:00 may follow a time." + ) + self.capture_date_edit.textEdited.connect(lambda _t: self._checks["Capture Date"].setChecked(True)) + return self._group("Capture Date", self.capture_date_edit) + + def _build_place_group(self) -> QWidget: + body = QWidget() + row = QHBoxLayout(body) + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(THEME.space_sm) + self.place_edit = QLineEdit() + self.place_edit.setPlaceholderText("Pick on a map, or paste coordinates") + self.place_edit.setText( + place_summary( + self._meta.location_city, + self._meta.location_state, + self._meta.location_country, + self._meta.gps_latitude, + self._meta.gps_longitude, + ) + ) + self.place_edit.setToolTip("Typed text is only ever coordinates; place names come from Map…") + self.place_edit.editingFinished.connect(self._on_place_edited) + row.addWidget(self.place_edit, 1) + map_btn = QToolButton() + map_btn.setIcon(qta.icon("fa5s.map-marked-alt", color=THEME.text_primary)) + map_btn.setToolTip("Pick the capture place on a map (contacts OpenStreetMap)") + map_btn.clicked.connect(self._open_location_picker) + row.addWidget(map_btn) + return self._group("Place", body) + + def _on_place_edited(self) -> None: + text = self.place_edit.text().strip() + if not text: + return + coords = parse_coords(text) + if coords is None: + return + self._set_meta("Place", gps_latitude=coords[0], gps_longitude=coords[1]) + + def _open_location_picker(self) -> None: + dlg = LocationPickerDialog( + self._meta.gps_latitude, + self._meta.gps_longitude, + self._meta.location_city, + self._meta.location_state, + self._meta.location_country, + parent=self, + ) + if dlg.exec() != dlg.DialogCode.Accepted: + return + lat, lon, city, state, country = dlg.location() + self._set_meta("Place", gps_latitude=lat, gps_longitude=lon, location_city=city, location_state=state, location_country=country) + self.place_edit.setText(place_summary(city, state, country, lat, lon)) + + def _build_process_group(self) -> QWidget: + body = QWidget() + col = QVBoxLayout(body) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(THEME.space_md) + + col.addWidget(field_label("Saved process")) + self.process_combo = SearchableGearCombo(placeholder="Search processes…") + self.process_combo.set_gear_items(self._library.processes, self._meta.process_id or "", lambda p: p.resolved_display_name) + self.process_combo.selection_changed.connect(lambda _id: self._on_process_selected()) + col.addWidget(self.process_combo) + + row1 = QHBoxLayout() + row1.setSpacing(THEME.space_sm) + dev_col = QVBoxLayout() + dev_col.setSpacing(THEME.space_md) + dev_col.addWidget(field_label("Developer")) + self.developer_edit = QLineEdit() + self.developer_edit.setPlaceholderText("e.g. D-76") + self.developer_edit.setText(self._meta.developer) + self.developer_edit.textEdited.connect(lambda _t: self._checks["Process"].setChecked(True)) + dev_col.addWidget(self.developer_edit) + dil_col = QVBoxLayout() + dil_col.setSpacing(THEME.space_md) + dil_col.addWidget(field_label("Dilution")) + self.dilution_edit = QLineEdit() + self.dilution_edit.setPlaceholderText("e.g. 1+50") + self.dilution_edit.setText(self._meta.process_dilution) + self.dilution_edit.textEdited.connect(lambda _t: self._checks["Process"].setChecked(True)) + dil_col.addWidget(self.dilution_edit) + row1.addLayout(dev_col, 2) + row1.addLayout(dil_col, 1) + col.addLayout(row1) + + col.addWidget(field_label("Push / Pull")) + self.push_pull_combo = QComboBox() + self.push_pull_combo.addItems([PUSH_PULL_LABELS[v] for v in PUSH_PULL_VALUES]) + self.push_pull_combo.setCurrentIndex( + PUSH_PULL_VALUES.index(self._meta.push_pull) if self._meta.push_pull in PUSH_PULL_VALUES else 3 + ) + self.push_pull_combo.currentIndexChanged.connect(lambda _i: self._checks["Process"].setChecked(True)) + col.addWidget(self.push_pull_combo) + + row2 = QHBoxLayout() + row2.setSpacing(THEME.space_sm) + time_col = QVBoxLayout() + time_col.setSpacing(THEME.space_md) + time_col.addWidget(field_label("Time")) + self.dev_time_edit = QLineEdit() + self.dev_time_edit.setPlaceholderText(DEV_TIME_HINT) + self.dev_time_edit.setText(format_dev_time(self._meta.process_time_seconds)) + self.dev_time_edit.textEdited.connect(lambda _t: self._checks["Process"].setChecked(True)) + time_col.addWidget(self.dev_time_edit) + temp_col = QVBoxLayout() + temp_col.setSpacing(THEME.space_md) + temp_col.addWidget(field_label("Temp (°C)")) + self.dev_temp_edit = QLineEdit() + self.dev_temp_edit.setPlaceholderText("e.g. 20") + self.dev_temp_edit.setText(format_temperature(self._meta.process_temperature_c)) + self.dev_temp_edit.textEdited.connect(lambda _t: self._checks["Process"].setChecked(True)) + temp_col.addWidget(self.dev_temp_edit) + row2.addLayout(time_col, 1) + row2.addLayout(temp_col, 1) + col.addLayout(row2) + + return self._group("Process", body) + + def _on_process_selected(self) -> None: + self._meta = metadata_from_process(self._meta, self._library, self.process_combo.selected_id()) + self.developer_edit.setText(self._meta.developer) + self.dilution_edit.setText(self._meta.process_dilution) + self.push_pull_combo.setCurrentIndex( + PUSH_PULL_VALUES.index(self._meta.push_pull) if self._meta.push_pull in PUSH_PULL_VALUES else 3 + ) + self.dev_time_edit.setText(format_dev_time(self._meta.process_time_seconds)) + self.dev_temp_edit.setText(format_temperature(self._meta.process_temperature_c)) + self._checks["Process"].setChecked(True) + + def _build_scanning_group(self) -> QWidget: + body = QWidget() + col = QVBoxLayout(body) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(THEME.space_md) + col.addWidget(field_label("Saved setup")) + self.scan_setup_combo = SearchableGearCombo(placeholder="Search scan setups…") + self.scan_setup_combo.set_gear_items(self._library.scan_setups, self._meta.scanning_id or "", lambda s: s.resolved_display_name) + self.scan_setup_combo.selection_changed.connect(lambda _id: self._on_scan_setup_selected()) + col.addWidget(self.scan_setup_combo) + col.addWidget(field_label("Scanning")) + self.scanning_edit = QLineEdit() + self.scanning_edit.setPlaceholderText("e.g. DSLR copy-stand scan") + self.scanning_edit.setText(self._meta.scanning) + self.scanning_edit.textEdited.connect(lambda _t: self._checks["Scanning"].setChecked(True)) + col.addWidget(self.scanning_edit) + return self._group("Scanning", body) + + def _on_scan_setup_selected(self) -> None: + self._meta = metadata_from_scan_setup(self._meta, self._library, self.scan_setup_combo.selected_id()) + self.scanning_edit.setText(self._meta.scanning) + self._checks["Scanning"].setChecked(True) + + def _build_roll_group(self) -> QWidget: + self.capture_roll_edit = QLineEdit() + self.capture_roll_edit.setPlaceholderText("e.g. Roll001") + self.capture_roll_edit.setText(self._meta.capture_roll) + self.capture_roll_edit.setToolTip("Scan capture roll name. Used in export filename templates as {{ roll }}.") + self.capture_roll_edit.textEdited.connect(lambda _t: self._checks["Roll"].setChecked(True)) + return self._group("Roll", self.capture_roll_edit) + + # ── preset shortcut ───────────────────────────────────────────────── + + def _build_preset_row(self) -> QHBoxLayout: + row = QHBoxLayout() + row.setSpacing(THEME.space_sm) + self.preset_combo = SearchableGearCombo(placeholder="Load from a metadata preset…") + self.preset_combo.set_labeled_items([(n, n) for n in sorted(MetadataPresets.list_presets())], "") + row.addWidget(self.preset_combo, 1) + load_btn = QPushButton("Load") + load_btn.setToolTip("Fill these fields from the preset, and tick the groups it stores") + load_btn.clicked.connect(self._on_load_preset) + row.addWidget(load_btn) + return row + + def _on_load_preset(self) -> None: + name = self.preset_combo.selected_id() + data = MetadataPresets.load_preset(name) if name else None + if not data: + return + preset_meta = preset_config(data).metadata + rows = rows_for_keys(data, "metadata") + self._meta = replace(self._meta, **{f: getattr(preset_meta, f) for row in rows for f in row.fields}) + for row in rows: + if row.label in self._checks: + self._checks[row.label].setChecked(True) + self._reload_widgets() + + def _reload_widgets(self) -> None: + m = self._meta + self.camera_combo.set_selected_id(m.camera_id or "") + self.lens_combo.set_selected_id(m.lens_id or "") + self.film_stock_combo.set_selected_id(m.film_stock_id or "") + self.format_combo.setCurrentText(format_label(m.format)) + self.format_other_edit.setText(m.format_other) + self.format_other_edit.setVisible(m.format == "Other") + self.capture_date_edit.setText(m.capture_date) + self.place_edit.setText(place_summary(m.location_city, m.location_state, m.location_country, m.gps_latitude, m.gps_longitude)) + self.process_combo.set_selected_id(m.process_id or "") + self.developer_edit.setText(m.developer) + self.dilution_edit.setText(m.process_dilution) + self.push_pull_combo.setCurrentIndex(PUSH_PULL_VALUES.index(m.push_pull) if m.push_pull in PUSH_PULL_VALUES else 3) + self.dev_time_edit.setText(format_dev_time(m.process_time_seconds)) + self.dev_temp_edit.setText(format_temperature(m.process_temperature_c)) + self.scan_setup_combo.set_selected_id(m.scanning_id or "") + self.scanning_edit.setText(m.scanning) + self.capture_roll_edit.setText(m.capture_roll) + for label, lbl in self._simple_labels.items(): + row = _METADATA_ROWS[label] + value = getattr(m, row.fields[0]) if len(row.fields) == 1 else tuple(getattr(m, f) for f in row.fields) + lbl.setText(_fmt(value)) + + # ── footer / result ───────────────────────────────────────────────── + + def _build_footer(self) -> QHBoxLayout: + row = QHBoxLayout() + row.addStretch() + cancel_btn = QPushButton("Cancel") + cancel_btn.clicked.connect(self.reject) + self.apply_btn = QPushButton("Apply") + self.apply_btn.clicked.connect(self.accept) + row.addWidget(cancel_btn) + row.addWidget(self.apply_btn) + pin_dialog_default(self.apply_btn, cancel_btn) + return row + + def selected_rows(self) -> list[SettingRow]: + return [_METADATA_ROWS[label] for label, box in self._checks.items() if box.isChecked()] + + def selected_config(self) -> WorkspaceConfig: + """Snapshot the scratch metadata, parsing free-text fields the same way the + Metadata panel does at persist time: unreadable text keeps the stored value, + blank clears it.""" + date_text = self.capture_date_edit.text().strip() + parsed_date = parse_capture_date(date_text) + capture_date = parsed_date.xmp_text() if parsed_date else ("" if not date_text else self._meta.capture_date) + + time_text = self.dev_time_edit.text().strip() + process_time_seconds = None + if time_text: + parsed_time = parse_dev_time(time_text) + process_time_seconds = self._meta.process_time_seconds if parsed_time is None else parsed_time + + temp_text = self.dev_temp_edit.text().strip() + process_temperature_c = None + if temp_text: + parsed_temp = parse_temperature(temp_text) + process_temperature_c = self._meta.process_temperature_c if parsed_temp is None else parsed_temp + + meta = replace( + self._meta, + capture_date=capture_date, + developer=self.developer_edit.text().strip(), + process_dilution=self.dilution_edit.text().strip(), + push_pull=PUSH_PULL_VALUES[self.push_pull_combo.currentIndex()], + process_time_seconds=process_time_seconds, + process_temperature_c=process_temperature_c, + scanning=self.scanning_edit.text().strip(), + capture_roll=self.capture_roll_edit.text().strip(), + format_other=self.format_other_edit.text().strip() if self._meta.format == "Other" else "", + ) + return replace(WorkspaceConfig(), metadata=meta) + + def scope(self) -> str: + return self._scope_radios.value() diff --git a/negpy/desktop/view/widgets/tutorial_steps.py b/negpy/desktop/view/widgets/tutorial_steps.py index 202ed66d1..414441fc9 100644 --- a/negpy/desktop/view/widgets/tutorial_steps.py +++ b/negpy/desktop/view/widgets/tutorial_steps.py @@ -100,8 +100,11 @@ def _split_grade(w: "MainWindow") -> Optional[QWidget]: def _zone_density(w: "MainWindow") -> Optional[QWidget]: return w.controls_panel.tone_sidebar.shadow_density_slider - def _gear_manage(w: "MainWindow") -> Optional[QWidget]: - return w.right_panel.metadata_sidebar.manage_btn + def _metadata(w: "MainWindow") -> Optional[QWidget]: + return w.right_panel.metadata_sidebar + + def _gear_library(w: "MainWindow") -> Optional[QWidget]: + return w.right_panel.gear_panel.items.category_list def _narrowband(w: "MainWindow") -> Optional[QWidget]: return w.controls_panel.sensor_sidebar.narrowband_scan_btn @@ -717,24 +720,29 @@ def _edge_burn(w: "MainWindow") -> Optional[QWidget]: pre_hook=lambda w: w.right_panel.show_tab_by_key("history"), ), TutorialStep( - title="Metadata & Gear Library", + title="Metadata", body=( "The Metadata tab writes film and scan info, meaning stock, format, " - "developer, push/pull and scanner, into the EXIF/XMP of exported files.

" - "Manage… opens the Gear Library: a searchable, user-extendable " - "library of cameras, lenses and film stocks. Gear picked for a frame rides " - "into the exported XMP.

" - "Protect original metadata keeps the source file's EXIF/XMP untouched " - "instead of NegPy rewriting it." + "developer, push/pull and scanner, into the EXIF/XMP of exported files. Gear " + "picked for a frame rides into the exported XMP." ), - target=_gear_manage, + target=_metadata, pre_hook=lambda w: w.right_panel.show_tab_by_key("metadata"), ), + TutorialStep( + title="Gear Library", + body=( + "The Gear tab holds a searchable, user-extendable library of cameras, " + "lenses, film stocks, processes and scan setups, shared by Metadata, Roll " + "Settings and every other picker in the app that offers gear." + ), + target=_gear_library, + pre_hook=lambda w: w.right_panel.show_tab_by_key("gear"), + ), TutorialStep( title="Export", body=( - "The Export tab (right panel, now active) is where you save your " - "results.

" + "The Export tab is where you save your results.

" "Choose a format (JPEG, high-bit-depth TIFF, PNG, WebP, JPEG XL), " "pick a color space, and set resolution or print size. The ICC section adds " "monitor-profile display and soft-proofing.

" @@ -742,7 +750,9 @@ def _edge_burn(w: "MainWindow") -> Optional[QWidget]: "menu arrow picks what it exports (current frame, selected frames, or all visible " "frames) and remembers the choice. Presets run every enabled preset per frame. " "Contact Sheet renders all frames into one sheet. " - "Export always runs at full RAW resolution." + "Export always runs at full RAW resolution.

" + "Protect original metadata keeps the source file's EXIF/XMP untouched " + "instead of NegPy rewriting it." ), target=_export, pre_hook=lambda w: w.right_panel.show_tab_by_key("export"), @@ -785,6 +795,6 @@ def _edge_burn(w: "MainWindow") -> Optional[QWidget]: "between files." ), target=lambda w: None, - pre_hook=lambda w: w.right_panel.show_tab_by_key("setup"), + pre_hook=lambda w: w.right_panel.show_tab_by_key("roll"), ), ] diff --git a/negpy/features/metadata/gear_logic.py b/negpy/features/metadata/gear_logic.py index 6a9db9e13..26ccb23ba 100644 --- a/negpy/features/metadata/gear_logic.py +++ b/negpy/features/metadata/gear_logic.py @@ -2,14 +2,80 @@ from __future__ import annotations +import copy +import uuid from dataclasses import replace -from typing import Optional, Union +from typing import Optional, Sequence, Union from negpy.features.metadata.gear_models import Camera, DevelopmentProcess, FilmStock, GearLibrary, Lens, ScanSetup from negpy.features.metadata.models import PUSH_PULL_LABELS, MetadataConfig GearItem = Union[Camera, Lens, FilmStock, DevelopmentProcess, ScanSetup] +CATEGORY_SINGULAR: dict[str, str] = { + "cameras": "Camera", + "lenses": "Lens", + "film_stocks": "Film Stock", + "processes": "Process", + "scan_setups": "Scan Setup", +} + +CATEGORY_SEARCH_PLACEHOLDER: dict[str, str] = { + "cameras": "Search cameras…", + "lenses": "Search lenses…", + "film_stocks": "Search film stocks…", + "processes": "Search processes…", + "scan_setups": "Search scan setups…", +} + +# Sentinel row appended by own_gear_entries(): picking it means "not in my own gear", +# so the caller should offer the full shipped catalog instead of searching it by default. +OTHER_ID = "__other__" +OTHER_LABEL = "Other…" + + +def own_gear_entries(items: Sequence[GearItem], selected_id: str) -> tuple[list[tuple[str, str]], dict[str, str]]: + """(label, id) rows for a gear combo that defaults to personal gear: bundled items + are left out, except one already selected (so a pre-existing pick, made before this + filter existed, never disappears), with a trailing Other… row for the rest of the + catalog. Returns the rows and an id -> search-text map, since Other…'s own search + text is just its label.""" + own = [item for item in items if not item.is_bundled] + if selected_id and not any(item.id == selected_id for item in own): + legacy = next((item for item in items if item.id == selected_id), None) + if legacy is not None: + own = [*own, legacy] + search_text = {item.id: gear_search_text(item) for item in own} + entries = [(item.resolved_display_name, item.id) for item in own] + entries.append((OTHER_LABEL, OTHER_ID)) + return entries, search_text + + +def blank_gear_item(category: str) -> GearItem: + """A personal item with no reference match. Real fields (make, model, ...) start + empty -- they ride into EXIF verbatim, so a placeholder there would misdescribe + the photo. display_name is UI-only, so it carries the placeholder instead, ready + to be replaced by the name the user actually types.""" + placeholder = f"New {CATEGORY_SINGULAR[category]}" + if category == "cameras": + return Camera(display_name=placeholder) + if category == "lenses": + return Lens(display_name=placeholder) + if category == "processes": + return DevelopmentProcess(display_name=placeholder) + if category == "scan_setups": + return ScanSetup(display_name=placeholder) + return FilmStock(display_name=placeholder) + + +def clone_into_personal(source: GearItem) -> GearItem: + """A bundled item made editable: a new id and is_bundled cleared, so it saves to + the user's own file instead of the read-only shipped one.""" + dup = copy.deepcopy(source) + dup.id = uuid.uuid4().hex + dup.is_bundled = False + return dup + def gear_search_text(item: GearItem) -> str: """Lowercase searchable text for substring filtering.""" diff --git a/negpy/services/assets/gear_match.py b/negpy/services/assets/gear_match.py new file mode 100644 index 000000000..9291a4ef9 --- /dev/null +++ b/negpy/services/assets/gear_match.py @@ -0,0 +1,120 @@ +"""Suggest a camera, film stock, ISO and capture date for a newly imported roll from its +folder name. + +Camera and film stock are matched against the gear catalog (bundled reference entries plus +whatever the caller has added as their own) two ways: as shared words ("gold" in +"08_penf_gold_marbella" against "Kodak Gold 200"), and as a squashed, delimiter-free run, for +the abbreviations scan-folder names tend to use ("penf" against "Olympus Pen F", "trix" +against "Kodak Tri-X 400"). More than one candidate matching the same field is treated as no +match: a wrong guess is worse than no guess. + +ISO and capture date read the folder name directly, since they are plain numbers and dates +rather than catalog lookups. A matched film stock already carries its own canonical ISO +(applied by metadata_from_gear), so the standalone ISO guess here only fires when no stock +matched. Both guesses fall back to no match rather than picking between two plausible +readings, the same rule as gear matching. +""" + +import os +import re +from dataclasses import dataclass +from typing import Any, Optional + +from negpy.features.metadata.capture import parse_capture_date +from negpy.features.metadata.gear_models import GearLibrary +from negpy.services.assets import rolls +from negpy.services.assets.library import folder_label + +_TOKEN_RE = re.compile(r"[a-z0-9]+") +_MIN_TOKEN_LEN = 2 +_MIN_SQUASH_LEN = 4 + +# Common still-film box speeds. A folder's own roll-sequence prefix ("05_...") or a film +# format number ("120") is never one of these, so the whitelist alone keeps them out without +# tracking token position. +_ISO_VALUES = frozenset({25, 32, 40, 50, 64, 80, 100, 125, 160, 200, 250, 320, 400, 500, 640, 800, 1000, 1250, 1600, 3200, 6400}) +_DIGITS_RE = re.compile(r"\d+") +_DATE_RE = re.compile(r"(? bool: + return bool(self.camera_id or self.film_stock_id or self.iso or self.capture_date) + + +def _infer_iso(folder_name: str) -> Optional[int]: + candidates = {int(n) for t in _tokens(folder_name) for n in _DIGITS_RE.findall(t)} + plausible = candidates & _ISO_VALUES + return plausible.pop() if len(plausible) == 1 else None + + +def _infer_capture_date(folder_name: str) -> str: + found = set() + for year, month, day in _DATE_RE.findall(folder_name): + parsed = parse_capture_date(f"{year}-{month}-{day}") + if parsed is not None: + found.add(parsed.text) + return found.pop() if len(found) == 1 else "" + + +def _tokens(text: str) -> set: + return {t for t in _TOKEN_RE.findall(text.lower()) if len(t) >= _MIN_TOKEN_LEN} + + +def _squash(text: str) -> str: + return "".join(_TOKEN_RE.findall(text.lower())) + + +def _is_match(folder_tokens: set, folder_squashed: str, name_parts: list) -> bool: + name = " ".join(p for p in name_parts if p) + if not name: + return False + candidate_tokens = _tokens(name) + if folder_tokens & candidate_tokens: + return True + candidate_squashed = _squash(name) + if len(candidate_squashed) >= _MIN_SQUASH_LEN and candidate_squashed in folder_squashed: + return True + return any(len(t) >= 3 and t in candidate_squashed for t in folder_tokens) + + +def _best_match(folder_tokens: set, folder_squashed: str, items: list, name_parts) -> Optional[str]: + matches = [item.id for item in items if _is_match(folder_tokens, folder_squashed, name_parts(item))] + return matches[0] if len(matches) == 1 else None + + +def match_gear_for_folder(folder_name: str, library: GearLibrary) -> GearMatch: + folder_tokens = _tokens(folder_name) + folder_squashed = _squash(folder_name) + camera_id = _best_match(folder_tokens, folder_squashed, library.cameras, lambda c: [c.make, c.model, c.display_name]) + film_stock_id = _best_match( + folder_tokens, folder_squashed, library.film_stocks, lambda f: [f.manufacturer, f.stock_name, f.display_name] + ) + return GearMatch( + camera_id=camera_id or "", + film_stock_id=film_stock_id or "", + iso=None if film_stock_id else _infer_iso(folder_name), + capture_date=_infer_capture_date(folder_name), + ) + + +def folder_name_for_active_context(state: Any, repo: Any) -> str: + """The folder name to match gear against: the active folder roll's own folder, + else the current frame's containing directory -- callers may have no roll active + at all, from a plain Add Files/Add Folder load.""" + roll_id = state.active_roll_id + if roll_id: + entry = rolls.roll_for_id(repo, roll_id) + if entry and entry.get("kind") == "folder": + return folder_label(entry.get("folder_path", "")) + src = state.selected_file_idx + if src == -1 or src >= len(state.uploaded_files): + return "" + path = state.uploaded_files[src].get("path", "") + return folder_label(os.path.dirname(path)) if path else "" diff --git a/negpy/services/assets/library.py b/negpy/services/assets/library.py index d086bef9d..e358722d6 100644 --- a/negpy/services/assets/library.py +++ b/negpy/services/assets/library.py @@ -25,6 +25,10 @@ def _is_image(name: str) -> bool: return name.lower().endswith(tuple(SUPPORTED_RAW_EXTENSIONS)) +def folder_label(path: str) -> str: + return os.path.basename(path.rstrip(os.sep)) or path + + def folder_counts(path: str) -> tuple[int, int]: """(images, subfolders) directly inside a folder — one readdir, nothing opened. diff --git a/negpy/services/assets/rolls.py b/negpy/services/assets/rolls.py new file mode 100644 index 000000000..9f0b0e355 --- /dev/null +++ b/negpy/services/assets/rolls.py @@ -0,0 +1,173 @@ +"""Roll membership: a Roll is a named, navigable group of frames -- either a real +library folder recognized as a roll, or a virtual roll built by hand from whatever the +Film Strip currently holds (a search result, a hand-picked selection, extras added to a +folder roll that are not physically in that folder). + +Edits are not stored here and are not scoped by roll: they stay in the edits DB under +each frame's own content hash, exactly as if no Roll existed. A Roll only decides which +files show up when you open it. +""" + +import os +import time +import uuid +from typing import Any, Dict, List, Optional + +ROLLS_KEY = "rolls_by_id" + + +def _read(repo: Any) -> Dict[str, dict]: + saved = repo.get_global_setting(ROLLS_KEY, default=None) + return dict(saved) if isinstance(saved, dict) else {} + + +def _write(repo: Any, store: Dict[str, dict]) -> None: + repo.save_global_setting(ROLLS_KEY, store) + + +def saved_rolls(repo: Any) -> Dict[str, dict]: + """Every remembered roll, keyed by id.""" + return _read(repo) + + +def roll_for_id(repo: Any, roll_id: str) -> Optional[dict]: + return _read(repo).get(roll_id) + + +def folder_roll_id_for_path(repo: Any, path: str) -> Optional[str]: + """The id of the roll recognizing *path*, or None if not yet recognized.""" + for roll_id, entry in _read(repo).items(): + if entry.get("kind") == "folder" and entry.get("folder_path") == path: + return roll_id + return None + + +def recognize_folder(repo: Any, path: str, name: str = "") -> str: + """Mark *path* as a recognized folder roll. Idempotent: returns the existing id + when the folder is already recognized, without touching its stored name.""" + existing = folder_roll_id_for_path(repo, path) + if existing: + return existing + store = _read(repo) + roll_id = uuid.uuid4().hex + store[roll_id] = { + "kind": "folder", + "name": name or path.rstrip("/\\").replace("\\", "/").rsplit("/", 1)[-1] or path, + "folder_path": path, + "extra_paths": [], + "created_at": time.time(), + } + _write(repo, store) + return roll_id + + +def import_subfolders_as_rolls(repo: Any, parent_path: str) -> List[str]: + """Recognize every immediate subfolder of *parent_path* as its own folder roll. + + One level only: a subfolder's own children are not walked. Idempotent per + subfolder, so re-running over a parent that already has some rolls recognized + only creates the missing ones. + """ + try: + entries = sorted(e.path for e in os.scandir(parent_path) if e.is_dir() and not e.name.startswith(".")) + except OSError: + return [] + return [recognize_folder(repo, path) for path in entries] + + +def create_virtual_roll(repo: Any, name: str, member_paths: List[str]) -> str: + store = _read(repo) + roll_id = uuid.uuid4().hex + store[roll_id] = { + "kind": "virtual", + "name": name, + "member_paths": list(member_paths), + "created_at": time.time(), + } + _write(repo, store) + return roll_id + + +def add_extra_member(repo: Any, roll_id: str, path: str) -> None: + """Extend a roll's membership by one path: a folder roll's extra_paths, or a virtual + roll's member_paths. No-op for an unknown roll id or an already-member path.""" + store = _read(repo) + entry = store.get(roll_id) + if entry is None: + return + key = "extra_paths" if entry["kind"] == "folder" else "member_paths" + if path not in entry[key]: + entry[key] = [*entry[key], path] + _write(repo, store) + + +def rename_roll(repo: Any, roll_id: str, name: str) -> None: + store = _read(repo) + if roll_id in store: + store[roll_id]["name"] = name + _write(repo, store) + + +def rename_folder_roll_disk(repo: Any, roll_id: str, new_name: str) -> Optional[str]: + """Rename a folder roll's actual folder on disk to *new_name*, in its current + parent directory, and update the roll's own folder_path to match. Returns the + new path, or None (and nothing is touched) when the roll is not a folder roll, + its folder is missing, a sibling is already named that, or the OS rename fails + (no permission, a mount that refuses it). *new_name* equal to the folder's + current basename is a no-op success, not a collision. + """ + store = _read(repo) + entry = store.get(roll_id) + if entry is None or entry.get("kind") != "folder": + return None + old_path = entry.get("folder_path", "") + if not old_path or not os.path.isdir(old_path): + return None + parent = os.path.dirname(old_path.rstrip("/\\")) + new_path = os.path.join(parent, new_name) + if os.path.normcase(os.path.abspath(new_path)) == os.path.normcase(os.path.abspath(old_path)): + return old_path + if os.path.exists(new_path): + return None + try: + os.rename(old_path, new_path) + except OSError: + return None + entry["folder_path"] = new_path + _write(repo, store) + return new_path + + +def delete_roll(repo: Any, roll_id: str) -> None: + store = _read(repo) + if roll_id in store: + del store[roll_id] + _write(repo, store) + + +def all_rolls_sorted(repo: Any) -> List[tuple]: + """(roll_id, entry) pairs for every roll, folder and virtual alike, name-sorted.""" + return sorted(_read(repo).items(), key=lambda pair: pair[1].get("name", "").casefold()) + + +def virtual_rolls(repo: Any) -> List[tuple]: + """(roll_id, entry) pairs for every virtual roll, name-sorted.""" + return [pair for pair in all_rolls_sorted(repo) if pair[1].get("kind") == "virtual"] + + +def rolls_containing_path(repo: Any, path: str) -> List[str]: + """Every roll *path* belongs to: under a folder roll's own folder or in its + extra_paths, or listed in a virtual roll's member_paths. A cheap path comparison + against the small rolls store, not a disk walk.""" + norm = os.path.normcase(os.path.abspath(path)) + out = [] + for roll_id, entry in _read(repo).items(): + if entry.get("kind") == "folder": + folder = entry.get("folder_path", "") + folder_norm = os.path.normcase(os.path.abspath(folder)) if folder else "" + in_folder = bool(folder_norm) and norm.startswith(folder_norm + os.sep) + if in_folder or path in entry.get("extra_paths", []): + out.append(roll_id) + elif path in entry.get("member_paths", []): + out.append(roll_id) + return out diff --git a/tests/metadata/test_gear_payload.py b/tests/metadata/test_gear_payload.py index 34a570740..2bd452947 100644 --- a/tests/metadata/test_gear_payload.py +++ b/tests/metadata/test_gear_payload.py @@ -2,6 +2,7 @@ import json import os +from unittest.mock import MagicMock, patch import piexif @@ -9,6 +10,8 @@ import pytest +from PyQt6.QtWidgets import QDialog + from negpy.features.metadata.gear_models import Camera, FilmStock, GearLibrary, Lens from negpy.features.metadata.gear_logic import metadata_from_gear, matches_gear_filter @@ -85,25 +88,117 @@ def test_save_library_excludes_bundled_items(gear_dir): def test_duplicate_bundled_item_is_editable_and_persistable(gear_dir): - from negpy.desktop.view.widgets.gear_library_dialog import GearLibraryDialog + from negpy.desktop.view.widgets.gear_library_panel import GearLibraryPanel library = GearLibrary(cameras=[Camera(id="cam-bundled", make="Leica", model="M6", is_bundled=True)]) - dlg = GearLibraryDialog(library) + dlg = GearLibraryPanel(library) + + # No personal cameras yet, so the default (personal-only) list is empty; the + # bundled item is reached through the catalog toggle, the behavior under test. + dlg.items.show_catalog_btn.setChecked(True) - assert dlg.display_name_edit.isEnabled() is False - assert dlg.del_btn.isEnabled() is False + assert dlg.items.display_name_edit.isEnabled() is False + assert dlg.items.del_btn.isEnabled() is False - dlg._duplicate_item() + dlg.items._duplicate_item() dup = dlg.library().cameras[-1] assert dup.is_bundled is False assert dup.id != "cam-bundled" - assert dlg.display_name_edit.isEnabled() is True + assert dlg.items.display_name_edit.isEnabled() is True on_disk = GearProfiles._read_list(os.path.join(gear_dir, "cameras.json"), Camera) assert [c.id for c in on_disk] == [dup.id] +def test_default_list_shows_personal_gear_only(gear_dir): + from negpy.desktop.view.widgets.gear_library_panel import GearLibraryPanel + + library = GearLibrary( + cameras=[ + Camera(id="cam-bundled", make="Leica", model="M6", is_bundled=True), + Camera(id="cam-mine", make="Pentax", model="K1000"), + ] + ) + dlg = GearLibraryPanel(library) + + labels = [dlg.items.item_list.item(i).text() for i in range(dlg.items.item_list.count())] + assert labels == ["Pentax K1000"] + + dlg.items.show_catalog_btn.setChecked(True) + labels = {dlg.items.item_list.item(i).text() for i in range(dlg.items.item_list.count())} + assert labels == {"Pentax K1000", "Leica M6"} + + +def test_empty_personal_list_shows_a_hint_not_a_blank_box(gear_dir): + from negpy.desktop.view.widgets.gear_library_panel import GearLibraryPanel + + library = GearLibrary(cameras=[Camera(id="cam-bundled", make="Leica", model="M6", is_bundled=True)]) + dlg = GearLibraryPanel(library) + + assert dlg.items.empty_hint.isVisibleTo(dlg) is True + assert "cameras" in dlg.items.empty_hint.text() + assert dlg.items.item_list.isVisibleTo(dlg) is False + + dlg.items.show_catalog_btn.setChecked(True) + assert dlg.items.empty_hint.isVisibleTo(dlg) is False + assert dlg.items.item_list.isVisibleTo(dlg) is True + + +def test_add_item_opens_the_catalog_and_clones_the_pick(gear_dir): + from negpy.desktop.view.widgets.gear_library_panel import GearLibraryPanel + + library = GearLibrary(cameras=[Camera(id="cam-bundled", make="Leica", model="M6", is_bundled=True)]) + dlg = GearLibraryPanel(library) + + fake_dlg = MagicMock() + fake_dlg.exec.return_value = QDialog.DialogCode.Accepted + fake_dlg.wants_custom.return_value = False + fake_dlg.selected_id.return_value = "cam-bundled" + with patch("negpy.desktop.view.widgets.gear_library_panel.GearCatalogDialog", return_value=fake_dlg): + dlg.items._add_item() + + assert len(dlg.library().cameras) == 2 + added = dlg.library().cameras[-1] + assert added.is_bundled is False + assert added.make == "Leica" + assert added.id != "cam-bundled" + + +def test_add_item_custom_fallback_creates_a_blank_record(gear_dir): + from negpy.desktop.view.widgets.gear_library_panel import GearLibraryPanel + + library = GearLibrary(cameras=[Camera(id="cam-bundled", make="Leica", model="M6", is_bundled=True)]) + dlg = GearLibraryPanel(library) + + fake_dlg = MagicMock() + fake_dlg.exec.return_value = QDialog.DialogCode.Accepted + fake_dlg.wants_custom.return_value = True + with patch("negpy.desktop.view.widgets.gear_library_panel.GearCatalogDialog", return_value=fake_dlg): + dlg.items._add_item() + + added = dlg.library().cameras[-1] + assert added.is_bundled is False + # Real fields (make, model, ...) ride into EXIF verbatim, so they stay blank; only the + # UI-only display_name carries a placeholder, pre-selected for the name the user types. + assert added.make == "" + assert added.model == "" + assert added.display_name == "New Camera" + assert dlg.items.display_name_edit.text() == "New Camera" + + +def test_delete_item_does_not_act_on_a_bundled_selection(gear_dir): + from negpy.desktop.view.widgets.gear_library_panel import GearLibraryPanel + + library = GearLibrary(cameras=[Camera(id="cam-bundled", make="Leica", model="M6", is_bundled=True)]) + dlg = GearLibraryPanel(library) + dlg.items.show_catalog_btn.setChecked(True) + + dlg.items._delete_item() + + assert [c.id for c in dlg.library().cameras] == ["cam-bundled"] + + def test_load_and_save_library(gear_dir): library = GearLibrary( cameras=[Camera(id="c1", make="Canon", model="AE-1")], @@ -218,7 +313,7 @@ def test_searchable_gear_combo_replace_selection_after_search(): def test_gear_library_dialog_item_search_hides_non_matching_selection(): - from negpy.desktop.view.widgets.gear_library_dialog import GearLibraryDialog + from negpy.desktop.view.widgets.gear_library_panel import GearLibraryPanel library = GearLibrary( lenses=[ @@ -226,17 +321,17 @@ def test_gear_library_dialog_item_search_hides_non_matching_selection(): Lens(id="l-nikon", lens_model="Nikkor 50mm f/1.8 AI-S", make="Nikkor"), ] ) - dlg = GearLibraryDialog(library) - dlg._select_category("lenses") - dlg.item_list.setCurrentRow(0) # Canon selected + dlg = GearLibraryPanel(library) + dlg.items._select_category("lenses") + dlg.items.item_list.setCurrentRow(0) # Canon selected - dlg.item_search.setText("nik") - dlg._rebuild_item_list() + dlg.items.item_search.setText("nik") + dlg.items._rebuild_item_list() - labels = [dlg.item_list.item(i).text() for i in range(dlg.item_list.count())] + labels = [dlg.items.item_list.item(i).text() for i in range(dlg.items.item_list.count())] assert labels == ["Nikkor 50mm f/1.8 AI-S"] - assert dlg.item_list.currentRow() == -1 - assert dlg.lens_model_edit.text() == "FD 50mm f/1.4" + assert dlg.items.item_list.currentRow() == -1 + assert dlg.items.lens_model_edit.text() == "FD 50mm f/1.4" def test_metadata_from_gear_clearing_camera_id(): diff --git a/tests/test_collapsible.py b/tests/test_collapsible.py new file mode 100644 index 000000000..47124cd89 --- /dev/null +++ b/tests/test_collapsible.py @@ -0,0 +1,81 @@ +"""Tests for the shared collapsible section container (widgets/collapsible.py).""" + +from __future__ import annotations + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +import sys + +from PyQt6.QtWidgets import QApplication, QWidget + +from conftest import FakeRepo +from negpy.desktop.view.widgets.collapsible import CollapsibleSection, make_section + +if not QApplication.instance(): + _app = QApplication(sys.argv) + + +class TestCollapsibleDefault: + def test_has_a_chevron_and_a_checkable_header(self) -> None: + section = CollapsibleSection("Title") + assert section.collapsible is True + assert section.chevron_label is not None + assert section.toggle_button.isCheckable() is True + + def test_unchecking_the_header_hides_the_content(self) -> None: + section = CollapsibleSection("Title", expanded=True) + section.toggle_button.setChecked(False) + assert section.content_area.isHidden() is True + + +class TestNonCollapsible: + def test_has_no_chevron(self) -> None: + section = CollapsibleSection("Title", collapsible=False) + assert section.chevron_label is None + + def test_stays_expanded_regardless_of_the_expanded_argument(self) -> None: + section = CollapsibleSection("Title", expanded=False, collapsible=False) + assert section.content_area.isHidden() is False + + def test_header_is_not_checkable_and_a_click_does_not_collapse_it(self) -> None: + section = CollapsibleSection("Title", collapsible=False) + assert section.toggle_button.isCheckable() is False + section.toggle_button.click() + assert section.content_area.isHidden() is False + + def test_expand_is_a_no_op(self) -> None: + section = CollapsibleSection("Title", collapsible=False) + section.expand() + assert section.content_area.isHidden() is False + + def test_info_button_is_unaffected(self) -> None: + section = CollapsibleSection("Title", collapsible=False, info=True) + assert section.info_btn is not None + + def test_title_and_icon_are_unaffected(self) -> None: + import qtawesome as qta + + icon = qta.icon("fa5s.cog") + section = CollapsibleSection("Title", collapsible=False, icon=icon) + assert section.title_label.text() == "Title" + + +class TestMakeSection: + def test_collapsible_reads_and_persists_the_setting(self) -> None: + repo = FakeRepo(section_expanded_demo=False) + section = make_section(repo, "Demo", "demo", QWidget(), "fa5s.cog") + assert section.content_area.isHidden() is True + section.toggle_button.setChecked(True) + assert repo.data["section_expanded_demo"] is True + + def test_non_collapsible_ignores_the_persisted_setting(self) -> None: + repo = FakeRepo(section_expanded_demo=False) + section = make_section(repo, "Demo", "demo", QWidget(), "fa5s.cog", collapsible=False) + assert section.content_area.isHidden() is False + + def test_non_collapsible_never_writes_the_setting(self) -> None: + repo = FakeRepo() + make_section(repo, "Demo", "demo", QWidget(), "fa5s.cog", collapsible=False) + assert "section_expanded_demo" not in repo.data diff --git a/tests/test_controller.py b/tests/test_controller.py index 1a26608b1..4d74d6215 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1989,6 +1989,19 @@ def test_batch_normalization_records_history_for_other_files(self): self.assertEqual(pushed, {"h1", "h3"}) self.mock_session_manager.update_config.assert_called() + def test_request_reset_roll_resets_every_visible_frame(self): + self.controller.request_reset_roll() + + visible = [self.mock_session_manager.state.uploaded_files[i] for i in self.visible_indices] + self.mock_session_manager.reset_roll.assert_called_once_with(visible) + + def test_request_reset_roll_with_nothing_visible_does_nothing(self): + self.visible_indices = [] + + self.controller.request_reset_roll() + + self.mock_session_manager.reset_roll.assert_not_called() + class TestSessionRestore(unittest.TestCase): def setUp(self): @@ -2052,6 +2065,38 @@ def test_restore_session_no_saved_files_is_noop(self): self.controller.restore_session() self.controller.request_asset_discovery.assert_not_called() + def _mock_settings_with_rolls(self, files, active, rolls_store): + from negpy.services.assets import rolls + + def get(key, default=None): + return {"session_files": files, "session_active_path": active, rolls.ROLLS_KEY: rolls_store}.get(key, default) + + self.mock_session_manager.repo.get_global_setting.side_effect = get + + def test_restore_session_recognizes_the_roll_all_restored_paths_belong_to(self): + import os + import tempfile + + with tempfile.TemporaryDirectory() as folder: + a, b = os.path.join(folder, "a.dng"), os.path.join(folder, "b.dng") + open(a, "w").close() + open(b, "w").close() + self._mock_settings_with_rolls([a, b], b, {"roll1": {"kind": "folder", "folder_path": folder, "extra_paths": []}}) + self.controller.restore_session() + self.assertEqual(self.controller.state.active_roll_id, "roll1") + + def test_restore_session_leaves_active_roll_id_none_when_paths_disagree(self): + import os + import tempfile + + with tempfile.TemporaryDirectory() as folder_a, tempfile.TemporaryDirectory() as folder_b: + a, b = os.path.join(folder_a, "a.dng"), os.path.join(folder_b, "b.dng") + open(a, "w").close() + open(b, "w").close() + self._mock_settings_with_rolls([a, b], a, {"roll1": {"kind": "folder", "folder_path": folder_a, "extra_paths": []}}) + self.controller.restore_session() + self.assertIsNone(self.controller.state.active_roll_id) + class TestRgbScanModeReload(unittest.TestCase): def setUp(self): @@ -3389,3 +3434,240 @@ def test_missing_folder_is_reported_not_opened(self): with patch.object(self.controller, "request_asset_discovery") as discovery: self.controller.open_library_folder("/photos/gone") discovery.assert_not_called() + + def _dict_repo(self) -> None: + """Swaps self.controller.session.repo's global settings for a real dict, so a + roll written in one call is readable back in the next -- the class-wide fixture's + bare MagicMock does not round-trip.""" + store: dict = {} + self.controller.session.repo.get_global_setting.side_effect = lambda key, default=None: store.get(key, default) + self.controller.session.repo.save_global_setting.side_effect = lambda key, value: store.__setitem__(key, value) + + def test_opening_one_folder_recognizes_and_activates_it(self): + self._dict_repo() + with patch("negpy.desktop.controller.os.path.isdir", return_value=True): + with patch.object(self.controller, "request_asset_discovery"): + self.controller.open_library_folder("/photos/roll_a") + + from negpy.services.assets.rolls import folder_roll_id_for_path + + roll_id = folder_roll_id_for_path(self.controller.session.repo, "/photos/roll_a") + self.assertIsNotNone(roll_id) + self.assertEqual(self.controller.state.active_roll_id, roll_id) + + def test_opening_several_folders_leaves_no_single_active_roll(self): + self._dict_repo() + with patch("negpy.desktop.controller.os.path.isdir", return_value=True): + with patch.object(self.controller, "request_asset_discovery"): + self.controller.open_library_folders(["/photos/a", "/photos/b"]) + + self.assertIsNone(self.controller.state.active_roll_id) + + def test_adding_to_session_does_not_recognize_a_folder(self): + self._dict_repo() + with patch("negpy.desktop.controller.os.path.isdir", return_value=True): + with patch.object(self.controller, "request_asset_discovery"): + self.controller.open_library_folder("/photos/roll_a", add_to_session=True) + + from negpy.services.assets.rolls import saved_rolls + + self.assertEqual(saved_rolls(self.controller.session.repo), {}) + + def test_open_roll_loads_a_folder_rolls_own_and_extra_paths(self): + self._dict_repo() + from negpy.services.assets.rolls import add_extra_member, recognize_folder + + roll_id = recognize_folder(self.controller.session.repo, "/photos/roll_a") + add_extra_member(self.controller.session.repo, roll_id, "/elsewhere/c.nef") + + with patch.object(self.controller, "request_asset_discovery") as discovery: + self.controller.open_roll(roll_id) + + discovery.assert_called_once_with(["/photos/roll_a", "/elsewhere/c.nef"], auto_open=True, replace_existing=True) + self.assertEqual(self.controller.state.active_roll_id, roll_id) + + def test_open_roll_loads_a_virtual_rolls_member_paths(self): + self._dict_repo() + from negpy.services.assets.rolls import create_virtual_roll + + roll_id = create_virtual_roll(self.controller.session.repo, "Portra", ["/a.nef", "/b.nef"]) + + with patch.object(self.controller, "request_asset_discovery") as discovery: + self.controller.open_roll(roll_id) + + discovery.assert_called_once_with(["/a.nef", "/b.nef"], auto_open=True, replace_existing=True) + + def test_open_roll_reports_an_unknown_id(self): + self._dict_repo() + with patch.object(self.controller, "request_asset_discovery") as discovery: + self.controller.open_roll("not-a-real-id") + discovery.assert_not_called() + + def test_create_roll_from_session_saves_the_loaded_paths(self): + self._dict_repo() + self.controller.state.uploaded_files = [{"path": "/a.nef"}, {"path": "/b.nef"}] + + roll_id = self.controller.create_roll_from_session("Portra") + + from negpy.services.assets.rolls import roll_for_id + + entry = roll_for_id(self.controller.session.repo, roll_id) + self.assertEqual(entry["kind"], "virtual") + self.assertEqual(entry["name"], "Portra") + self.assertEqual(entry["member_paths"], ["/a.nef", "/b.nef"]) + self.assertEqual(self.controller.state.active_roll_id, roll_id) + + def test_request_rename_roll_display_name_only(self): + self._dict_repo() + from negpy.services.assets.rolls import create_virtual_roll, roll_for_id + + roll_id = create_virtual_roll(self.controller.session.repo, "Portra", []) + + result = self.controller.request_rename_roll(roll_id, "Portra 400", False) + + self.assertTrue(result) + self.assertEqual(roll_for_id(self.controller.session.repo, roll_id)["name"], "Portra 400") + self.controller.session.rehome_folder_paths.assert_not_called() + + def test_request_rename_roll_also_renames_the_folder_when_not_active(self): + self._dict_repo() + from negpy.services.assets.rolls import recognize_folder, roll_for_id + + with tempfile.TemporaryDirectory() as d: + old_path = os.path.join(d, "roll_a") + os.mkdir(old_path) + roll_id = recognize_folder(self.controller.session.repo, old_path) + self.controller.state.active_roll_id = None + + result = self.controller.request_rename_roll(roll_id, "roll_b", True) + + self.assertTrue(result) + new_path = os.path.join(d, "roll_b") + self.assertTrue(os.path.isdir(new_path)) + self.assertEqual(roll_for_id(self.controller.session.repo, roll_id)["folder_path"], new_path) + self.assertEqual(roll_for_id(self.controller.session.repo, roll_id)["name"], "roll_b") + self.controller.session.rehome_folder_paths.assert_not_called() + + def test_request_rename_roll_rehomes_the_active_rolls_paths(self): + self._dict_repo() + from negpy.services.assets.rolls import recognize_folder + + with tempfile.TemporaryDirectory() as d: + old_path = os.path.join(d, "roll_a") + os.mkdir(old_path) + roll_id = recognize_folder(self.controller.session.repo, old_path) + self.controller.state.active_roll_id = roll_id + + result = self.controller.request_rename_roll(roll_id, "roll_b", True) + + self.assertTrue(result) + new_path = os.path.join(d, "roll_b") + self.controller.session.rehome_folder_paths.assert_called_once_with(old_path, new_path) + + def test_request_rename_roll_disk_failure_leaves_the_display_name_alone(self): + self._dict_repo() + from negpy.services.assets.rolls import recognize_folder, roll_for_id + + with tempfile.TemporaryDirectory() as d: + old_path = os.path.join(d, "roll_a") + os.mkdir(old_path) + os.mkdir(os.path.join(d, "roll_b")) # collides with the requested new name + roll_id = recognize_folder(self.controller.session.repo, old_path) + + result = self.controller.request_rename_roll(roll_id, "roll_b", True) + + self.assertFalse(result) + entry = roll_for_id(self.controller.session.repo, roll_id) + self.assertEqual(entry["name"], "roll_a") + self.assertEqual(entry["folder_path"], old_path) + self.controller.session.rehome_folder_paths.assert_not_called() + + def test_create_roll_from_session_with_nothing_loaded(self): + self._dict_repo() + self.controller.state.uploaded_files = [] + self.assertIsNone(self.controller.create_roll_from_session("Portra")) + + def test_appending_while_a_roll_is_active_extends_its_membership(self): + self._dict_repo() + from negpy.services.assets.rolls import create_virtual_roll, roll_for_id + + roll_id = create_virtual_roll(self.controller.session.repo, "Portra", ["/a.nef"]) + self.controller.state.active_roll_id = roll_id + + self.controller._replace_after_discovery = False + self.controller._on_discovery_finished([{"path": "/b.nef", "hash": "hb", "name": "b.nef"}]) + + self.assertEqual(roll_for_id(self.controller.session.repo, roll_id)["member_paths"], ["/a.nef", "/b.nef"]) + + def test_replacing_does_not_extend_the_previously_active_roll(self): + self._dict_repo() + from negpy.services.assets.rolls import create_virtual_roll, roll_for_id + + roll_id = create_virtual_roll(self.controller.session.repo, "Portra", ["/a.nef"]) + self.controller.state.active_roll_id = roll_id + + # The replace branch reaches further into session/asset_model than this fixture's + # bare mock supports; only what this test cares about (roll membership) needs it. + self.mock_session_manager.asset_model = MagicMock(visible_actual_indices_ordered=MagicMock(return_value=[])) + self.controller._replace_after_discovery = True + self.controller._on_discovery_finished([{"path": "/b.nef", "hash": "hb", "name": "b.nef"}]) + + self.assertEqual(roll_for_id(self.controller.session.repo, roll_id)["member_paths"], ["/a.nef"]) + + def test_library_search_results_clear_the_active_roll(self): + self._dict_repo() + from negpy.services.assets.rolls import create_virtual_roll + + roll_id = create_virtual_roll(self.controller.session.repo, "Portra", ["/a.nef"]) + self.controller.state.active_roll_id = roll_id + + with patch.object(self.controller, "request_asset_discovery"): + self.controller._on_library_search_finished(["/c.nef"]) + + self.assertIsNone(self.controller.state.active_roll_id) + + def test_has_rolls_reflects_the_store(self): + self._dict_repo() + self.assertFalse(self.controller.has_rolls()) + + from negpy.services.assets.rolls import create_virtual_roll + + create_virtual_roll(self.controller.session.repo, "Portra", []) + self.assertTrue(self.controller.has_rolls()) + + def test_opening_a_folder_registers_it_as_a_search_root(self): + self._dict_repo() + with patch("negpy.desktop.controller.os.path.isdir", return_value=True): + with patch.object(self.controller, "request_asset_discovery"): + self.controller.open_library_folder("/photos/roll_a") + + self.assertIn("/photos/roll_a", self.controller.library_roots()) + + def test_adding_to_session_does_not_register_a_search_root(self): + self._dict_repo() + with patch("negpy.desktop.controller.os.path.isdir", return_value=True): + with patch.object(self.controller, "request_asset_discovery"): + self.controller.open_library_folder("/photos/roll_a", add_to_session=True) + + self.assertEqual(self.controller.library_roots(), []) + + def test_import_subfolders_as_rolls_recognizes_each_one_and_registers_the_parent(self): + self._dict_repo() + # MagicMock's own `name` kwarg sets its repr, not an attribute -- set it after. + entry_a = MagicMock(path="/scans/roll_a", is_dir=lambda: True) + entry_a.name = "roll_a" + entry_b = MagicMock(path="/scans/roll_b", is_dir=lambda: True) + entry_b.name = "roll_b" + with patch("negpy.services.assets.rolls.os.scandir", return_value=iter([entry_a, entry_b])): + roll_ids = self.controller.import_subfolders_as_rolls("/scans") + + self.assertEqual(len(roll_ids), 2) + self.assertIn("/scans", self.controller.library_roots()) + + def test_import_subfolders_as_rolls_with_none_found_registers_nothing(self): + self._dict_repo() + with patch("negpy.services.assets.rolls.os.scandir", return_value=iter([])): + roll_ids = self.controller.import_subfolders_as_rolls("/scans") + + self.assertEqual(roll_ids, []) + self.assertEqual(self.controller.library_roots(), []) diff --git a/tests/test_database_management.py b/tests/test_database_management.py index 9a8408c93..0144ec8a8 100644 --- a/tests/test_database_management.py +++ b/tests/test_database_management.py @@ -125,15 +125,17 @@ def test_clear_on_empty_db_is_a_noop(tmp_path): assert repo.database_stats()["file_settings"] == 0 -def test_clear_library_forgets_the_folders_only(tmp_path, qapp): - """The library is a list of places to look, not data — clearing it must not touch - saved edits, and must never touch the folders themselves.""" +def test_clear_library_forgets_the_rolls_only(tmp_path, qapp): + """The library is a list of rolls, not data — clearing it must not touch saved + edits, and must never touch the folders or images themselves.""" from unittest.mock import MagicMock from negpy.desktop.view.widgets.database_dialog import DatabaseDialog + from negpy.services.assets.rolls import recognize_folder, saved_rolls repo = StorageRepository(str(tmp_path / "edits.db"), str(tmp_path / "settings.db")) repo.initialize() + recognize_folder(repo, str(tmp_path)) repo.save_global_setting("library_roots", [str(tmp_path)]) repo.save_file_settings("h1", WorkspaceConfig(), file_path="/a/1.nef") @@ -146,6 +148,7 @@ def test_clear_library_forgets_the_folders_only(tmp_path, qapp): dialog._on_clear_library() assert repo.get_global_setting("library_roots") == [] + assert saved_rolls(repo) == {} assert repo.load_file_settings("h1") is not None assert tmp_path.is_dir() assert not dialog.clear_library_btn.isEnabled() diff --git a/tests/test_desktop_session.py b/tests/test_desktop_session.py index 097998473..61740290b 100644 --- a/tests/test_desktop_session.py +++ b/tests/test_desktop_session.py @@ -792,6 +792,91 @@ def test_reset_settings_is_recorded_not_wiping(self): self.mock_repo.save_history_step.assert_called_with("hash1", 1, edited) self.assertEqual(self.session.state.undo_index, 2) + def test_reset_roll_resets_the_active_frame_in_place(self): + self.session.select_file(0) + dirty = replace(self.session.state.config, exposure=replace(self.session.state.config.exposure, density=1.8)) + self.session.update_config(dirty, persist=True) + + self.session.reset_roll(self.session.state.uploaded_files) + + self.assertEqual(self.session.state.config, WorkspaceConfig()) + + def test_reset_roll_writes_other_frames_straight_to_the_db(self): + self.session.select_file(0) # hash1 active; hash2 is the "other" frame + + self.session.reset_roll(self.session.state.uploaded_files) + + self.mock_repo.save_file_settings.assert_any_call("hash2", WorkspaceConfig(), file_path="path2") + # Recorded as an external history step, undoable after switching to it. + steps = [c.args for c in self.mock_repo.save_history_step.call_args_list if c.args[0] == "hash2"] + self.assertEqual(len(steps), 2) + self.assertEqual(steps[1][2], WorkspaceConfig()) + + def test_reset_roll_does_not_touch_a_frame_outside_the_given_list(self): + self.session.select_file(0) + only_hash1 = [self.session.state.uploaded_files[0]] + + self.session.reset_roll(only_hash1) + + for c in self.mock_repo.save_file_settings.call_args_list: + self.assertNotEqual(c.args[0], "hash2") + + def test_rehome_folder_paths_repoints_every_matching_asset(self): + self.session.state.uploaded_files = [ + {"name": "a.tif", "path": "/scans/roll_a/a.tif", "hash": "ha"}, + {"name": "b.tif", "path": "/scans/roll_a/sub/b.tif", "hash": "hb"}, + {"name": "c.tif", "path": "/elsewhere/c.tif", "hash": "hc"}, + ] + + self.session.rehome_folder_paths("/scans/roll_a", "/scans/roll_b") + + paths = [f["path"] for f in self.session.state.uploaded_files] + self.assertEqual(paths, ["/scans/roll_b/a.tif", "/scans/roll_b/sub/b.tif", "/elsewhere/c.tif"]) + + def test_rehome_folder_paths_updates_the_active_file_path(self): + self.session.state.uploaded_files = [{"name": "a.tif", "path": "/scans/roll_a/a.tif", "hash": "ha"}] + self.session.state.current_file_path = "/scans/roll_a/a.tif" + + self.session.rehome_folder_paths("/scans/roll_a", "/scans/roll_b") + + self.assertEqual(self.session.state.current_file_path, "/scans/roll_b/a.tif") + + def test_rehome_folder_paths_rewrites_composite_part_paths(self): + self.session.state.uploaded_files = [ + { + "name": "triplet", + "path": "/scans/roll_a/r.tif", + "hash": "ha", + "green_path": "/scans/roll_a/g.tif", + "blue_path": "/scans/roll_a/b.tif", + }, + { + "name": "stitch", + "path": "/scans/roll_a/1.tif", + "hash": "hb", + "stitch_paths": ["/scans/roll_a/1.tif", "/scans/roll_a/2.tif"], + "stitch_transforms": [[1, 0, 0], [0, 1, 0]], + "stitch_canvas": [100, 100], + "stitch_sizes": [[50, 100], [50, 100]], + }, + ] + + self.session.rehome_folder_paths("/scans/roll_a", "/scans/roll_b") + + triplet, stitch = self.session.state.uploaded_files + self.assertEqual(triplet["green_path"], "/scans/roll_b/g.tif") + self.assertEqual(triplet["blue_path"], "/scans/roll_b/b.tif") + self.assertEqual(stitch["stitch_paths"], ["/scans/roll_b/1.tif", "/scans/roll_b/2.tif"]) + + def test_rehome_folder_paths_is_a_noop_when_nothing_matches(self): + self.session.state.uploaded_files = [{"name": "c.tif", "path": "/elsewhere/c.tif", "hash": "hc"}] + self.mock_repo.save_global_setting.reset_mock() + + self.session.rehome_folder_paths("/scans/roll_a", "/scans/roll_b") + + self.assertEqual(self.session.state.uploaded_files[0]["path"], "/elsewhere/c.tif") + self.mock_repo.save_global_setting.assert_not_called() + def test_sync_to_roll_records_target_history(self): self.mock_repo.get_max_history_index.return_value = 0 self.mock_repo.load_history_step.return_value = None diff --git a/tests/test_export_sync_to_batch.py b/tests/test_export_sync_to_batch.py new file mode 100644 index 000000000..94e50b4e3 --- /dev/null +++ b/tests/test_export_sync_to_batch.py @@ -0,0 +1,50 @@ +"""Export tab's Protect Original Metadata and Sync To Batch checkboxes: export-time +behaviors over the Metadata tab's per-frame fields, not metadata content themselves, +so they live beside the Export button rather than on that tab.""" + +from __future__ import annotations + +from dataclasses import replace + +from conftest import FakeController +from negpy.desktop.view.sidebar.export import ExportSidebar + + +def _sidebar() -> ExportSidebar: + controller = FakeController() + controller.session.update_config = lambda config, **_kwargs: setattr(controller.state, "config", config) + return ExportSidebar(controller) + + +def test_off_by_default() -> None: + sidebar = _sidebar() + assert sidebar.sync_check.isChecked() is False + assert sidebar.protect_check.isChecked() is False + + +def test_toggle_persists_to_the_metadata_config() -> None: + sidebar = _sidebar() + sidebar.sync_check.setChecked(True) + assert sidebar.state.config.metadata.sync_to_batch is True + + +def test_protect_toggle_persists_and_disables_sync_to_batch() -> None: + sidebar = _sidebar() + + sidebar.protect_check.setChecked(True) + assert sidebar.state.config.metadata.protect_original_metadata is True + assert sidebar.sync_check.isEnabled() is False + + sidebar.protect_check.setChecked(False) + assert sidebar.state.config.metadata.protect_original_metadata is False + assert sidebar.sync_check.isEnabled() is True + + +def test_sync_ui_reflects_protect_state_from_an_unrelated_config_change() -> None: + sidebar = _sidebar() + sidebar.state.config = replace(sidebar.state.config, metadata=replace(sidebar.state.config.metadata, protect_original_metadata=True)) + + sidebar.sync_ui() + + assert sidebar.protect_check.isChecked() is True + assert sidebar.sync_check.isEnabled() is False diff --git a/tests/test_file_browser_toolbar.py b/tests/test_file_browser_toolbar.py index b374cacf2..650e81773 100644 --- a/tests/test_file_browser_toolbar.py +++ b/tests/test_file_browser_toolbar.py @@ -1,5 +1,4 @@ import pytest -from negpy.desktop.view.widgets.overflow_bar import OverflowBar from negpy.desktop.session import AssetListModel from negpy.desktop.view.sidebar.session_panel import SessionPanel @@ -18,40 +17,48 @@ def panel(qapp): return panel -def _toolbar(panel) -> OverflowBar: - return panel.file_browser.findChild(OverflowBar) - +def test_sort_joins_the_library_tree_corner_row(panel): + """No top-level toolbar: Sort sits with LibraryTree's own +/refresh, sized to match + (20x20), not the taller film-strip-toolbar convention.""" + browser = panel.file_browser + tree = panel.library_tree -def test_toolbar_minimum_is_not_the_sum_of_its_buttons(panel): - """The regression this guards: a plain QHBoxLayout made the session panel unshrinkable - below every button laid end to end, so each new tool widened the panel for good.""" - toolbar = _toolbar(panel) - assert toolbar.minimumSizeHint().width() < toolbar.sizeHint().width() / 2 + assert tree.isAncestorOf(browser.sort_btn) + assert (browser.sort_btn.width(), browser.sort_btn.height()) == (20, 20) -def test_toolbar_keeps_every_action(panel): +def test_film_strip_toolbar_holds_roll_scoped_actions(panel): browser = panel.file_browser - toolbar = _toolbar(panel) expected = [ - browser.library_btn, - browser.add_files_btn, - browser.add_folder_btn, - browser.unload_btn, + browser.save_roll_btn, + browser.add_btn, browser.hot_folder_btn, browser.rgb_scan_btn, browser.half_frame_btn, browser.half_frame_menu_btn, browser.apply_btn, + browser.roll_settings_btn, + browser.unload_btn, browser.sheet_btn, - browser.sort_btn, ] - assert toolbar.buttons == expected + assert browser.film_strip_toolbar.buttons == expected + + +def test_new_roll_lives_on_the_film_strip_section_header(panel): + """Not a toolbar button (it wrapped the row to a second line): the section header's + actions menu instead, next to the info and chevron icons.""" + browser = panel.file_browser + assert not hasattr(browser, "new_roll_btn") + actions_btn = browser.frames_section.actions_btn + assert actions_btn is not None + labels = [action.text() for action in actions_btn.menu().actions()] + assert labels == ["New Roll…", "Reset Roll to Defaults…"] def test_narrowing_the_panel_raises_a_populated_overflow_menu(panel, qapp): """QToolBar's native extension menu was tried first and came up empty: widgets added with addWidget() become QWidgetActions its popup cannot host.""" - toolbar = _toolbar(panel) + toolbar = panel.file_browser.film_strip_toolbar panel.resize(420, 700) qapp.processEvents() assert not toolbar.overflow_btn.isVisible() @@ -62,7 +69,12 @@ def test_narrowing_the_panel_raises_a_populated_overflow_menu(panel, qapp): labels = [action.text() for action in toolbar.build_overflow_menu().actions()] assert labels, "overflow button with an empty menu" - assert "Sort" in labels + assert "Sheet filter" in labels + + +def test_film_strip_toolbar_minimum_is_not_the_sum_of_its_buttons(panel): + toolbar = panel.file_browser.film_strip_toolbar + assert toolbar.minimumSizeHint().width() < toolbar.sizeHint().width() def test_session_panel_shrinks_below_the_old_button_row_floor(panel): diff --git a/tests/test_file_browser_widget.py b/tests/test_file_browser_widget.py index 6fb451fa6..2eaeffbec 100644 --- a/tests/test_file_browser_widget.py +++ b/tests/test_file_browser_widget.py @@ -232,17 +232,17 @@ def test_context_menu_multi_selection_adds_apply_and_remove_selected(browser, se def test_apply_dialog_shows_header_scope_and_counts(qapp): dlg = GranularSettingsDialog(None, _edited_cfg(), "IMG_0001.cr2", show_scope=True, sel_count=2, roll_count=3) - assert dlg.sel_radio.text() == "Selected frames (2)" - assert dlg.sel_radio.isEnabled() - assert dlg.sel_radio.isChecked() # selection preferred when it has targets - assert dlg.roll_radio.text() == "Whole roll (3)" - assert dlg.roll_radio.isEnabled() + assert dlg._scope_radios.sel.text() == "Selected frames (2)" + assert dlg._scope_radios.sel.isEnabled() + assert dlg._scope_radios.sel.isChecked() # selection preferred when it has targets + assert dlg._scope_radios.roll.text() == "Whole roll (3)" + assert dlg._scope_radios.roll.isEnabled() def test_apply_dialog_defaults_to_roll_when_selection_empty(qapp): dlg = GranularSettingsDialog(None, _edited_cfg(), "IMG_0001.cr2", show_scope=True, sel_count=0, roll_count=3) - assert not dlg.sel_radio.isEnabled() - assert dlg.roll_radio.isChecked() + assert not dlg._scope_radios.sel.isEnabled() + assert dlg._scope_radios.roll.isChecked() def test_apply_dialog_check_all_and_none(qapp): @@ -259,7 +259,7 @@ def test_apply_dialog_check_all_and_none(qapp): def test_apply_dialog_apply_collects_checked_rows_and_scope(qapp): dlg = GranularSettingsDialog(None, _edited_cfg(), "IMG_0001.cr2", show_scope=True, sel_count=1, roll_count=3) - dlg.roll_radio.setChecked(True) + dlg._scope_radios.roll.setChecked(True) dlg._on_apply() labels = {r.label for r in dlg.selected()} assert "Print Density" in labels # the edited exposure setting @@ -303,6 +303,169 @@ def test_open_apply_dialog_noop_without_active_file(browser, session): session.sync_selected_settings.assert_not_called() +def test_open_roll_settings_dialog_routes_rows_and_scope_to_session(browser, session): + session.state.selected_indices = [0, 1] + session.state.selected_file_idx = 0 + session.apply_preset_fields = MagicMock(return_value=2) + + rows = [object()] + mock_dlg = MagicMock() + mock_dlg.exec.return_value = QDialog.DialogCode.Accepted + mock_dlg.selected_rows.return_value = rows + mock_dlg.selected_config.return_value = _edited_cfg() + mock_dlg.scope.return_value = "selection" + with patch("negpy.desktop.view.sidebar.files.RollSettingsDialog", return_value=mock_dlg) as ctor: + browser._open_roll_settings_dialog() + + assert ctor.call_args.kwargs["sel_count"] == 1 # 1 other selected + assert ctor.call_args.kwargs["roll_count"] == 3 # 3 other on roll + session.apply_preset_fields.assert_called_once_with(mock_dlg.selected_config.return_value, rows, "selection") + browser.controller.request_render.assert_called_once() + + +def test_open_roll_settings_dialog_noop_without_active_file(browser, session): + session.state.selected_file_idx = -1 + session.apply_preset_fields = MagicMock() + with patch("negpy.desktop.view.sidebar.files.RollSettingsDialog") as ctor: + browser._open_roll_settings_dialog() + ctor.assert_not_called() + session.apply_preset_fields.assert_not_called() + + +def test_open_roll_settings_dialog_noop_when_nothing_is_ticked(browser, session): + session.state.selected_file_idx = 0 + session.apply_preset_fields = MagicMock() + mock_dlg = MagicMock() + mock_dlg.exec.return_value = QDialog.DialogCode.Accepted + mock_dlg.selected_rows.return_value = [] + with patch("negpy.desktop.view.sidebar.files.RollSettingsDialog", return_value=mock_dlg): + browser._open_roll_settings_dialog() + session.apply_preset_fields.assert_not_called() + + +def test_open_roll_settings_dialog_also_prefills_a_gear_match(browser, session): + """The tag-icon button offers the same suggestion the import-time popup does -- + useful any time you open it, not just the one moment right after import.""" + session.state.selected_file_idx = 0 + detected = MagicMock(any=MagicMock(return_value=True), camera_id="cam1", film_stock_id="") + mock_dlg = MagicMock() + mock_dlg.exec.return_value = QDialog.DialogCode.Rejected + with ( + patch("negpy.desktop.view.sidebar.files.match_gear_for_folder", return_value=detected), + patch("negpy.desktop.view.sidebar.files.RollSettingsDialog", return_value=mock_dlg), + ): + browser._open_roll_settings_dialog() + + mock_dlg.apply_detected_gear.assert_called_once_with(camera_id="cam1", film_stock_id="") + + +def test_open_roll_settings_dialog_does_not_override_gear_already_set_in_full(browser, session): + """Both fields already carry something -- there is nothing left to suggest.""" + session.state.selected_file_idx = 0 + session.state.config = replace( + session.state.config, + metadata=replace(session.state.config.metadata, camera_id="existing", film_stock_id="existing-film"), + ) + detected = MagicMock(camera_id="cam1", film_stock_id="film1") + mock_dlg = MagicMock() + mock_dlg.exec.return_value = QDialog.DialogCode.Rejected + with ( + patch("negpy.desktop.view.sidebar.files.match_gear_for_folder", return_value=detected), + patch("negpy.desktop.view.sidebar.files.RollSettingsDialog", return_value=mock_dlg), + ): + browser._open_roll_settings_dialog() + + mock_dlg.apply_detected_gear.assert_not_called() + + +def test_open_roll_settings_dialog_suggests_only_the_field_not_already_set(browser, session): + """A camera already tagged (carried from elsewhere, set by hand) must not also block + a film-stock match that is otherwise free to suggest -- the bug behind a folder like + '08_penf_gold_200_marbella' matching Kodak Gold 200 but never offering it because an + unrelated camera happened to already be set.""" + session.state.selected_file_idx = 0 + session.state.config = replace(session.state.config, metadata=replace(session.state.config.metadata, camera_id="existing")) + detected = MagicMock(camera_id="cam1", film_stock_id="film1") + mock_dlg = MagicMock() + mock_dlg.exec.return_value = QDialog.DialogCode.Rejected + with ( + patch("negpy.desktop.view.sidebar.files.match_gear_for_folder", return_value=detected), + patch("negpy.desktop.view.sidebar.files.RollSettingsDialog", return_value=mock_dlg), + ): + browser._open_roll_settings_dialog() + + mock_dlg.apply_detected_gear.assert_called_once_with(camera_id="", film_stock_id="film1") + + +def test_folder_name_for_gear_suggestion_prefers_the_active_folder_roll(browser, session, tmp_path): + from negpy.services.assets.rolls import recognize_folder + + store: dict = {} + session.repo.get_global_setting.side_effect = lambda key, default=None: store.get(key, default) + session.repo.save_global_setting.side_effect = lambda key, value: store.__setitem__(key, value) + roll_id = recognize_folder(session.repo, str(tmp_path / "08_penf_gold_marbella")) + session.state.active_roll_id = roll_id + + assert browser._folder_name_for_gear_suggestion() == "08_penf_gold_marbella" + + +def test_folder_name_for_gear_suggestion_falls_back_to_the_current_files_folder(browser, session): + session.state.active_roll_id = None + session.state.selected_file_idx = 0 # session fixture's first file lives under /tmp + + assert browser._folder_name_for_gear_suggestion() == "tmp" + + +def test_folder_name_for_gear_suggestion_is_empty_without_an_active_file(browser, session): + session.state.active_roll_id = None + session.state.selected_file_idx = -1 + + assert browser._folder_name_for_gear_suggestion() == "" + + +def test_maybe_suggest_gear_opens_the_dialog_prefilled_when_something_matches(browser, session): + session.state.selected_file_idx = 0 + session.apply_preset_fields = MagicMock(return_value=1) + detected = MagicMock(any=MagicMock(return_value=True), camera_id="cam1", film_stock_id="film1") + mock_dlg = MagicMock() + mock_dlg.exec.return_value = QDialog.DialogCode.Accepted + mock_dlg.selected_rows.return_value = [object()] + mock_dlg.selected_config.return_value = _edited_cfg() + mock_dlg.scope.return_value = "roll" + with ( + patch("negpy.desktop.view.sidebar.files.match_gear_for_folder", return_value=detected), + patch("negpy.desktop.view.sidebar.files.RollSettingsDialog", return_value=mock_dlg), + ): + browser._maybe_suggest_gear("/library/08_penf_gold_marbella") + + mock_dlg.apply_detected_gear.assert_called_once_with(camera_id="cam1", film_stock_id="film1") + browser.controller.request_render.assert_called_once() + + +def test_maybe_suggest_gear_does_nothing_without_a_match(browser, session): + session.state.selected_file_idx = 0 + detected = MagicMock(camera_id="", film_stock_id="") + with ( + patch("negpy.desktop.view.sidebar.files.match_gear_for_folder", return_value=detected), + patch("negpy.desktop.view.sidebar.files.RollSettingsDialog") as ctor, + ): + browser._maybe_suggest_gear("/library/roll_a") + + ctor.assert_not_called() + + +def test_maybe_suggest_gear_noop_without_an_active_file(browser, session): + session.state.selected_file_idx = -1 + detected = MagicMock(any=MagicMock(return_value=True), camera_id="cam1", film_stock_id="") + with ( + patch("negpy.desktop.view.sidebar.files.match_gear_for_folder", return_value=detected), + patch("negpy.desktop.view.sidebar.files.RollSettingsDialog") as ctor, + ): + browser._maybe_suggest_gear("/library/roll_a") + + ctor.assert_not_called() + + def test_context_menu_paste_disabled_without_clipboard(browser, session): session.state.clipboard = None paste = next(a for a in browser._build_context_menu().actions() if a.text().startswith("Paste")) @@ -607,6 +770,102 @@ def test_session_menu_clear_all_clears_every_frame(browser, session): session.clear_files.assert_called_once() +def test_new_roll_menu_action_clears_the_session_like_clear_all(browser, session): + """Distinct from Unload: this is the deliberate "start over" action, for building a + roll entirely by drag-drop, so it confirms and clears everything, not the selection.""" + session.clear_files = MagicMock() + menu = browser.frames_section.actions_btn.menu() + action = next(a for a in menu.actions() if a.text() == "New Roll…") + with patch("negpy.desktop.view.sidebar.files.confirm_unload", return_value=True) as confirm: + action.trigger() + confirm.assert_called_once_with(browser, clear_all=True) + session.clear_files.assert_called_once() + + +def test_reset_roll_menu_action_resets_every_visible_frame(browser, session): + menu = browser.frames_section.actions_btn.menu() + action = next(a for a in menu.actions() if a.text() == "Reset Roll to Defaults…") + with patch("negpy.desktop.view.sidebar.files.confirm_reset_roll", return_value=True) as confirm: + action.trigger() + confirm.assert_called_once_with(browser, 4) # the session fixture's 4 uploaded_files + browser.controller.request_reset_roll.assert_called_once() + + +def test_reset_roll_menu_action_cancelled_does_nothing(browser, session): + with patch("negpy.desktop.view.sidebar.files.confirm_reset_roll", return_value=False): + browser._on_reset_roll() + browser.controller.request_reset_roll.assert_not_called() + + +def test_reset_roll_with_nothing_loaded_never_prompts(browser, session): + session.state.uploaded_files = [] + session.asset_model.refresh() + with patch("negpy.desktop.view.sidebar.files.confirm_reset_roll") as confirm: + browser._on_reset_roll() + confirm.assert_not_called() + browser.controller.request_reset_roll.assert_not_called() + + +def test_unload_button_always_targets_the_selection_never_the_whole_roll(browser, session): + """The toolbar button never falls back to Clear All: opening a different roll already + replaces the film strip, so a stray click with nothing multi-selected must remove only + the active frame, not wipe everything.""" + session.remove_current_file = MagicMock() + session.remove_selected_files = MagicMock() + session.clear_files = MagicMock() + + with patch("negpy.desktop.view.sidebar.files.confirm_unload", return_value=True): + session.state.selected_indices = [1] + browser._on_unload_clicked() + session.remove_current_file.assert_called_once() + session.remove_selected_files.assert_not_called() + + session.state.selected_indices = [0, 1] + browser._on_unload_clicked() + session.remove_selected_files.assert_called_once() + + session.clear_files.assert_not_called() + + +def test_unload_button_tooltip_reflects_the_selection(browser, session): + session.state.selected_indices = [0] + browser._update_unload_button() + assert browser.unload_btn.toolTip() == "Unload…" + + session.state.selected_indices = [0, 1] + browser._update_unload_button() + assert browser.unload_btn.toolTip() == "Unload Selected…" + + +def test_save_roll_prompts_for_a_name_and_refreshes_the_tree(browser): + browser.controller.create_roll_from_session.return_value = "roll-1" + browser.library_tree = MagicMock() + with patch("negpy.desktop.view.sidebar.files.QInputDialog.getText", return_value=("Portra", True)): + browser._on_save_roll_clicked() + + browser.controller.create_roll_from_session.assert_called_once_with("Portra") + browser.library_tree.reload.assert_called_once() + + +def test_save_roll_cancelled_does_nothing(browser): + browser.controller.create_roll_from_session = MagicMock() + with patch("negpy.desktop.view.sidebar.files.QInputDialog.getText", return_value=("Portra", False)): + browser._on_save_roll_clicked() + + browser.controller.create_roll_from_session.assert_not_called() + + +def test_save_roll_rejects_an_invalid_name(browser): + browser.controller.create_roll_from_session = MagicMock() + with ( + patch("negpy.desktop.view.sidebar.files.QInputDialog.getText", return_value=("bad/name", True)), + patch("negpy.desktop.view.sidebar.files.QMessageBox.warning"), + ): + browser._on_save_roll_clicked() + + browser.controller.create_roll_from_session.assert_not_called() + + # --- Composite badges ----------------------------------------------------- diff --git a/tests/test_filmstrip_filter_visibility.py b/tests/test_filmstrip_filter_visibility.py index c6587bdec..ffc876591 100644 --- a/tests/test_filmstrip_filter_visibility.py +++ b/tests/test_filmstrip_filter_visibility.py @@ -8,6 +8,7 @@ from negpy.desktop.session import AssetListModel from negpy.desktop.view.sidebar.session_panel import SessionPanel +from negpy.services.assets.rolls import create_virtual_roll from conftest import FakeController as _Controller, FakeRepo as _Repo @@ -96,6 +97,17 @@ def test_both_filters_are_named_together(qapp): assert "search and Keepers filter" in browser.empty_label.text() +def test_the_tally_leads_with_the_active_rolls_name(qapp): + repo = _Repo() + roll_id = create_virtual_roll(repo, "Portra 400", []) + browser = _browser(qapp, repo=repo) + browser.controller.state.active_roll_id = roll_id + + browser._update_tally() + + assert browser.tally_label.text() == "Portra 400 — 36 frames" + + def test_an_empty_session_shows_neither_tally_nor_message(qapp): """No frames loaded is not a filtered strip: the panel stays quiet.""" browser = _browser(qapp, files=[]) diff --git a/tests/test_gear_match.py b/tests/test_gear_match.py new file mode 100644 index 000000000..cd032fd89 --- /dev/null +++ b/tests/test_gear_match.py @@ -0,0 +1,155 @@ +"""Folder-name matching against the gear catalog, plus the standalone ISO and capture-date +readers -- these tests exercise the matching and inference directly, no Qt.""" + +from negpy.features.metadata.gear_models import Camera, FilmStock, GearLibrary +from negpy.services.assets.gear_match import match_gear_for_folder + + +def _library(cameras=(), film_stocks=()) -> GearLibrary: + return GearLibrary(cameras=list(cameras), film_stocks=list(film_stocks)) + + +def test_a_shared_word_matches_a_film_stock(): + gold = FilmStock(manufacturer="Kodak", stock_name="Gold 200") + library = _library(film_stocks=[gold]) + + match = match_gear_for_folder("08_penf_gold_marbella", library) + + assert match.film_stock_id == gold.id + + +def test_a_squashed_abbreviation_matches_a_camera(): + pen_f = Camera(make="Olympus", model="Pen F") + library = _library(cameras=[pen_f]) + + match = match_gear_for_folder("08_penf_gold_marbella", library) + + assert match.camera_id == pen_f.id + + +def test_camera_and_film_stock_match_independently(): + pen_f = Camera(make="Olympus", model="Pen F") + gold = FilmStock(manufacturer="Kodak", stock_name="Gold 200") + library = _library(cameras=[pen_f], film_stocks=[gold]) + + match = match_gear_for_folder("08_penf_gold_marbella", library) + + assert match.camera_id == pen_f.id + assert match.film_stock_id == gold.id + assert match.any() + + +def test_an_unrelated_folder_name_matches_nothing(): + library = _library( + cameras=[Camera(make="Olympus", model="Pen F")], + film_stocks=[FilmStock(manufacturer="Kodak", stock_name="Gold 200")], + ) + + match = match_gear_for_folder("family_trip_photos", library) + + assert not match.any() + + +def test_two_candidates_sharing_a_word_is_treated_as_no_match(): + plus_a = FilmStock(manufacturer="Ilford", stock_name="HP5 Plus 400") + plus_b = FilmStock(manufacturer="Ilford", stock_name="FP4 Plus 125") + library = _library(film_stocks=[plus_a, plus_b]) + + match = match_gear_for_folder("roll_plus_test", library) + + assert match.film_stock_id == "" + + +def test_matching_is_case_insensitive(): + portra = FilmStock(manufacturer="Kodak", stock_name="Portra 400") + library = _library(film_stocks=[portra]) + + match = match_gear_for_folder("2024_PORTRA_leica", library) + + assert match.film_stock_id == portra.id + + +def test_empty_library_matches_nothing(): + match = match_gear_for_folder("08_penf_gold_marbella", GearLibrary()) + + assert not match.any() + + +def test_display_name_is_matched_when_set(): + custom = Camera(make="", model="", display_name="Widelux F7") + library = _library(cameras=[custom]) + + match = match_gear_for_folder("widelux_panorama_roll", library) + + assert match.camera_id == custom.id + + +def test_a_short_unrelated_substring_does_not_spuriously_match(): + """ "marbella" should not accidentally match a stock whose squashed name happens to + share a short run with it.""" + library = _library(film_stocks=[FilmStock(manufacturer="Kodak", stock_name="Ektar 100")]) + + match = match_gear_for_folder("08_penf_gold_marbella", library) + + assert match.film_stock_id == "" + + +def test_a_plausible_number_infers_an_iso_when_no_stock_matches(): + match = match_gear_for_folder("06_scala_50_vietnam", GearLibrary()) + + assert match.iso == 50 + + +def test_a_film_format_number_is_not_treated_as_an_iso(): + match = match_gear_for_folder("08_bronica_ilford_120_marbella", GearLibrary()) + + assert match.iso is None + + +def test_a_number_embedded_in_a_letter_prefixed_token_is_read_as_an_iso(): + """The roll-sequence prefix ("05") and the camera token ("om1hd") both carry digits too + short to be a real ISO, so only "e100" resolves.""" + match = match_gear_for_folder("05_om1hd_e100_tailandia", GearLibrary()) + + assert match.iso == 100 + + +def test_two_plausible_iso_candidates_is_treated_as_no_match(): + match = match_gear_for_folder("50_roll_400_iso", GearLibrary()) + + assert match.iso is None + + +def test_iso_is_not_inferred_standalone_once_a_stock_already_matched(): + """The matched stock's own ISO reaches MetadataConfig through metadata_from_gear, so a + standalone guess here would be redundant at best and conflicting at worst.""" + gold = FilmStock(manufacturer="Kodak", stock_name="Gold 200") + library = _library(film_stocks=[gold]) + + match = match_gear_for_folder("05_penees_kodakgold200_tailandia", library) + + assert match.film_stock_id == gold.id + assert match.iso is None + + +def test_a_date_is_inferred_from_the_folder_name(): + match = match_gear_for_folder("2024-10-17_family_trip", GearLibrary()) + + assert match.capture_date == "2024-10-17" + + +def test_a_date_with_underscores_or_no_separator_is_still_inferred(): + assert match_gear_for_folder("2024_10_17_family_trip", GearLibrary()).capture_date == "2024-10-17" + assert match_gear_for_folder("20241017_family_trip", GearLibrary()).capture_date == "2024-10-17" + + +def test_two_distinct_dates_is_treated_as_no_match(): + match = match_gear_for_folder("2024-10-17_backup_of_2023-01-05", GearLibrary()) + + assert match.capture_date == "" + + +def test_an_impossible_date_is_not_inferred(): + match = match_gear_for_folder("2024-13-40_family_trip", GearLibrary()) + + assert match.capture_date == "" diff --git a/tests/test_granular_save_mode.py b/tests/test_granular_save_mode.py index e324900c0..92cb85b09 100644 --- a/tests/test_granular_save_mode.py +++ b/tests/test_granular_save_mode.py @@ -52,13 +52,13 @@ def test_scope_current_and_apply_mode(qapp): dlg = GranularSettingsDialog( None, _edited_cfg(), "P", show_scope=True, show_current=True, show_apply_mode=True, sel_count=2, roll_count=3 ) - assert dlg.current_radio.isChecked() + assert dlg._scope_radios.current.isChecked() assert dlg.apply_mode() == "overlay" dlg._on_apply() assert dlg.scope() == "current" dlg.replace_radio.setChecked(True) - dlg.sel_radio.setChecked(True) + dlg._scope_radios.sel.setChecked(True) dlg._on_apply() assert dlg.scope() == "selection" assert dlg.apply_mode() == "replace" diff --git a/tests/test_library_tree.py b/tests/test_library_tree.py index b4e3a0075..7d94c2e3b 100644 --- a/tests/test_library_tree.py +++ b/tests/test_library_tree.py @@ -1,252 +1,426 @@ from unittest.mock import MagicMock import pytest +from PyQt6.QtCore import QPoint +from PyQt6.QtGui import QIcon +from PyQt6.QtWidgets import QFileDialog, QInputDialog, QMessageBox from negpy.desktop.view.sidebar.library_tree import LibraryTree from negpy.desktop.view.styles.theme import THEME from negpy.infrastructure.storage.repository import StorageRepository +from negpy.services.assets.rolls import create_virtual_roll, recognize_folder, roll_for_id @pytest.fixture def tree_dirs(tmp_path): - """scans/ with a container folder, two rolls, and a roll holding a subfolder.""" + """scans/ with two roll folders, one still un-imported.""" (tmp_path / "scans" / "roll_a").mkdir(parents=True) - (tmp_path / "scans" / "roll_b" / "rescans").mkdir(parents=True) - (tmp_path / "scans" / "empty_box" / "nothing").mkdir(parents=True) - (tmp_path / "scans" / ".hidden").mkdir() + (tmp_path / "scans" / "roll_b").mkdir(parents=True) (tmp_path / "scans" / "roll_a" / "a1.NEF").write_bytes(b"1") (tmp_path / "scans" / "roll_a" / "a2.NEF").write_bytes(b"2") (tmp_path / "scans" / "roll_b" / "b1.NEF").write_bytes(b"3") - (tmp_path / "scans" / "roll_b" / "rescans" / "b1_v2.NEF").write_bytes(b"4") return tmp_path / "scans" -def _make(tmp_path, roots) -> LibraryTree: +def _make(tmp_path) -> LibraryTree: repo = StorageRepository(str(tmp_path / "edits.db"), str(tmp_path / "settings.db")) repo.initialize() - repo.save_global_setting("library_roots", [str(r) for r in roots]) controller = MagicMock() controller.session.repo = repo return LibraryTree(controller) @pytest.fixture -def widget(qapp, tmp_path, tree_dirs): - return _make(tmp_path, [tree_dirs]) +def widget(qapp, tmp_path): + return _make(tmp_path) -def _labels(item) -> list[str]: - return [item.child(i).text(0) for i in range(item.childCount())] +def _names(widget) -> list[str]: + return [widget.tree.topLevelItem(i).text(0) for i in range(widget.tree.topLevelItemCount())] -def _counts(item) -> list[str]: - return [item.child(i).text(1) for i in range(item.childCount())] +# --- listing -------------------------------------------------------------- -def test_roots_appear_as_top_level_items(widget, tree_dirs): - assert widget.tree.topLevelItem(0).text(0) == "scans" +def test_empty_library_shows_the_hint(widget): + assert widget.tree.topLevelItemCount() == 0 + assert widget.empty_label.isVisibleTo(widget) + +def test_folder_and_virtual_rolls_appear_together_sorted_by_name(widget, tree_dirs): + recognize_folder(widget.repo, str(tree_dirs / "roll_b"), name="Zebra") + create_virtual_roll(widget.repo, "apple", []) + widget.reload() + + assert _names(widget) == ["apple", "Zebra"] + assert not widget.empty_label.isVisibleTo(widget) -def test_children_are_read_only_on_expand(widget): - root = widget.tree.topLevelItem(0) - assert _labels(root) == ["__unpopulated__"] - root.setExpanded(True) - assert _labels(root) == ["empty_box", "roll_a", "roll_b"] # dot-dirs excluded +def test_a_folder_roll_shows_an_amber_icon_and_its_live_count(widget, tree_dirs, monkeypatch): + recognize_folder(widget.repo, str(tree_dirs / "roll_a")) + colors = [] + monkeypatch.setattr( + "negpy.desktop.view.sidebar.library_tree.qta.icon", + lambda name, color=None: colors.append(color) or QIcon(), + ) + widget.reload() + + assert colors == [THEME.mode_c41] + assert widget.tree.topLevelItem(0).text(1) == "2 photos" + + +def test_a_virtual_roll_shows_a_red_icon_and_its_member_count(widget, monkeypatch): + create_virtual_roll(widget.repo, "Portra", ["/a.nef", "/b.nef"]) + colors = [] + monkeypatch.setattr( + "negpy.desktop.view.sidebar.library_tree.qta.icon", + lambda name, color=None: colors.append(color) or QIcon(), + ) + + widget.reload() -def test_each_folder_reports_what_is_inside(widget): - root = widget.tree.topLevelItem(0) - root.setExpanded(True) + assert colors == [THEME.roll_virtual] + assert widget.tree.topLevelItem(0).text(1) == "2 photos" - assert _counts(root) == ["1 folder", "2 photos", "1 photo · 1 folder"] +def test_selection_survives_a_reload(widget, tree_dirs): + recognize_folder(widget.repo, str(tree_dirs / "roll_a")) + recognize_folder(widget.repo, str(tree_dirs / "roll_b")) + widget.reload() + widget.tree.setCurrentItem(widget.tree.topLevelItem(1)) -def test_a_leaf_folder_has_no_expander(widget): - root = widget.tree.topLevelItem(0) - root.setExpanded(True) - roll_a = root.child(1) + widget.reload() - assert roll_a.childCount() == 0 # roll_a holds photos but no subfolders + assert widget.tree.currentItem().text(0) == "roll_b" -def test_expanding_twice_does_not_duplicate_children(widget): - root = widget.tree.topLevelItem(0) - root.setExpanded(True) - root.setExpanded(False) - root.setExpanded(True) +def test_selection_uses_the_accent_color(widget): + assert THEME.accent_primary in widget.tree.styleSheet() - assert _labels(root) == ["empty_box", "roll_a", "roll_b"] +# --- sorting ---------------------------------------------------------------- -def test_expanded_and_selected_folders_survive_a_reload(widget): - root = widget.tree.topLevelItem(0) - root.setExpanded(True) - root.child(1).setSelected(True) +def test_rolls_follow_name_sort(widget): + create_virtual_roll(widget.repo, "apple", []) + create_virtual_roll(widget.repo, "Zebra", []) widget.reload() + assert _names(widget) == ["apple", "Zebra"] + + widget.set_sort("name", True) + + assert _names(widget) == ["Zebra", "apple"] + + +def test_rolls_follow_date_sort_by_created_at(widget, monkeypatch): + import negpy.services.assets.rolls as rolls_module + + times = iter([100.0, 200.0]) + monkeypatch.setattr(rolls_module.time, "time", lambda: next(times)) + create_virtual_roll(widget.repo, "older", []) + create_virtual_roll(widget.repo, "newer", []) + + widget.set_sort("date", False) - reloaded = widget.tree.topLevelItem(0) - assert reloaded.isExpanded() - assert [i.text(0) for i in widget.tree.selectedItems()] == ["roll_a"] + assert _names(widget) == ["older", "newer"] # --- opening ------------------------------------------------------------------ -def test_a_single_click_does_not_open_anything(widget): - opened = [] - widget.folders_activated.connect(opened.append) - root = widget.tree.topLevelItem(0) - root.setExpanded(True) +def test_double_click_opens_the_roll(widget): + roll_id = create_virtual_roll(widget.repo, "Portra", ["/a.nef"]) + widget.reload() - widget.tree.itemClicked.emit(root.child(1), 0) + widget._on_double_clicked(widget.tree.topLevelItem(0), 0) - assert opened == [] + widget.controller.open_roll.assert_called_once_with(roll_id) -def test_double_click_opens_that_folder(widget, tree_dirs): - opened = [] - widget.folders_activated.connect(opened.append) - root = widget.tree.topLevelItem(0) - root.setExpanded(True) +def test_enter_opens_the_selected_roll(widget): + roll_id = create_virtual_roll(widget.repo, "Portra", ["/a.nef"]) + widget.reload() + widget.tree.setCurrentItem(widget.tree.topLevelItem(0)) - widget._on_double_clicked(root.child(1), 0) + widget.open_selection() - assert opened == [[str(tree_dirs / "roll_a")]] + widget.controller.open_roll.assert_called_once_with(roll_id) -def test_opening_a_row_inside_a_selection_opens_the_whole_selection(widget, tree_dirs): - opened = [] - widget.folders_activated.connect(opened.append) - root = widget.tree.topLevelItem(0) - root.setExpanded(True) - root.child(1).setSelected(True) - root.child(2).setSelected(True) +def test_enter_with_nothing_selected_opens_nothing(widget): + widget.open_selection() - widget._on_double_clicked(root.child(1), 0) + widget.controller.open_roll.assert_not_called() - assert opened == [[str(tree_dirs / "roll_a"), str(tree_dirs / "roll_b")]] +# --- rename / delete ------------------------------------------------------------ -def test_opening_a_row_outside_the_selection_opens_only_that_row(widget, tree_dirs): - opened = [] - widget.folders_activated.connect(opened.append) - root = widget.tree.topLevelItem(0) - root.setExpanded(True) - root.child(1).setSelected(True) - widget._on_double_clicked(root.child(2), 0) +def test_renaming_a_roll(widget, monkeypatch): + roll_id = create_virtual_roll(widget.repo, "Portra", []) + monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("Portra 400", True))) - assert opened == [[str(tree_dirs / "roll_b")]] + widget._rename_roll(roll_id, "Portra") + assert roll_for_id(widget.repo, roll_id)["name"] == "Portra 400" + assert widget.tree.topLevelItem(0).text(0) == "Portra 400" -def test_select_parent_moves_up_one_level(widget, tree_dirs): - root = widget.tree.topLevelItem(0) - root.setExpanded(True) - widget.tree.setCurrentItem(root.child(1)) - widget.select_parent() +def test_renaming_to_an_invalid_name_is_rejected(widget, monkeypatch): + roll_id = create_virtual_roll(widget.repo, "Portra", []) + monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("bad/name", True))) + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: None)) - assert widget.tree.currentItem().text(0) == "scans" + widget._rename_roll(roll_id, "Portra") + assert roll_for_id(widget.repo, roll_id)["name"] == "Portra" -def test_select_parent_at_a_root_does_nothing(widget): - widget.tree.setCurrentItem(widget.tree.topLevelItem(0)) - widget.select_parent() +def test_deleting_a_roll(widget, monkeypatch): + roll_id = create_virtual_roll(widget.repo, "Portra", []) + monkeypatch.setattr("negpy.desktop.view.sidebar.library_tree.confirm_delete_named", lambda *a, **k: True) - assert widget.tree.currentItem().text(0) == "scans" + widget._delete_roll(roll_id, "Portra") + assert roll_for_id(widget.repo, roll_id) is None + assert widget.tree.topLevelItemCount() == 0 -def test_reveal_selects_and_expands_a_folder(widget, tree_dirs): - widget.reveal(str(tree_dirs / "roll_b")) - current = widget.tree.currentItem() - assert current.text(0) == "roll_b" - assert current.isSelected() and current.isExpanded() +def test_right_clicking_a_multi_selection_offers_a_bulk_delete(widget, monkeypatch): + create_virtual_roll(widget.repo, "apple", []) + create_virtual_roll(widget.repo, "banana", []) + widget.reload() + item = widget.tree.topLevelItem(0) + widget.tree.topLevelItem(0).setSelected(True) + widget.tree.topLevelItem(1).setSelected(True) + monkeypatch.setattr(widget.tree, "itemAt", lambda pos: item) + menu = MagicMock() + monkeypatch.setattr("negpy.desktop.view.sidebar.library_tree.QMenu", lambda *a, **k: menu) + + widget._show_context_menu(QPoint(0, 0)) + labels = [call.args[0] for call in menu.addAction.call_args_list] + assert any("Delete 2 Rolls" in label for label in labels) + assert "Open" not in labels + assert "Rename…" not in labels -# --- sorting ------------------------------------------------------------------ +def test_right_clicking_outside_a_multi_selection_still_targets_just_that_row(widget, monkeypatch): + create_virtual_roll(widget.repo, "apple", []) + create_virtual_roll(widget.repo, "banana", []) + widget.reload() + widget.tree.topLevelItem(0).setSelected(True) + other = widget.tree.topLevelItem(1) + monkeypatch.setattr(widget.tree, "itemAt", lambda pos: other) + menu = MagicMock() + monkeypatch.setattr("negpy.desktop.view.sidebar.library_tree.QMenu", lambda *a, **k: menu) -def test_folders_follow_the_sheet_sort(widget): - root = widget.tree.topLevelItem(0) - root.setExpanded(True) - assert _labels(root) == ["empty_box", "roll_a", "roll_b"] + widget._show_context_menu(QPoint(0, 0)) - widget.set_sort("name", True) - assert _labels(widget.tree.topLevelItem(0)) == ["roll_b", "roll_a", "empty_box"] + labels = [call.args[0] for call in menu.addAction.call_args_list] + assert "Open" in labels + assert "Delete…" in labels -def test_sorting_by_date_uses_folder_mtime(widget, tree_dirs): - import os - import time +def test_rename_roll_dialog_checkbox_defaults_off(qapp): + from negpy.desktop.view.widgets.rename_roll_dialog import RenameRollDialog - now = time.time() - os.utime(tree_dirs / "roll_a", (now - 10_000, now - 10_000)) - os.utime(tree_dirs / "roll_b", (now - 5_000, now - 5_000)) - os.utime(tree_dirs / "empty_box", (now, now)) + dlg = RenameRollDialog("roll_a") + assert dlg.rename_folder() is False + assert dlg.name() == "roll_a" - widget.set_sort("date", False) - widget.tree.topLevelItem(0).setExpanded(True) - assert _labels(widget.tree.topLevelItem(0)) == ["roll_a", "roll_b", "empty_box"] +def test_rename_roll_dialog_name_is_trimmed(qapp): + from negpy.desktop.view.widgets.rename_roll_dialog import RenameRollDialog + dlg = RenameRollDialog("roll_a") + dlg.name_edit.setText(" new name ") + assert dlg.name() == "new name" -# --- roots -------------------------------------------------------------------- +def test_deleting_a_multi_selection_removes_every_selected_roll(widget, monkeypatch): + id_a = create_virtual_roll(widget.repo, "apple", []) + id_b = create_virtual_roll(widget.repo, "banana", []) + monkeypatch.setattr("negpy.desktop.view.sidebar.library_tree.confirm_delete_several", lambda *a, **k: True) -def test_remove_root_persists_and_reloads(widget, tree_dirs): - widget.remove_root(str(tree_dirs)) + widget._delete_rolls([(id_a, "apple"), (id_b, "banana")]) - assert widget.roots() == [] + assert roll_for_id(widget.repo, id_a) is None + assert roll_for_id(widget.repo, id_b) is None assert widget.tree.topLevelItemCount() == 0 - assert widget.empty_label.isVisibleTo(widget) -def test_add_root_puts_the_new_folder_first(qapp, tmp_path, tree_dirs): - widget = _make(tmp_path, [tree_dirs / "roll_a"]) +def test_deleting_a_folder_roll_only_forgets_the_record(widget, tree_dirs, monkeypatch): + roll_id = recognize_folder(widget.repo, str(tree_dirs / "roll_a")) + monkeypatch.setattr("negpy.desktop.view.sidebar.library_tree.confirm_delete_named", lambda *a, **k: True) - widget.add_root(str(tree_dirs)) + widget._delete_roll(roll_id, "roll_a") - assert widget.roots() == [str(tree_dirs), str(tree_dirs / "roll_a")] - assert widget.primary_root() == str(tree_dirs) + assert roll_for_id(widget.repo, roll_id) is None + assert (tree_dirs / "roll_a" / "a1.NEF").exists() -def test_primary_root_skips_a_folder_that_is_gone(qapp, tmp_path, tree_dirs): - widget = _make(tmp_path, [tmp_path / "unplugged", tree_dirs]) +class _FakeRenameDialog: + """Stands in for RenameRollDialog: exec() reports the outcome an earlier call to + accept_as()/cancelled() set up, without opening a real modal dialog.""" - assert widget.primary_root() == str(tree_dirs) + _outcome = None # ("name", rename_folder) or None for rejected, set per test + def __init__(self, *_a, **_k): + pass -def test_selection_uses_the_accent_color(widget): - assert THEME.accent_primary in widget.tree.styleSheet() + def exec(self): + from PyQt6.QtWidgets import QDialog + return QDialog.DialogCode.Accepted if self._outcome is not None else QDialog.DialogCode.Rejected -def test_empty_library_shows_the_hint(qapp, tmp_path): - widget = _make(tmp_path, []) + def name(self): + return self._outcome[0] - assert widget.tree.topLevelItemCount() == 0 - assert widget.empty_label.isVisibleTo(widget) + def rename_folder(self): + return self._outcome[1] -def test_enter_opens_the_selection(widget, tree_dirs): - opened = [] - widget.folders_activated.connect(opened.append) - root = widget.tree.topLevelItem(0) - root.setExpanded(True) - root.child(2).setSelected(True) +def test_renaming_a_folder_roll_shows_the_rename_dialog_not_the_plain_one(widget, tree_dirs, monkeypatch): + roll_id = recognize_folder(widget.repo, str(tree_dirs / "roll_a")) + _FakeRenameDialog._outcome = ("roll_a_renamed", True) + monkeypatch.setattr("negpy.desktop.view.sidebar.library_tree.RenameRollDialog", _FakeRenameDialog) + widget.controller.request_rename_roll.return_value = True - widget.open_selection() + widget._rename_roll(roll_id, "roll_a") - assert opened == [[str(tree_dirs / "roll_b")]] + widget.controller.request_rename_roll.assert_called_once_with(roll_id, "roll_a_renamed", True) -def test_enter_with_nothing_selected_opens_nothing(widget): - opened = [] - widget.folders_activated.connect(opened.append) +def test_renaming_a_folder_roll_without_the_checkbox_never_touches_disk(widget, tree_dirs, monkeypatch): + roll_id = recognize_folder(widget.repo, str(tree_dirs / "roll_a")) + _FakeRenameDialog._outcome = ("roll_a_renamed", False) + monkeypatch.setattr("negpy.desktop.view.sidebar.library_tree.RenameRollDialog", _FakeRenameDialog) - widget.open_selection() + widget._rename_roll(roll_id, "roll_a") + + widget.controller.request_rename_roll.assert_not_called() + assert roll_for_id(widget.repo, roll_id)["name"] == "roll_a_renamed" + assert roll_for_id(widget.repo, roll_id)["folder_path"] == str(tree_dirs / "roll_a") + assert (tree_dirs / "roll_a").exists() # nothing on disk moved + + +def test_renaming_a_folder_roll_cancelled_calls_nothing(widget, tree_dirs, monkeypatch): + roll_id = recognize_folder(widget.repo, str(tree_dirs / "roll_a")) + _FakeRenameDialog._outcome = None + monkeypatch.setattr("negpy.desktop.view.sidebar.library_tree.RenameRollDialog", _FakeRenameDialog) + + widget._rename_roll(roll_id, "roll_a") + + widget.controller.request_rename_roll.assert_not_called() + + +def test_renaming_a_folder_roll_disk_failure_warns_and_does_not_reload(widget, tree_dirs, monkeypatch): + roll_id = recognize_folder(widget.repo, str(tree_dirs / "roll_a")) + _FakeRenameDialog._outcome = ("roll_b", True) # already taken, per tree_dirs + monkeypatch.setattr("negpy.desktop.view.sidebar.library_tree.RenameRollDialog", _FakeRenameDialog) + widget.controller.request_rename_roll.return_value = False + warned = [] + monkeypatch.setattr(QMessageBox, "warning", staticmethod(lambda *a, **k: warned.append(a))) + reloaded = [] + monkeypatch.setattr(widget, "reload", lambda: reloaded.append(True)) + + widget._rename_roll(roll_id, "roll_a") + + assert len(warned) == 1 + assert reloaded == [] + + +def test_renaming_a_virtual_roll_never_shows_the_folder_dialog(widget, monkeypatch): + roll_id = create_virtual_roll(widget.repo, "Portra", []) + monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("Portra 400", True))) + + def _boom(*_a, **_k): + raise AssertionError("RenameRollDialog must not be used for a virtual roll") + + monkeypatch.setattr("negpy.desktop.view.sidebar.library_tree.RenameRollDialog", _boom) + + widget._rename_roll(roll_id, "Portra") + + assert roll_for_id(widget.repo, roll_id)["name"] == "Portra 400" + + +# --- importing ------------------------------------------------------------------ + + +def test_import_folder_recognizes_and_opens_it(widget, tree_dirs, monkeypatch): + path = str(tree_dirs / "roll_a") + monkeypatch.setattr(QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: path)) + widget.repo.save_global_setting("library_autoload_folders", True) # skip the confirm prompt + + imported = widget.prompt_import_folder() + + assert imported is True + widget.controller.open_library_folder.assert_called_once_with(path) + + +def test_import_folder_reports_a_newly_recognized_folder(widget, tree_dirs, monkeypatch): + path = str(tree_dirs / "roll_a") + monkeypatch.setattr(QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: path)) + widget.repo.save_global_setting("library_autoload_folders", True) + created = [] + widget.folder_roll_created.connect(created.append) + + widget.prompt_import_folder() + + assert created == [path] + + +def test_import_folder_says_nothing_for_an_already_recognized_folder(widget, tree_dirs, monkeypatch): + path = str(tree_dirs / "roll_a") + recognize_folder(widget.repo, path) + monkeypatch.setattr(QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: path)) + widget.repo.save_global_setting("library_autoload_folders", True) + created = [] + widget.folder_roll_created.connect(created.append) + + widget.prompt_import_folder() + + assert created == [] + + +def test_import_folder_with_no_images_reports_status_without_opening(widget, tree_dirs, monkeypatch): + empty = tree_dirs / "empty" + empty.mkdir() + monkeypatch.setattr(QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: str(empty))) + + imported = widget.prompt_import_folder() + + assert imported is False + widget.controller.open_library_folder.assert_not_called() + widget.controller.set_status.assert_called_once() + + +def test_cancelling_the_folder_picker_imports_nothing(widget, monkeypatch): + monkeypatch.setattr(QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: "")) + + assert widget.prompt_import_folder() is False + widget.controller.open_library_folder.assert_not_called() + + +def test_import_subfolders_delegates_to_the_controller(widget, tree_dirs, monkeypatch): + monkeypatch.setattr(QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: str(tree_dirs))) + widget.controller.import_subfolders_as_rolls.return_value = ["id1", "id2"] + + imported = widget.prompt_import_subfolders() + + assert imported is True + widget.controller.import_subfolders_as_rolls.assert_called_once_with(str(tree_dirs)) + widget.controller.set_status.assert_called_once() + + +def test_import_subfolders_with_none_found_reports_status(widget, tree_dirs, monkeypatch): + empty = tree_dirs / "roll_a" # holds only files, no subfolders + monkeypatch.setattr(QFileDialog, "getExistingDirectory", staticmethod(lambda *a, **k: str(empty))) + widget.controller.import_subfolders_as_rolls.return_value = [] + + imported = widget.prompt_import_subfolders() - assert opened == [] + assert imported is False + widget.controller.set_status.assert_called_once() diff --git a/tests/test_metadata_presets.py b/tests/test_metadata_presets.py index a76ec51f0..ce1be8ee5 100644 --- a/tests/test_metadata_presets.py +++ b/tests/test_metadata_presets.py @@ -18,7 +18,7 @@ from negpy.desktop.settings_catalog import CATALOG, apply_selected_fields, preset_config, rows_for_keys, selected_flat_dict from negpy.desktop.view.sidebar import metadata as metadata_module from negpy.desktop.view.sidebar.metadata import MetadataSidebar -from negpy.desktop.view.widgets.gear_library_dialog import GearLibraryDialog +from negpy.desktop.view.widgets.gear_library_panel import GearLibraryPanel from negpy.desktop.view.widgets.granular_settings_dialog import GranularSettingsDialog from negpy.domain.models import WorkspaceConfig from negpy.features.metadata.gear_models import Camera, DevelopmentProcess, FilmFormat, FilmStock, GearLibrary, ScanSetup @@ -106,8 +106,8 @@ def qapp_dialog_library(monkeypatch, tmp_path): library = GearLibrary( processes=[DevelopmentProcess(id="p1", display_name="D-76", developer="D-76", time_seconds=570, temperature_c=20.0)] ) - dialog = GearLibraryDialog(library) - dialog._select_category("processes") + dialog = GearLibraryPanel(library) + dialog.items._select_category("processes") return dialog, library @@ -153,10 +153,9 @@ def test_manage_saves_the_current_frame_as_a_preset(sidebar: MetadataSidebar) -> dlg.exec.return_value = QDialog.DialogCode.Accepted dlg.name.return_value = "Dev only" dlg.selected.return_value = [r for r in _metadata_rows() if r.label == "Process"] - library = GearLibraryDialog(GearLibrary(), current_config=state.config) - library._select_category("metadata_presets") - with patch("negpy.desktop.view.widgets.gear_library_dialog.GranularSettingsDialog", return_value=dlg): - library._add_item() + library = GearLibraryPanel(GearLibrary(), current_config_fn=lambda: state.config) + with patch("negpy.desktop.view.widgets.gear_library_panel.GranularSettingsDialog", return_value=dlg): + library.presets._add_item() assert MetadataPresets.load_preset("Dev only") == { "developer": "D-76", @@ -180,14 +179,13 @@ def test_manage_edit_renames_and_keeps_values() -> None: "process_id": "", }, ) - library = GearLibraryDialog(GearLibrary()) - library._select_category("metadata_presets") + library = GearLibraryPanel(GearLibrary()) dlg = MagicMock() dlg.exec.return_value = QDialog.DialogCode.Accepted dlg.name.return_value = "New" dlg.selected.return_value = [r for r in _metadata_rows() if r.label == "Process"] - with patch("negpy.desktop.view.widgets.gear_library_dialog.GranularSettingsDialog", return_value=dlg): - library._edit_preset() + with patch("negpy.desktop.view.widgets.gear_library_panel.GranularSettingsDialog", return_value=dlg): + library.presets._edit_preset() assert MetadataPresets.list_presets() == ["New"] assert MetadataPresets.load_preset("New") == { @@ -202,19 +200,17 @@ def test_manage_edit_renames_and_keeps_values() -> None: def test_manage_lists_presets_and_shows_the_selected_one() -> None: MetadataPresets.save_preset("HP5", {"developer": "D-76 1+1", "push_pull": 1, "scanning": "DSLR copy-stand"}) - library = GearLibraryDialog(GearLibrary()) - library._select_category("metadata_presets") + library = GearLibraryPanel(GearLibrary()) - assert [library.item_list.item(i).text() for i in range(library.item_list.count())] == ["HP5"] - assert library.preset_name_label.text() == "HP5" + assert [library.presets.item_list.item(i).text() for i in range(library.presets.item_list.count())] == ["HP5"] + assert library.presets.preset_name_label.text() == "HP5" # Stored rows with an editor are shown as fields, filled with the preset's own values. - assert library.preset_developer_edit.text() == "D-76 1+1" - assert library.preset_developer_edit.isVisibleTo(library.preset_panel) - assert library.preset_scanning_edit.text() == "DSLR copy-stand" - assert library.preset_push_combo.currentText() == "Push +1" + assert library.presets.preset_developer_edit.text() == "D-76 1+1" + assert library.presets.preset_developer_edit.isVisibleTo(library.presets.preset_panel) + assert library.presets.preset_scanning_edit.text() == "DSLR copy-stand" + assert library.presets.preset_push_combo.currentText() == "Push +1" # Rows it does not store stay hidden. - assert not library.preset_roll_edit.isVisibleTo(library.preset_panel) - assert library.form_panel.isVisible() is False + assert not library.presets.preset_roll_edit.isVisibleTo(library.presets.preset_panel) def test_preset_fields_reach_other_frames_unchanged(): @@ -516,21 +512,21 @@ def test_exif_transliterates_the_degree_sign(self): def test_library_editor_keeps_a_value_while_the_time_is_half_typed(self, qapp_dialog_library): dialog, library = qapp_dialog_library - dialog.dev_time_edit.setText("9:") + dialog.items.dev_time_edit.setText("9:") assert library.processes[0].time_seconds == 570 - assert dialog.dev_time_edit.styleSheet() != "" + assert dialog.items.dev_time_edit.styleSheet() != "" - dialog.dev_time_edit.setText("11:15") + dialog.items.dev_time_edit.setText("11:15") assert library.processes[0].time_seconds == 675 - assert dialog.dev_time_edit.styleSheet() == "" + assert dialog.items.dev_time_edit.styleSheet() == "" def test_library_editor_treats_blank_as_an_explicit_clear(self, qapp_dialog_library): dialog, library = qapp_dialog_library - dialog.dev_time_edit.setText("") + dialog.items.dev_time_edit.setText("") assert library.processes[0].time_seconds is None - assert dialog.dev_time_edit.styleSheet() == "" + assert dialog.items.dev_time_edit.styleSheet() == "" def test_load_action_is_registered_and_dispatches(self): assert "metadata_preset_load" in REGISTRY @@ -595,9 +591,9 @@ def test_migration_carries_gear_preset_notes(self, monkeypatch, tmp_path): def test_manage_pane_edits_notes_in_place(self, qapp_dialog_library): dialog, _library = qapp_dialog_library MetadataPresets.save_preset("HP5", {"developer": "D-76"}) - dialog._select_category("metadata_presets") + dialog.presets._rebuild_item_list(select_id="HP5") - dialog.preset_notes_edit.setText("stand development") + dialog.presets.preset_notes_edit.setText("stand development") assert preset_notes(MetadataPresets.load_preset("HP5")) == "stand development" assert MetadataPresets.load_preset("HP5")["developer"] == "D-76" @@ -605,13 +601,13 @@ def test_manage_pane_edits_notes_in_place(self, qapp_dialog_library): def test_changing_which_fields_a_preset_stores_keeps_its_notes(self, qapp_dialog_library): dialog, _library = qapp_dialog_library MetadataPresets.save_preset("HP5", with_preset_notes({"developer": "D-76"}, "keep me")) - dialog._select_category("metadata_presets") + dialog.presets._rebuild_item_list(select_id="HP5") dlg = MagicMock() dlg.exec.return_value = QDialog.DialogCode.Accepted dlg.name.return_value = "HP5" dlg.selected.return_value = [r for r in _metadata_rows() if r.label == "Process"] - with patch("negpy.desktop.view.widgets.gear_library_dialog.GranularSettingsDialog", return_value=dlg): - dialog._edit_preset() + with patch("negpy.desktop.view.widgets.gear_library_panel.GranularSettingsDialog", return_value=dlg): + dialog.presets._edit_preset() assert preset_notes(MetadataPresets.load_preset("HP5")) == "keep me" @@ -621,7 +617,7 @@ def test_editing_keeps_a_row_that_stores_a_default_value(self, qapp_dialog_libra dialog, _library = qapp_dialog_library # push_pull 0 is the default, and storing it deliberately means "develop normally". MetadataPresets.save_preset("Normal dev", {"developer": "D-76", "push_pull": 0, "process_id": ""}) - dialog._select_category("metadata_presets") + dialog.presets._rebuild_item_list(select_id="Normal dev") captured = {} @@ -631,8 +627,8 @@ def _capture(parent, cfg, name, **kwargs): dlg.exec = lambda: QDialog.DialogCode.Accepted return dlg - with patch("negpy.desktop.view.widgets.gear_library_dialog.GranularSettingsDialog", _capture): - dialog._edit_preset() + with patch("negpy.desktop.view.widgets.gear_library_panel.GranularSettingsDialog", _capture): + dialog.presets._edit_preset() assert "Process" in [r.label for r in captured["dlg"].selected()] assert captured["dlg"]._show_unchanged.isChecked(), "editing lists every row, not only edited ones" @@ -663,27 +659,6 @@ def test_panel_clears_the_temperature_when_blanked(self, sidebar: MetadataSideba assert sidebar.state.config.metadata.process_temperature_c is None - def test_manage_sees_edits_the_debounce_has_not_written_yet(self, sidebar: MetadataSidebar) -> None: - sidebar.developer_edit.setText("Rodinal 1+50") - assert sidebar.state.config.metadata.developer != "Rodinal 1+50" # still pending - - opened = {} - - class _Dialog: - def __init__(self, _library, parent=None, current_config=None): - opened["config"] = current_config - self.library_changed = MagicMock() - self.presets_changed = MagicMock() - - def exec(self): - return 0 - - with patch("negpy.desktop.view.sidebar.metadata.GearLibraryDialog", _Dialog): - sidebar._open_gear_library() - - assert opened["config"].metadata.developer == "Rodinal 1+50" - assert sidebar.update_timer.isActive() is False - def test_migration_will_not_overwrite_a_name_differing_only_in_case(self, monkeypatch, tmp_path): monkeypatch.setattr(APP_CONFIG, "gear_dir", str(tmp_path / "gear")) os.makedirs(APP_CONFIG.gear_dir, exist_ok=True) @@ -757,19 +732,18 @@ def test_renaming_onto_another_preset_asks_first(self, qapp_dialog_library, monk dialog, _library = qapp_dialog_library MetadataPresets.save_preset("Source", {"developer": "D-76"}) MetadataPresets.save_preset("Existing", {"scanning": "Flextight"}) - dialog._select_category("metadata_presets") - dialog.item_list.setCurrentRow([dialog._item_label(i) for i in dialog._list_items].index("Source")) + dialog.presets._rebuild_item_list(select_id="Source") dlg = MagicMock() dlg.exec.return_value = QDialog.DialogCode.Accepted dlg.name.return_value = "Existing" dlg.selected.return_value = [r for r in _metadata_rows() if r.label == "Process"] monkeypatch.setattr( - "negpy.desktop.view.widgets.gear_library_dialog.QMessageBox.question", + "negpy.desktop.view.widgets.gear_library_panel.QMessageBox.question", lambda *_a, **_k: QMessageBox.StandardButton.No, ) - with patch("negpy.desktop.view.widgets.gear_library_dialog.GranularSettingsDialog", return_value=dlg): - dialog._edit_preset() + with patch("negpy.desktop.view.widgets.gear_library_panel.GranularSettingsDialog", return_value=dlg): + dialog.presets._edit_preset() # Declined: both presets survive untouched. assert sorted(MetadataPresets.list_presets()) == ["Existing", "Source"] @@ -801,20 +775,19 @@ def dialog(self, monkeypatch, tmp_path): film_stocks=[FilmStock(id="f1", manufacturer="Ilford", stock_name="HP5+", iso=400, format=FilmFormat.FORMAT_120)], processes=[DevelopmentProcess(id="p1", display_name="HC-110 B", developer="HC-110", dilution="1+31", time_seconds=390)], ) - dlg = GearLibraryDialog(library) + dlg = GearLibraryPanel(library) return dlg, library def _select(self, dlg, name): - dlg._select_category("metadata_presets") - dlg.item_list.setCurrentRow([dlg._item_label(i) for i in dlg._list_items].index(name)) + dlg.presets._rebuild_item_list(select_id=name) def test_swapping_the_camera_rewrites_the_resolved_values(self, dialog): dlg, _library = dialog MetadataPresets.save_preset("Kit", selected_flat_dict(WorkspaceConfig(), [r for r in _metadata_rows() if r.label == "Gear"])) self._select(dlg, "Kit") - dlg.preset_camera_combo.set_selected_id("c2") - dlg._on_preset_gear_changed() + dlg.presets.preset_camera_combo.set_selected_id("c2") + dlg.presets._on_preset_gear_changed() stored = MetadataPresets.load_preset("Kit") assert stored["camera_id"] == "c2" @@ -826,8 +799,8 @@ def test_picking_a_film_stock_carries_its_format(self, dialog): MetadataPresets.save_preset("Kit", selected_flat_dict(WorkspaceConfig(), [r for r in _metadata_rows() if r.label == "Gear"])) self._select(dlg, "Kit") - dlg.preset_film_combo.set_selected_id("f1") - dlg._on_preset_gear_changed() + dlg.presets.preset_film_combo.set_selected_id("f1") + dlg.presets._on_preset_gear_changed() stored = MetadataPresets.load_preset("Kit") assert stored["film"] == "Ilford HP5+" @@ -839,8 +812,8 @@ def test_picking_a_saved_process_fills_the_recipe(self, dialog): MetadataPresets.save_preset("Dev", selected_flat_dict(WorkspaceConfig(), [r for r in _metadata_rows() if r.label == "Process"])) self._select(dlg, "Dev") - dlg.preset_process_combo.set_selected_id("p1") - dlg._on_preset_process_picked() + dlg.presets.preset_process_combo.set_selected_id("p1") + dlg.presets._on_preset_process_picked() stored = MetadataPresets.load_preset("Dev") assert (stored["developer"], stored["process_dilution"]) == ("HC-110", "1+31") @@ -862,7 +835,7 @@ def test_typing_a_developer_unlinks_the_saved_process(self, dialog): ) self._select(dlg, "Dev") - dlg.preset_developer_edit.setText("Rodinal") + dlg.presets.preset_developer_edit.setText("Rodinal") stored = MetadataPresets.load_preset("Dev") assert stored["developer"] == "Rodinal" @@ -873,7 +846,7 @@ def test_editing_never_adds_a_row_the_preset_does_not_store(self, dialog): MetadataPresets.save_preset("Dev", {"developer": "HC-110", "push_pull": 0, "process_id": ""}) self._select(dlg, "Dev") - dlg.preset_developer_edit.setText("Rodinal") + dlg.presets.preset_developer_edit.setText("Rodinal") stored = MetadataPresets.load_preset("Dev") assert stored["developer"] == "Rodinal" @@ -895,10 +868,58 @@ def test_a_half_typed_time_does_not_erase_the_stored_one(self, dialog): ) self._select(dlg, "Dev") - dlg.preset_time_edit.setText("6:") + dlg.presets.preset_time_edit.setText("6:") assert MetadataPresets.load_preset("Dev")["process_time_seconds"] == 390 - assert dlg.preset_time_edit.styleSheet() != "" + assert dlg.presets.preset_time_edit.styleSheet() != "" + + +class TestPresetGearCombosDefaultToOwnGear: + """Same mine-vs-catalog default as the Metadata tab's own combos, since this form + edits the same camera_id / lens_id / ... fields.""" + + @pytest.fixture + def dialog(self, monkeypatch, tmp_path): + monkeypatch.setattr(APP_CONFIG, "gear_dir", str(tmp_path / "gear")) + library = GearLibrary( + cameras=[ + Camera(id="c-bundled", make="Leica", model="M6", is_bundled=True), + Camera(id="c-mine", make="Nikon", model="FM2", is_bundled=False), + ] + ) + dlg = GearLibraryPanel(library) + MetadataPresets.save_preset("Kit", selected_flat_dict(WorkspaceConfig(), [r for r in _metadata_rows() if r.label == "Gear"])) + dlg.presets._rebuild_item_list(select_id="Kit") + return dlg, library + + def test_camera_combo_defaults_to_personal_gear_only(self, dialog): + dlg, _library = dialog + from negpy.desktop.view.widgets.gear_library_panel import OTHER_ID + + ids = {item_id for _label, item_id, _search in dlg.presets.preset_camera_combo._entries} + assert ids == {"c-mine", OTHER_ID} + + def test_other_pick_clones_the_catalog_camera_and_writes_through(self, dialog): + dlg, _library = dialog + from negpy.desktop.view.widgets.gear_library_panel import OTHER_ID + + cloned = Camera(id="c-cloned", make="Leica", model="M6", is_bundled=False) + with patch("negpy.desktop.view.widgets.gear_library_panel.resolve_other_gear_pick", return_value=cloned): + dlg.presets.preset_camera_combo._commit_id(OTHER_ID) + + stored = MetadataPresets.load_preset("Kit") + assert stored["camera_id"] == "c-cloned" + assert stored["camera_make"] == "Leica" + assert any(c.id == "c-cloned" for c in _library.cameras) + + def test_other_pick_cancelled_reverts_to_no_selection(self, dialog): + dlg, _library = dialog + from negpy.desktop.view.widgets.gear_library_panel import OTHER_ID + + with patch("negpy.desktop.view.widgets.gear_library_panel.resolve_other_gear_pick", return_value=None): + dlg.presets.preset_camera_combo._commit_id(OTHER_ID) + + assert dlg.presets.preset_camera_combo.selected_id() == "" class TestMigrationNaming: @@ -979,12 +1000,11 @@ def test_editing_a_preset_leaves_an_unset_format_alone(self, monkeypatch, tmp_pa MetadataPresets.save_preset("Body only", selected_flat_dict(WorkspaceConfig(), gear_row)) assert MetadataPresets.load_preset("Body only")["format"] == "" - dlg = GearLibraryDialog(GearLibrary(cameras=[Camera(id="c1", make="Nikon", model="FM2")])) - dlg._select_category("metadata_presets") - dlg.item_list.setCurrentRow([dlg._item_label(i) for i in dlg._list_items].index("Body only")) + dlg = GearLibraryPanel(GearLibrary(cameras=[Camera(id="c1", make="Nikon", model="FM2")])) + dlg.presets._rebuild_item_list(select_id="Body only") - assert dlg.preset_format_combo.currentText() != "Other" - dlg._on_preset_value_changed() + assert dlg.presets.preset_format_combo.currentText() != "Other" + dlg.presets._on_preset_value_changed() assert MetadataPresets.load_preset("Body only")["format"] == "" @@ -1012,8 +1032,7 @@ def test_it_lists_every_row_not_only_the_edited_ones(self, monkeypatch, tmp_path monkeypatch.setattr(APP_CONFIG, "gear_dir", str(tmp_path / "gear")) base = WorkspaceConfig() cfg = replace(base, metadata=replace(base.metadata, developer="D-76")) - library = GearLibraryDialog(GearLibrary(), current_config=cfg) - library._select_category("metadata_presets") + library = GearLibraryPanel(GearLibrary(), current_config_fn=lambda: cfg) captured = {} @@ -1023,10 +1042,96 @@ def _capture(parent, config, name, **kwargs): dlg.exec = lambda: QDialog.DialogCode.Rejected return dlg - with patch("negpy.desktop.view.widgets.gear_library_dialog.GranularSettingsDialog", _capture): - library._add_item() + with patch("negpy.desktop.view.widgets.gear_library_panel.GranularSettingsDialog", _capture): + library.presets._add_item() dlg = captured["dlg"] assert dlg._show_unchanged.isChecked(), "the new-preset window lists every row" # Revealed, not ticked: only what the frame actually sets arrives selected. assert [r.label for r in dlg.selected()] == ["Process"] + + +class TestItemsPresetsSplit: + """The Gear tab's Items/Presets switcher, and what each side does and does not have.""" + + def _panel(self, monkeypatch, tmp_path, **kwargs): + monkeypatch.setattr(APP_CONFIG, "gear_dir", str(tmp_path / "gear")) + return GearLibraryPanel(GearLibrary(), **kwargs) + + def test_items_is_the_default_subtab(self, monkeypatch, tmp_path): + panel = self._panel(monkeypatch, tmp_path) + + assert panel.stack.currentWidget().widget() is panel.items + assert panel._sub_buttons[0].isChecked() is True + assert panel._sub_buttons[1].isChecked() is False + + def test_switching_subtabs_swaps_the_stack_page(self, monkeypatch, tmp_path): + panel = self._panel(monkeypatch, tmp_path) + + panel._switch_subtab(1) + + assert panel.stack.currentWidget().widget() is panel.presets + assert panel._sub_buttons[1].isChecked() is True + assert panel._sub_buttons[0].isChecked() is False + + def test_items_category_picker_has_no_presets_entry(self, monkeypatch, tmp_path): + panel = self._panel(monkeypatch, tmp_path) + + categories = [panel.items.category_list.itemData(i) for i in range(panel.items.category_list.count())] + assert categories == ["cameras", "lenses", "film_stocks", "processes", "scan_setups"] + + def test_presets_has_no_category_picker_or_catalog_toggle(self, monkeypatch, tmp_path): + panel = self._panel(monkeypatch, tmp_path) + + assert not hasattr(panel.presets, "category_list") + assert not hasattr(panel.presets, "show_catalog_btn") + + def test_catalog_toggle_is_items_only(self, monkeypatch, tmp_path): + panel = self._panel(monkeypatch, tmp_path) + + assert hasattr(panel.items, "show_catalog_btn") + assert not hasattr(panel.presets, "show_catalog_btn") + + def test_personal_gear_added_on_items_reaches_an_open_presets_combo(self, monkeypatch, tmp_path): + panel = self._panel(monkeypatch, tmp_path) + MetadataPresets.save_preset("Kit", {}) + panel.presets._rebuild_item_list(select_id="Kit") + + panel.items._add_custom_item() + added = panel.items._current_items()[-1] + + ids = {item_id for _label, item_id, _search in panel.presets.preset_camera_combo._entries} + assert added.id in ids + + def test_show_subtab_by_key_switches_the_stack_page(self, monkeypatch, tmp_path): + panel = self._panel(monkeypatch, tmp_path) + + panel.show_subtab_by_key("presets") + assert panel.stack.currentWidget().widget() is panel.presets + + panel.show_subtab_by_key("items") + assert panel.stack.currentWidget().widget() is panel.items + + def test_show_subtab_by_key_ignores_an_unknown_key(self, monkeypatch, tmp_path): + panel = self._panel(monkeypatch, tmp_path) + + panel.show_subtab_by_key("bogus") + + assert panel.stack.currentWidget().widget() is panel.items + + def test_subtab_tooltips_carry_their_bound_shortcut(self, monkeypatch, tmp_path): + # A bare, unmodified key: Ctrl/Shift combos render as platform symbols (e.g. macOS + # shows ⇧⌘ glyphs), which a literal string match can't see through. + import negpy.desktop.view.shortcut_registry as registry + + panel = self._panel(monkeypatch, tmp_path) + monkeypatch.setattr( + registry, + "key_for", + lambda action_id, bindings=None: "G" if action_id == "tab_gear_items" else "", + ) + + panel.apply_shortcut_tooltips() + + assert "G" in panel._sub_buttons[0].toolTip() + assert "G" not in panel._sub_buttons[1].toolTip() diff --git a/tests/test_metadata_sidebar.py b/tests/test_metadata_sidebar.py index db7c0c673..68ef7342c 100644 --- a/tests/test_metadata_sidebar.py +++ b/tests/test_metadata_sidebar.py @@ -9,15 +9,17 @@ import sys from dataclasses import replace +from unittest.mock import patch import piexif import pytest -from PyQt6.QtWidgets import QApplication, QCheckBox, QLabel +from PyQt6.QtWidgets import QApplication, QLabel, QScrollArea from conftest import FakeController from negpy.desktop.view.sidebar import metadata as metadata_module from negpy.desktop.view.sidebar.metadata import MetadataSidebar -from negpy.features.metadata.gear_models import GearLibrary +from negpy.desktop.view.widgets.collapsible import CollapsibleSection +from negpy.features.metadata.gear_models import Camera, FilmStock, GearLibrary if not QApplication.instance(): _app = QApplication(sys.argv) @@ -36,6 +38,68 @@ def _set_metadata(sidebar: MetadataSidebar, **changes) -> None: state.config = replace(state.config, metadata=replace(state.config.metadata, **changes)) +@pytest.fixture +def gear_sidebar(monkeypatch) -> MetadataSidebar: + library = GearLibrary( + cameras=[ + Camera(id="cam-bundled", make="Leica", model="M6", is_bundled=True), + Camera(id="cam-mine", make="Pentax", model="K1000", is_bundled=False), + ] + ) + monkeypatch.setattr(metadata_module.GearProfiles, "load_library", staticmethod(lambda: library)) + monkeypatch.setattr(metadata_module.GearProfiles, "save_library", staticmethod(lambda _lib: None)) + controller = FakeController() + controller.session.update_config = lambda config, **_kwargs: setattr(controller.state, "config", config) + return MetadataSidebar(controller) + + +class TestGearCombos: + def test_camera_combo_defaults_to_personal_gear_only(self, gear_sidebar: MetadataSidebar) -> None: + ids = {item_id for _label, item_id, _search in gear_sidebar.camera_combo._entries} + assert ids == {"cam-mine", metadata_module.OTHER_ID} + + def test_a_bundled_selection_made_before_the_filter_stays_visible(self, monkeypatch) -> None: + library = GearLibrary(cameras=[Camera(id="cam-bundled", make="Leica", model="M6", is_bundled=True)]) + monkeypatch.setattr(metadata_module.GearProfiles, "load_library", staticmethod(lambda: library)) + controller = FakeController() + controller.session.update_config = lambda config, **_kwargs: setattr(controller.state, "config", config) + controller.state.config = replace( + controller.state.config, metadata=replace(controller.state.config.metadata, camera_id="cam-bundled") + ) + sidebar = MetadataSidebar(controller) + assert sidebar.camera_combo.selected_id() == "cam-bundled" + assert sidebar.camera_combo.line_edit().text() == "Leica M6" + + def test_other_pick_clones_the_catalog_camera_into_personal_gear(self, gear_sidebar: MetadataSidebar) -> None: + cloned = Camera(id="cam-cloned", make="Leica", model="M6", is_bundled=False) + with patch.object(metadata_module, "resolve_other_gear_pick", return_value=cloned): + gear_sidebar.camera_combo._commit_id(metadata_module.OTHER_ID) + + new_id = gear_sidebar.state.config.metadata.camera_id + assert new_id == "cam-cloned" + assert gear_sidebar.camera_combo.selected_id() == new_id + assert gear_sidebar.camera_combo.line_edit().text() == "Leica M6" + + def test_other_pick_add_custom_starts_a_blank_personal_camera(self, gear_sidebar: MetadataSidebar) -> None: + custom = Camera(id="cam-custom", display_name="New Camera") + with patch.object(metadata_module, "resolve_other_gear_pick", return_value=custom): + gear_sidebar.camera_combo._commit_id(metadata_module.OTHER_ID) + + new_id = gear_sidebar.state.config.metadata.camera_id + assert new_id == "cam-custom" + assert gear_sidebar.camera_combo.line_edit().text() == "New Camera" + + def test_other_pick_cancelled_reverts_to_the_previous_selection(self, gear_sidebar: MetadataSidebar) -> None: + _set_metadata(gear_sidebar, camera_id="cam-mine") + gear_sidebar.sync_ui() + + with patch.object(metadata_module, "resolve_other_gear_pick", return_value=None): + gear_sidebar.camera_combo._commit_id(metadata_module.OTHER_ID) + + assert gear_sidebar.camera_combo.selected_id() == "cam-mine" + assert gear_sidebar.state.config.metadata.camera_id == "cam-mine" + + class TestCaptureDate: def test_invalid_date_is_flagged_and_not_persisted(self, sidebar: MetadataSidebar) -> None: _set_metadata(sidebar, capture_date="1998-07") @@ -172,24 +236,42 @@ def test_an_existing_place_needs_no_centre(self, sidebar: MetadataSidebar, monke assert seen["center"] is None -class TestSyncCheckbox: - def test_sits_at_the_top_beside_protect_and_not_inside_a_card(self, sidebar: MetadataSidebar) -> None: - order = [sidebar.layout.indexOf(w) for w in (sidebar.protect_check, sidebar.sync_check)] +class TestTabIdentity: + def test_header_and_scope_hint_lead_the_panel(self, sidebar: MetadataSidebar) -> None: + order = [sidebar.layout.indexOf(w) for w in (sidebar.metadata_title_label, sidebar.metadata_scope_hint)] assert -1 not in order - assert order[0] < order[1] < sidebar.layout.indexOf(sidebar._metadata_controls) - assert sidebar._metadata_controls.findChildren(QCheckBox).count(sidebar.sync_check) == 0 - - def test_protect_disables_it(self, sidebar: MetadataSidebar) -> None: - """Protect mode ignores the panel's fields, so syncing them would mean nothing.""" - sidebar._on_protect_toggled(True) - assert sidebar.sync_check.isEnabled() is False - sidebar._on_protect_toggled(False) - assert sidebar.sync_check.isEnabled() is True - - def test_toggle_persists(self, sidebar: MetadataSidebar) -> None: - sidebar.sync_check.setChecked(True) - sidebar._persist_all_metadata_settings() - assert sidebar.state.config.metadata.sync_to_batch is True + assert order[0] < order[1] + + +class TestPreviewPinning: + """The Preview stays visible above the per-frame cards, which scroll on their own.""" + + def test_preview_is_pinned_above_the_scrolling_cards(self, sidebar: MetadataSidebar) -> None: + assert sidebar.layout.indexOf(sidebar.preview_section) != -1 + assert sidebar.layout.indexOf(sidebar._metadata_controls) == -1 + assert isinstance(sidebar._metadata_scroll_area, QScrollArea) + assert sidebar._metadata_scroll_area.widget() is sidebar._metadata_controls + assert sidebar.layout.indexOf(sidebar.preview_section) < sidebar.layout.indexOf(sidebar._metadata_scroll_area) + + def test_preview_is_a_sibling_of_the_scrolling_controls_area(self, sidebar: MetadataSidebar) -> None: + assert sidebar.preview_section not in sidebar._metadata_controls.findChildren(CollapsibleSection) + + +class TestProtectGating: + """Protect Original Metadata is a checkbox on the Export tab, not here (it is an + export-time behavior, not metadata content), but this tab's own fields still + disable under it through the ordinary config sync.""" + + def test_protect_disables_this_tabs_fields(self, sidebar: MetadataSidebar) -> None: + assert sidebar._metadata_controls.isEnabled() is True + + _set_metadata(sidebar, protect_original_metadata=True) + sidebar.sync_ui() + assert sidebar._metadata_controls.isEnabled() is False + + _set_metadata(sidebar, protect_original_metadata=False) + sidebar.sync_ui() + assert sidebar._metadata_controls.isEnabled() is True class TestPlaceButtons: @@ -249,3 +331,117 @@ def test_clears_are_bound_actions(self, sidebar: MetadataSidebar) -> None: for action_id in ("metadata_clear_gear", "metadata_clear_process", "metadata_clear_scanning"): assert action_id in REGISTRY assert REGISTRY[action_id].default_key == "" + + +class TestGearInferFromFolder: + def _sidebar_with_folder(self, monkeypatch, folder_name: str, **library_kwargs) -> MetadataSidebar: + library = GearLibrary(**library_kwargs) + monkeypatch.setattr(metadata_module.GearProfiles, "load_library", staticmethod(lambda: library)) + controller = FakeController() + controller.session.update_config = lambda config, **_kwargs: setattr(controller.state, "config", config) + controller.state.uploaded_files = [{"path": f"/scans/{folder_name}/frame001.tif"}] + controller.state.selected_file_idx = 0 + return MetadataSidebar(controller) + + def test_infers_camera_and_film_stock_from_the_folder_name(self, monkeypatch) -> None: + sidebar = self._sidebar_with_folder( + monkeypatch, + "04_om1_Fuji400_Japon", + cameras=[Camera(id="c1", make="Olympus", model="OM-1")], + film_stocks=[FilmStock(id="f1", manufacturer="Fuji", stock_name="400")], + ) + sidebar.gear_infer_btn.click() + meta = sidebar.state.config.metadata + assert meta.camera_id == "c1" + assert meta.film_stock_id == "f1" + + def test_does_not_overwrite_an_already_set_camera(self, monkeypatch) -> None: + sidebar = self._sidebar_with_folder( + monkeypatch, + "04_om1_Fuji400_Japon", + cameras=[Camera(id="c1", make="Olympus", model="OM-1"), Camera(id="c2", make="Nikon", model="FM2")], + film_stocks=[FilmStock(id="f1", manufacturer="Fuji", stock_name="400")], + ) + _set_metadata(sidebar, camera_id="c2") + sidebar.gear_infer_btn.click() + meta = sidebar.state.config.metadata + assert meta.camera_id == "c2" + assert meta.film_stock_id == "f1" + + def test_no_match_shows_a_status_warning_and_changes_nothing(self, monkeypatch) -> None: + sidebar = self._sidebar_with_folder( + monkeypatch, + "unrelated_folder_name", + cameras=[Camera(id="c1", make="Olympus", model="OM-1")], + ) + sidebar.gear_infer_btn.click() + assert sidebar.state.config.metadata.camera_id == "" + sidebar.controller.set_status.assert_called_once() + + def test_no_open_file_shows_a_status_warning(self, monkeypatch) -> None: + library = GearLibrary() + monkeypatch.setattr(metadata_module.GearProfiles, "load_library", staticmethod(lambda: library)) + controller = FakeController() + controller.session.update_config = lambda config, **_kwargs: setattr(controller.state, "config", config) + sidebar = MetadataSidebar(controller) + + sidebar.gear_infer_btn.click() + + sidebar.controller.set_status.assert_called_once() + + def test_infers_iso_and_capture_date_from_the_folder_name(self, monkeypatch) -> None: + sidebar = self._sidebar_with_folder(monkeypatch, "06_scala_50_2024-05-12_vietnam") + sidebar.gear_infer_btn.click() + meta = sidebar.state.config.metadata + assert meta.film_iso == 50 + assert meta.capture_date == "2024-05-12" + + def test_does_not_overwrite_an_already_set_iso_or_capture_date(self, monkeypatch) -> None: + sidebar = self._sidebar_with_folder(monkeypatch, "06_scala_50_2024-05-12_vietnam") + _set_metadata(sidebar, film_iso=800, capture_date="1999-01-01") + sidebar.gear_infer_btn.click() + meta = sidebar.state.config.metadata + assert meta.film_iso == 800 + assert meta.capture_date == "1999-01-01" + + def test_iso_from_folder_stays_off_once_a_stock_matches(self, monkeypatch) -> None: + """A matched stock's own ISO reaches the frame through the gear pick, so the + standalone folder-name guess never overrides it.""" + sidebar = self._sidebar_with_folder( + monkeypatch, + "05_penees_kodakgold200_tailandia", + film_stocks=[FilmStock(id="f1", manufacturer="Kodak", stock_name="Gold 200", iso=200)], + ) + sidebar.gear_infer_btn.click() + meta = sidebar.state.config.metadata + assert meta.film_stock_id == "f1" + assert meta.film_iso == 200 + + +def _card_titled(sidebar: MetadataSidebar, title: str) -> CollapsibleSection: + for section in sidebar.findChildren(CollapsibleSection): + if section.title_label.text() == title: + return section + raise AssertionError(f"no card titled {title!r}") + + +class TestFlatCards: + """Metadata Preview and Metadata Presets are always expanded, with no collapse chevron; + every other card on the tab keeps its chevron and default expanded state.""" + + def test_preview_and_presets_have_no_chevron_and_stay_expanded(self, sidebar: MetadataSidebar) -> None: + for title in ("Metadata Preview", "Metadata Presets"): + section = _card_titled(sidebar, title) + assert section.collapsible is False + assert section.chevron_label is None + assert section.content_area.isHidden() is False + + def test_preview_header_click_does_not_collapse_it(self, sidebar: MetadataSidebar) -> None: + sidebar.preview_section.toggle_button.click() + assert sidebar.preview_section.content_area.isHidden() is False + + def test_other_cards_stay_collapsible(self, sidebar: MetadataSidebar) -> None: + for title in ("Analog Gear", "Capture", "Process", "Scanning", "Exposure"): + section = _card_titled(sidebar, title) + assert section.collapsible is True + assert section.chevron_label is not None diff --git a/tests/test_right_panel_wiring.py b/tests/test_right_panel_wiring.py index f5db92897..d431d3aec 100644 --- a/tests/test_right_panel_wiring.py +++ b/tests/test_right_panel_wiring.py @@ -1,8 +1,12 @@ -"""Signal wiring of the right panel's analysis refresh. +"""Signal wiring of the right panel's analysis refresh, and the outer Roll / Frame / +Metadata / Gear / Export / Scan tab switch. _paint_negative_peek emits image_updated only, never metrics_available, so the image_updated path must refresh the histograms itself or entering Peek Negative leaves the chart in print mode. + +Stubs on the unbound methods throughout: no test in this repo constructs a real +RightPanel, since it pulls in a full chain of sidebars that need a real controller. """ from unittest.mock import MagicMock @@ -36,3 +40,100 @@ def test_update_analysis_skips_mid_gesture_frames() -> None: RightPanel._update_analysis(panel) panel._update_histograms.assert_not_called() + + +def _group_panel_stub(*, scan_index: int = 5, active_group: int = 0, n_groups: int = 6) -> MagicMock: + panel = MagicMock() + panel._group_buttons = [MagicMock() for _ in range(n_groups)] + panel._group_icons = ["mdi6.film", "fa5s.image", "fa5s.tags", "fa5s.toolbox", "fa5s.file-export", "fa5s.camera-retro"][:n_groups] + panel._group_keys = ["roll", "frame", "metadata", "gear", "export", "scan"][:n_groups] + panel._scan_group_index = scan_index + panel._active_group = active_group + return panel + + +def test_switch_group_persists_the_index_and_updates_the_stack(): + panel = _group_panel_stub() + + RightPanel._switch_group(panel, 1) + + panel.controller.session.repo.save_global_setting.assert_called_once_with("right_panel_group", 1) + panel.group_stack.setCurrentIndex.assert_called_once_with(1) + panel.group_switcher.set_pinned.assert_called_once_with(1) + assert panel._active_group == 1 + panel._group_buttons[1].setChecked.assert_called_once_with(True) + panel._group_buttons[0].setChecked.assert_called_once_with(False) + + +def test_switch_group_activates_scan_sidebars_only_on_the_scan_tab(): + panel = _group_panel_stub(scan_index=5) + + RightPanel._switch_group(panel, 0) + panel.scan_sidebar.on_activated.assert_not_called() + panel.scanlight_sidebar.on_activated.assert_not_called() + + RightPanel._switch_group(panel, 5) + panel.scan_sidebar.on_activated.assert_called_once_with() + panel.scanlight_sidebar.on_activated.assert_called_once_with() + + +def test_show_tab_by_key_dispatches_to_a_group_tab(): + panel = _group_panel_stub() + panel._tab_keys = ["favourites", "geometry"] + + RightPanel.show_tab_by_key(panel, "metadata") + + panel._switch_group.assert_called_once_with(2) + panel._switch_tab.assert_not_called() + + +def test_show_tab_by_key_dispatches_to_a_frame_tab(): + panel = _group_panel_stub() + panel._tab_keys = ["favourites", "geometry"] + + RightPanel.show_tab_by_key(panel, "geometry") + + panel._switch_group.assert_called_once_with(1) + panel._switch_tab.assert_called_once_with(1) + + +def test_show_tab_by_key_ignores_an_unknown_key(): + panel = _group_panel_stub() + panel._tab_keys = ["favourites", "geometry"] + + RightPanel.show_tab_by_key(panel, "not-a-real-tab") + + panel._switch_group.assert_not_called() + panel._switch_tab.assert_not_called() + + +def test_reveal_section_switches_to_frame_then_the_section_tab(): + panel = _group_panel_stub() + panel._section_tab_index = {"retouch_section": 3} + + RightPanel.reveal_section(panel, "retouch_section") + + panel._switch_group.assert_called_once_with(1) + panel._switch_tab.assert_called_once_with(3) + + +def test_reveal_section_switches_to_roll_for_a_roll_section(): + """sensor_section (Calibration) lives on the Roll tab, not as a Frame sub-tab -- + switching group is the whole job, since Roll has no inner switcher to land on.""" + panel = _group_panel_stub() + panel._section_tab_index = {} + + RightPanel.reveal_section(panel, "sensor_section") + + panel._switch_group.assert_called_once_with(0) + panel._switch_tab.assert_not_called() + + +def test_reveal_section_ignores_an_unknown_section(): + panel = _group_panel_stub() + panel._section_tab_index = {} + + RightPanel.reveal_section(panel, "nope") + + panel._switch_group.assert_not_called() + panel._switch_tab.assert_not_called() diff --git a/tests/test_roll_settings_dialog.py b/tests/test_roll_settings_dialog.py new file mode 100644 index 000000000..9d5e750be --- /dev/null +++ b/tests/test_roll_settings_dialog.py @@ -0,0 +1,102 @@ +from dataclasses import replace + +from negpy.desktop.settings_catalog import all_rows, selected_flat_dict +from negpy.desktop.view.widgets.roll_settings_dialog import RollSettingsDialog +from negpy.domain.models import WorkspaceConfig +from negpy.features.metadata.gear_models import Camera, FilmStock, GearLibrary +from negpy.kernel.system.config import APP_CONFIG +from negpy.services.assets.presets import MetadataPresets + + +def _cfg(**metadata_fields) -> WorkspaceConfig: + base = WorkspaceConfig() + return replace(base, metadata=replace(base.metadata, **metadata_fields)) + + +def _dialog(cfg: WorkspaceConfig, sel_count: int = 2, roll_count: int = 5) -> RollSettingsDialog: + return RollSettingsDialog(None, cfg, GearLibrary(), sel_count=sel_count, roll_count=roll_count) + + +def test_groups_with_data_start_checked_others_do_not(qapp): + dlg = _dialog(_cfg(capture_roll="Roll007", developer="D-76")) + assert dlg._checks["Roll"].isChecked() + assert dlg._checks["Process"].isChecked() + assert not dlg._checks["Gear"].isChecked() + assert not dlg._checks["Capture Date"].isChecked() + + +def test_scope_defaults_to_whole_roll_and_reads_the_radios(qapp): + dlg = _dialog(_cfg(), sel_count=3, roll_count=8) + assert dlg.scope() == "roll" + dlg._scope_radios.sel.setChecked(True) + assert dlg.scope() == "selection" + dlg._scope_radios.current.setChecked(True) + assert dlg.scope() == "current" + + +def test_scope_falls_back_to_current_when_nothing_else_is_loaded(qapp): + dlg = _dialog(_cfg(), sel_count=0, roll_count=0) + assert dlg.scope() == "current" + + +def test_selected_rows_only_returns_checked_groups(qapp): + dlg = _dialog(_cfg(capture_roll="Roll007")) + assert [r.label for r in dlg.selected_rows()] == ["Roll"] + dlg._checks["Capture Date"].setChecked(True) + assert {r.label for r in dlg.selected_rows()} == {"Roll", "Capture Date"} + + +def test_selected_config_carries_edited_field_values(qapp): + dlg = _dialog(_cfg()) + dlg.capture_roll_edit.setText("Roll042") + dlg.developer_edit.setText("HC-110") + out = dlg.selected_config() + assert out.metadata.capture_roll == "Roll042" + assert out.metadata.developer == "HC-110" + + +def test_selected_config_keeps_stored_value_for_unreadable_dev_time(qapp): + dlg = _dialog(_cfg(process_time_seconds=390)) + dlg.dev_time_edit.setText("not a time") + assert dlg.selected_config().metadata.process_time_seconds == 390 + + +def test_load_preset_fills_fields_and_checks_its_rows(qapp, tmp_path, monkeypatch): + monkeypatch.setattr(APP_CONFIG, "presets_dir", str(tmp_path)) + preset_cfg = _cfg(capture_roll="RollX", capture_date="2020-01-01") + rows = [r for r in all_rows() if r.label in ("Roll", "Capture Date")] + MetadataPresets.save_preset("smoke", selected_flat_dict(preset_cfg, rows)) + + dlg = _dialog(_cfg()) + dlg.preset_combo.set_selected_id("smoke") + dlg._on_load_preset() + + assert dlg._checks["Roll"].isChecked() + assert dlg._checks["Capture Date"].isChecked() + assert dlg.capture_roll_edit.text() == "RollX" + assert dlg.selected_config().metadata.capture_roll == "RollX" + + +def test_load_preset_is_a_noop_when_nothing_is_selected(qapp): + dlg = _dialog(_cfg(capture_roll="Roll007")) + dlg._on_load_preset() + assert dlg.capture_roll_edit.text() == "Roll007" + + +def test_apply_detected_gear_ticks_gear_and_fills_the_combos(qapp): + camera = Camera(make="Olympus", model="Pen F") + stock = FilmStock(manufacturer="Kodak", stock_name="Gold 200") + dlg = RollSettingsDialog(None, _cfg(), GearLibrary(cameras=[camera], film_stocks=[stock]), sel_count=0, roll_count=5) + + dlg.apply_detected_gear(camera_id=camera.id, film_stock_id=stock.id) + + assert dlg._checks["Gear"].isChecked() + assert dlg.camera_combo.selected_id() == camera.id + assert dlg.film_stock_combo.selected_id() == stock.id + assert dlg.selected_config().metadata.camera_id == camera.id + + +def test_apply_detected_gear_with_nothing_detected_is_a_noop(qapp): + dlg = _dialog(_cfg()) + dlg.apply_detected_gear(camera_id="", film_stock_id="") + assert not dlg._checks["Gear"].isChecked() diff --git a/tests/test_rolls.py b/tests/test_rolls.py new file mode 100644 index 000000000..d6c1560ca --- /dev/null +++ b/tests/test_rolls.py @@ -0,0 +1,239 @@ +"""A Roll is a navigation layer over a folder or a hand-built set of paths; it never +scopes or duplicates the edits themselves.""" + +from unittest.mock import MagicMock + +from negpy.infrastructure.storage.repository import StorageRepository +from negpy.services.assets.rolls import ( + add_extra_member, + all_rolls_sorted, + create_virtual_roll, + delete_roll, + folder_roll_id_for_path, + import_subfolders_as_rolls, + recognize_folder, + rename_folder_roll_disk, + rename_roll, + roll_for_id, + rolls_containing_path, + saved_rolls, + virtual_rolls, +) + + +def _repo() -> MagicMock: + """A repository whose global settings live in a dict, so a write is readable back.""" + repo = MagicMock(spec=StorageRepository) + store: dict = {} + repo.get_global_setting.side_effect = lambda key, default=None: store.get(key, default) + repo.save_global_setting.side_effect = lambda key, value: store.__setitem__(key, value) + repo.settings = store + return repo + + +def test_recognize_folder_names_it_from_the_path(): + repo = _repo() + roll_id = recognize_folder(repo, "/scans/2024-10-portra") + entry = roll_for_id(repo, roll_id) + assert entry["kind"] == "folder" + assert entry["folder_path"] == "/scans/2024-10-portra" + assert entry["name"] == "2024-10-portra" + assert entry["extra_paths"] == [] + + +def test_recognize_folder_is_idempotent(): + repo = _repo() + first = recognize_folder(repo, "/scans/roll_a") + second = recognize_folder(repo, "/scans/roll_a") + assert first == second + assert len(saved_rolls(repo)) == 1 + + +def test_folder_roll_id_for_path_finds_a_recognized_folder(): + repo = _repo() + assert folder_roll_id_for_path(repo, "/scans/roll_a") is None + roll_id = recognize_folder(repo, "/scans/roll_a") + assert folder_roll_id_for_path(repo, "/scans/roll_a") == roll_id + + +def test_create_virtual_roll_stores_the_exact_member_list(): + repo = _repo() + roll_id = create_virtual_roll(repo, "Portra", ["/a.nef", "/b.nef"]) + entry = roll_for_id(repo, roll_id) + assert entry["kind"] == "virtual" + assert entry["name"] == "Portra" + assert entry["member_paths"] == ["/a.nef", "/b.nef"] + + +def test_add_extra_member_extends_a_folder_rolls_extra_paths(): + repo = _repo() + roll_id = recognize_folder(repo, "/scans/roll_a") + add_extra_member(repo, roll_id, "/elsewhere/c.nef") + assert roll_for_id(repo, roll_id)["extra_paths"] == ["/elsewhere/c.nef"] + + +def test_add_extra_member_extends_a_virtual_rolls_member_paths(): + repo = _repo() + roll_id = create_virtual_roll(repo, "Portra", ["/a.nef"]) + add_extra_member(repo, roll_id, "/b.nef") + assert roll_for_id(repo, roll_id)["member_paths"] == ["/a.nef", "/b.nef"] + + +def test_add_extra_member_does_not_duplicate(): + repo = _repo() + roll_id = create_virtual_roll(repo, "Portra", ["/a.nef"]) + add_extra_member(repo, roll_id, "/a.nef") + assert roll_for_id(repo, roll_id)["member_paths"] == ["/a.nef"] + + +def test_add_extra_member_on_unknown_roll_is_a_noop(): + repo = _repo() + add_extra_member(repo, "not-a-real-id", "/a.nef") + assert saved_rolls(repo) == {} + + +def test_rename_and_delete_roll(): + repo = _repo() + roll_id = create_virtual_roll(repo, "Portra", []) + rename_roll(repo, roll_id, "Portra 400") + assert roll_for_id(repo, roll_id)["name"] == "Portra 400" + + delete_roll(repo, roll_id) + assert roll_for_id(repo, roll_id) is None + assert saved_rolls(repo) == {} + + +def test_rename_folder_roll_disk_renames_and_updates_folder_path(tmp_path): + repo = _repo() + old = tmp_path / "roll_a" + old.mkdir() + (old / "frame001.tif").write_bytes(b"x") + roll_id = recognize_folder(repo, str(old)) + + new_path = rename_folder_roll_disk(repo, roll_id, "roll_b") + + assert new_path == str(tmp_path / "roll_b") + assert not old.exists() + assert (tmp_path / "roll_b" / "frame001.tif").exists() + assert roll_for_id(repo, roll_id)["folder_path"] == new_path + + +def test_rename_folder_roll_disk_refuses_a_sibling_collision(tmp_path): + repo = _repo() + old = tmp_path / "roll_a" + old.mkdir() + (tmp_path / "roll_b").mkdir() + roll_id = recognize_folder(repo, str(old)) + + assert rename_folder_roll_disk(repo, roll_id, "roll_b") is None + assert old.exists() + assert roll_for_id(repo, roll_id)["folder_path"] == str(old) + + +def test_rename_folder_roll_disk_same_name_is_a_noop_success(tmp_path): + repo = _repo() + old = tmp_path / "roll_a" + old.mkdir() + roll_id = recognize_folder(repo, str(old)) + + assert rename_folder_roll_disk(repo, roll_id, "roll_a") == str(old) + assert old.exists() + + +def test_rename_folder_roll_disk_on_a_virtual_roll_is_a_noop(tmp_path): + repo = _repo() + roll_id = create_virtual_roll(repo, "Portra", []) + assert rename_folder_roll_disk(repo, roll_id, "anything") is None + + +def test_rename_folder_roll_disk_missing_folder_is_a_noop(tmp_path): + repo = _repo() + roll_id = recognize_folder(repo, str(tmp_path / "gone")) + assert rename_folder_roll_disk(repo, roll_id, "roll_b") is None + + +def test_virtual_rolls_lists_only_virtual_ones_sorted_by_name(): + repo = _repo() + recognize_folder(repo, "/scans/roll_a") + create_virtual_roll(repo, "Zebra", []) + create_virtual_roll(repo, "apple", []) + + names = [entry["name"] for _id, entry in virtual_rolls(repo)] + assert names == ["apple", "Zebra"] + + +def test_all_rolls_sorted_lists_every_kind_by_name(): + repo = _repo() + recognize_folder(repo, "/scans/roll_a", name="Zebra") + create_virtual_roll(repo, "apple", []) + + names = [entry["name"] for _id, entry in all_rolls_sorted(repo)] + assert names == ["apple", "Zebra"] + + +def test_import_subfolders_as_rolls_recognizes_each_immediate_subfolder(tmp_path): + (tmp_path / "roll_a").mkdir() + (tmp_path / "roll_b").mkdir() + (tmp_path / ".hidden").mkdir() + (tmp_path / "roll_a" / "nested").mkdir() + repo = _repo() + + roll_ids = import_subfolders_as_rolls(repo, str(tmp_path)) + + names = sorted(roll_for_id(repo, rid)["name"] for rid in roll_ids) + assert names == ["roll_a", "roll_b"] + # One level only: "nested" inside roll_a is not recognized on its own. + assert folder_roll_id_for_path(repo, str(tmp_path / "roll_a" / "nested")) is None + + +def test_import_subfolders_as_rolls_is_idempotent_per_subfolder(tmp_path): + (tmp_path / "roll_a").mkdir() + repo = _repo() + + first = import_subfolders_as_rolls(repo, str(tmp_path)) + second = import_subfolders_as_rolls(repo, str(tmp_path)) + + assert first == second + assert len(saved_rolls(repo)) == 1 + + +def test_import_subfolders_as_rolls_on_a_missing_parent_returns_nothing(): + repo = _repo() + assert import_subfolders_as_rolls(repo, "/does/not/exist") == [] + + +def test_rolls_containing_path_finds_a_folder_roll_by_prefix(tmp_path): + repo = _repo() + folder = tmp_path / "roll_a" + folder.mkdir() + roll_id = recognize_folder(repo, str(folder)) + assert rolls_containing_path(repo, str(folder / "frame001.tif")) == [roll_id] + + +def test_rolls_containing_path_finds_a_folder_rolls_extra_member(): + repo = _repo() + roll_id = recognize_folder(repo, "/scans/roll_a") + add_extra_member(repo, roll_id, "/elsewhere/c.nef") + assert rolls_containing_path(repo, "/elsewhere/c.nef") == [roll_id] + + +def test_rolls_containing_path_finds_a_virtual_rolls_member(): + repo = _repo() + roll_id = create_virtual_roll(repo, "Portra", ["/a.nef"]) + assert rolls_containing_path(repo, "/a.nef") == [roll_id] + + +def test_rolls_containing_path_lists_every_matching_roll(tmp_path): + repo = _repo() + folder = tmp_path / "roll_a" + folder.mkdir() + frame = folder / "frame001.tif" + folder_roll = recognize_folder(repo, str(folder)) + virtual_roll = create_virtual_roll(repo, "Picks", [str(frame)]) + assert sorted(rolls_containing_path(repo, str(frame))) == sorted([folder_roll, virtual_roll]) + + +def test_rolls_containing_path_is_empty_for_an_unshared_path(): + repo = _repo() + create_virtual_roll(repo, "Portra", ["/a.nef"]) + assert rolls_containing_path(repo, "/unrelated.nef") == [] diff --git a/tests/test_session_panel.py b/tests/test_session_panel.py index 1a98595f2..4ba4e0977 100644 --- a/tests/test_session_panel.py +++ b/tests/test_session_panel.py @@ -1,4 +1,4 @@ -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -6,17 +6,21 @@ from negpy.desktop.view.sidebar.session_panel import SessionPanel from negpy.infrastructure.storage.repository import StorageRepository from negpy.kernel.system.updater import UpdateInfo +from negpy.services.assets import rolls as rolls_service def _controller(tmp_path, roots: list[str]) -> MagicMock: - """A mock controller around a real session — the film strip needs a real model.""" + """A mock controller around a real session — the film strip needs a real model. + Each path in *roots* becomes a recognized folder roll.""" repo = StorageRepository(str(tmp_path / "edits.db"), str(tmp_path / "settings.db")) repo.initialize() - repo.save_global_setting("library_roots", roots) + for path in roots: + rolls_service.recognize_folder(repo, path) controller = MagicMock() controller.session = DesktopSessionManager(repo) controller.library_roots.return_value = roots + controller.has_rolls.return_value = bool(roots) return controller @@ -31,28 +35,37 @@ def panel(qapp, tmp_path, monkeypatch): def test_library_sits_above_the_film_strip(panel): browser = panel.file_browser - order = [browser.layout().itemAt(i).widget() for i in range(browser.layout().count())] + splitter = browser.sections_splitter - assert order.index(browser.library_section) < order.index(browser.frames_section) + assert splitter.indexOf(browser.library_section) < splitter.indexOf(browser.frames_section) assert browser.library_section.content_area.isAncestorOf(panel.library_tree) assert browser.frames_section.content_area.isAncestorOf(browser.list_view) +def test_thumbnail_size_slider_lives_in_the_film_strip_tally_row(panel): + """Only the thumbnail grid reads it, so it belongs with the frames it resizes, not + the search row shared with Library.""" + browser = panel.file_browser + + assert browser.frames_section.content_area.isAncestorOf(browser.thumb_size_slider) + assert not browser.library_section.content_area.isAncestorOf(browser.thumb_size_slider) + + def test_the_search_row_sits_above_both_sections(panel): browser = panel.file_browser layout = browser.layout() rows = [layout.itemAt(i) for i in range(layout.count())] search_at = next(i for i, item in enumerate(rows) if item.layout() and item.layout().indexOf(browser.search_input) >= 0) - library_at = next(i for i, item in enumerate(rows) if item.widget() is browser.library_section) + splitter_at = next(i for i, item in enumerate(rows) if item.widget() is browser.sections_splitter) - assert search_at < library_at + assert search_at < splitter_at -def test_tree_is_shown_when_the_library_has_roots(panel): +def test_tree_is_shown_when_the_library_has_rolls(panel): assert panel.library_tree.isVisibleTo(panel) -def test_tree_hidden_when_no_roots(qapp, tmp_path, monkeypatch): +def test_tree_hidden_when_no_rolls(qapp, tmp_path, monkeypatch): monkeypatch.setattr("negpy.desktop.view.widgets.update_dialog.find_update", lambda *a, **k: None) panel = SessionPanel(_controller(tmp_path, [])) @@ -67,72 +80,99 @@ def test_a_collapsed_section_keeps_only_its_header(panel): browser.library_section.toggle_button.setChecked(False) assert browser.library_section.maximumHeight() == header - assert browser.layout().stretch(browser.layout().indexOf(browser.library_section)) == 0 - # The film strip is still expanded, so it keeps its share. - assert browser.layout().stretch(browser.layout().indexOf(browser.frames_section)) > 0 + library, frames = browser.sections_splitter.sizes() + assert library == header + # The film strip is still expanded, so it keeps the rest. + assert frames > header def test_collapsing_both_sections_keeps_the_panel_top_aligned(panel, qapp): - """With nothing expanded the leftover height must not spread into the gaps (#754).""" + """With nothing expanded the leftover height must not spread into the gaps (#754). + + Shows the real top-level ancestor (panel), not just the embedded browser: a + QSplitter's live layout needs a genuinely shown/sized top-level window to position + its children correctly, unlike a plain QLayout, which computes geometry analytically + regardless of show state. + """ browser = panel.file_browser browser.library_section.toggle_button.setChecked(False) browser.frames_section.toggle_button.setChecked(False) - browser.resize(300, 900) - browser.show() + panel.resize(300, 900) + panel.show() qapp.processEvents() - assert browser.frames_section.y() < 200 # ~115 stacked, ~707 spread + assert browser.frames_section.y() < 200 # ~40 stacked, ~707 spread def test_expanding_a_section_gives_it_back_a_share(panel): browser = panel.file_browser + header = browser.frames_section.toggle_button.height() browser.frames_section.toggle_button.setChecked(False) - assert browser.layout().stretch(browser.layout().indexOf(browser.frames_section)) == 0 + assert browser.sections_splitter.sizes()[1] == header browser.frames_section.toggle_button.setChecked(True) - assert browser.layout().stretch(browser.layout().indexOf(browser.frames_section)) > 0 + assert browser.sections_splitter.sizes()[1] > header assert browser.frames_section.maximumHeight() > 1000 -def test_both_open_splits_the_panel_40_60(panel): - """The tree finds a roll, the sheet is where the work happens.""" +def test_both_open_favors_the_film_strip(panel): + """The tree finds a roll, glanced at occasionally; the sheet is where the work happens + and starts with most of the room. Loose bound: pixel sizes, not a pure integer ratio, + so the splitter's own rounding shifts this around, and the point is "clearly the + minority share", not an exact number the user can drag away from anyway.""" + library, frames = panel.file_browser.sections_splitter.sizes() + + assert library / (library + frames) < 0.4 + + +def test_dragging_the_splitter_persists_the_split(panel): + """Drives the persist path the way a real drag does: splitterMoved fires after Qt has + already applied the new sizes, so this saves whatever sizes() reports, not the + argument setSizes was given (Qt rescales it if the splitter has not been shown).""" browser = panel.file_browser - layout = browser.layout() + browser.sections_splitter.setSizes([300, 300]) + expected = browser.sections_splitter.sizes() + + browser._on_sections_splitter_moved() - library = layout.stretch(layout.indexOf(browser.library_section)) - frames = layout.stretch(layout.indexOf(browser.frames_section)) + assert panel.controller.session.repo.get_global_setting("session_sections_splitter_sizes") == expected - assert library / (library + frames) == pytest.approx(0.4) +def test_a_new_panel_restores_the_saved_split(qapp, tmp_path, monkeypatch): + """setSizes on an unshown splitter rescales its argument to fit a not-yet-laid-out + guess at the total, so this checks the restore path is wired to the saved value, + not the pixel sizes an unshown splitter ends up reporting.""" + monkeypatch.setattr("negpy.desktop.view.widgets.update_dialog.find_update", lambda *a, **k: None) + root = tmp_path / "library" + root.mkdir() + controller = _controller(tmp_path, [str(root)]) + controller.session.repo.save_global_setting("session_sections_splitter_sizes", [111, 222]) -def test_opening_an_image_less_folder_from_the_tree_loads_nothing(panel, tmp_path): - """The tree navigates and the strip loads: a folder with no images of its own has - nothing to load, so nothing is hashed.""" - (tmp_path / "library" / "roll_a").mkdir() + with patch("negpy.desktop.view.sidebar.files.QSplitter.setSizes") as set_sizes: + SessionPanel(controller) - panel.library_tree.folders_activated.emit([str(tmp_path / "library")]) + assert [111, 222] in [list(c.args[0]) for c in set_sizes.call_args_list] - panel.controller.open_library_folder.assert_not_called() - panel.controller.open_library_folders.assert_not_called() +def test_the_library_button_shows_the_section_when_rolls_exist(panel): + panel.file_browser.library_section.setVisible(False) -def test_opening_a_roll_from_the_tree_reaches_the_film_strip(panel, tmp_path, monkeypatch): - roll = tmp_path / "library" / "roll_a" - roll.mkdir() - (roll / "a1.NEF").write_bytes(b"1") - monkeypatch.setattr(panel.file_browser, "_confirm_load", lambda count, label: True) + panel.file_browser.library_requested.emit(True) - panel.library_tree.folders_activated.emit([str(roll)]) + assert panel.file_browser.library_section.isVisibleTo(panel) - panel.controller.open_library_folder.assert_called_once_with(str(roll), add_to_session=False) +def test_the_library_button_prompts_an_import_when_the_library_is_empty(qapp, tmp_path, monkeypatch): + monkeypatch.setattr("negpy.desktop.view.widgets.update_dialog.find_update", lambda *a, **k: None) + panel = SessionPanel(_controller(tmp_path, [])) + prompted = [] + monkeypatch.setattr(panel.library_tree, "prompt_import_folder", lambda: prompted.append(1) or False) -def test_the_library_button_reveals_the_primary_folder(panel, tmp_path): panel.file_browser.library_requested.emit(True) - assert panel.library_tree.tree.currentItem().text(0) == "library" + assert prompted def test_sorting_the_sheet_sorts_the_tree(panel): @@ -141,8 +181,8 @@ def test_sorting_the_sheet_sorts_the_tree(panel): assert panel.library_tree._sort_descending is True -def test_changing_roots_drops_the_cached_walk(panel): - panel.library_tree.roots_changed.emit() +def test_a_roll_change_drops_the_cached_walk(panel): + panel.library_tree.rolls_changed.emit() panel.controller.invalidate_library_walk.assert_called_once_with()