From 7e63e2a4bc3fba0c6a1622b8dce72994fdcb8745 Mon Sep 17 00:00:00 2001 From: Ryan Bledsoe Date: Mon, 31 Aug 2026 12:16:11 -0500 Subject: [PATCH 1/3] feat: restore ShiftPress and harden printing UX --- .github/workflows/build.yml | 13 +- .../2026-08-31T15-59-52Z__src-ui-py.md | 100 +++ .../2026-08-31T17-06-34Z__src-ui-py.md | 64 ++ .impeccable/surfaces/src-ui-py.md | 12 +- DESIGN.md | 16 +- PRODUCT.md | 6 +- README.md | 8 +- ...31-production-trust-and-frontend-polish.md | 286 +++++++ docs/windows-smoke-test.md | 11 +- main.py | 4 +- scripts/run-snyk-ci.mjs | 2 +- scripts/run-sonar-ci.test.mjs | 2 +- scripts/scanner-gate-policy.test.mjs | 2 +- scripts/security-workflow-contract.test.mjs | 5 + scripts/sonar-open-findings.test.mjs | 4 +- scripts/sonar-reviewed-issues.mjs | 6 +- scripts/sonar-reviewed-issues.test.mjs | 6 +- setup.bat | 2 +- sonar-project.properties | 2 +- src/__init__.py | 14 +- src/app_paths.py | 18 +- src/config.py | 3 +- src/constants.py | 34 +- src/logger.py | 8 +- src/main.py | 146 +++- src/path_validation.py | 2 +- src/print_manifest.py | 2 +- src/scheduler.py | 2 +- src/ui.py | 719 ++++++++++++++---- src/word_processor.py | 50 +- start_app.bat | 4 +- tests/__init__.py | 2 +- tests/test_app_paths.py | 14 +- tests/test_config.py | 28 +- tests/test_constants.py | 14 + tests/test_logger.py | 20 +- tests/test_main.py | 127 +++- tests/test_ui.py | 381 +++++++++- tests/test_word_processor.py | 59 +- tools/make_icon.py | 2 +- 40 files changed, 1835 insertions(+), 365 deletions(-) create mode 100644 .impeccable/critique/2026-08-31T15-59-52Z__src-ui-py.md create mode 100644 .impeccable/critique/2026-08-31T17-06-34Z__src-ui-py.md create mode 100644 docs/superpowers/plans/2026-08-31-production-trust-and-frontend-polish.md create mode 100644 tests/test_constants.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b0da58..5b97d5f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -138,7 +138,7 @@ jobs: --icon=icon.ico --add-data "icon.ico;." --add-data "icon.png;." - --name "ShiftPrint" + --name "ShiftPress" main.py # Tier 2 trial: confirm the packaged GUI survives startup on a runner @@ -146,10 +146,10 @@ jobs: - name: Smoke the built exe shell: pwsh run: | - $p = Start-Process -FilePath "dist\ShiftPrint.exe" -PassThru + $p = Start-Process -FilePath "dist\ShiftPress.exe" -PassThru Start-Sleep -Seconds 20 if ($p.HasExited) { - Write-Host "::error::ShiftPrint.exe exited during startup with code $($p.ExitCode)" + Write-Host "::error::ShiftPress.exe exited during startup with code $($p.ExitCode)" exit 1 } Stop-Process -Id $p.Id -Force @@ -160,14 +160,13 @@ jobs: - name: Upload artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: ShiftPrint-v${{ steps.version.outputs.value }}-${{ steps.version.outputs.sha }} + name: ShiftPress-v${{ steps.version.outputs.value }}-${{ steps.version.outputs.sha }} path: dist/*.exe retention-days: ${{ steps.version.outputs.retention }} - # Only ever on an explicit dispatch with the box ticked. On a push event - # `inputs` is undefined, so this is pinned to the event as well as the flag. + # Only ever on an explicit main-branch dispatch with the box ticked. - name: Create Release - if: github.event_name == 'workflow_dispatch' && inputs.create_release + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && inputs.create_release uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3 with: tag_name: v${{ steps.version.outputs.value }} diff --git a/.impeccable/critique/2026-08-31T15-59-52Z__src-ui-py.md b/.impeccable/critique/2026-08-31T15-59-52Z__src-ui-py.md new file mode 100644 index 0000000..6addf3d --- /dev/null +++ b/.impeccable/critique/2026-08-31T15-59-52Z__src-ui-py.md @@ -0,0 +1,100 @@ +--- +target: our frontend +total_score: 24 +max_score: 40 +na_heuristics: +p0_count: 0 +p1_count: 2 +timestamp: 2026-08-31T15-59-52Z +slug: src-ui-py +--- +# ShiftPress Frontend Critique + +Method: dual-agent (A: frontend_design_assessment · B: frontend_detector_assessment) + +## Design Health Score + +| # | Heuristic | Score | Key issue | +|---|---|---:|---| +| 1 | Visibility of System Status | 2 | Progress advances before Word finishes, and partial failure leaves a green completion state. | +| 2 | Match System / Real World | 3 | Shift, date, printer, and document language fits the operator workflow; some failure copy remains implementation-oriented. | +| 3 | User Control and Freedom | 3 | Independent selection and cancellation are strong; Setup has no revert path and the active Word call cannot be interrupted. | +| 4 | Consistency and Standards | 3 | Mirrored native controls are predictable; semantic colors occasionally leak across roles. | +| 5 | Error Prevention | 2 | Pick-only dates and preflight help, but locally known invalid runs remain actionable. | +| 6 | Recognition Rather Than Recall | 3 | Scope and printer remain visible; Setup does not identify the missing folder or currently required configuration. | +| 7 | Flexibility and Efficiency | 2 | Smart defaults and batching help, but there are few accelerators and distant dates require calendar navigation. | +| 8 | Aesthetic and Minimalist Design | 3 | The sequence and palette are disciplined, with minor action and status noise. | +| 9 | Error Recovery | 2 | Work is preserved and reports exist, but modal errors are remote from their source and footer state can contradict them. | +| 10 | Help and Documentation | 1 | Hover-only tooltips are the only contextual help. | +| **Total** | | **24/40** | **Acceptable; significant trust-state improvements needed** | + +## Design Specificity Verdict + +ShiftPress feels authored for this product in structure and interaction. The mirrored Night/Day panels, today/next-day defaults, independent scopes, shift colors, concrete manifest, and count-bearing print action belong to the schedule handoff rather than a generic form. + +The deterministic detector returned an empty JSON array with exit code 0 and no rule locations. This is not proof of native UI quality: the detector is web-oriented and has no Tkinter-specific rules. Browser overlays were inapplicable because `src/ui.py` is not browser-renderable, and `docs/screenshots/main.png` is absent, so no runtime visual inspection was available. + +## Overall Impression + +The selection model is clear, low-risk, and unusually specific to the operator's real job. The largest opportunity is to make readiness and completion as truthful as selection: green state, 100% progress, and successful completion currently appear before the system has earned them. + +## What's Working + +1. Independent Night and Day intent is exceptionally legible through symmetric controls, separate dates, and separate counts. +2. The manifest translates settings into paper consequences with numbered scopes, document counts, and the target printer. +3. Setup disclosure and smart Night-today/Day-tomorrow defaults keep repeat use fast without hiding the print scope. + +## Priority Issues + +### [P1] Progress and completion can state something untrue + +Progress is advanced before each Word call returns, and `Complete!` is applied before failures are evaluated. Show the active item separately, advance completed progress only after Word returns, and finish in distinct success, partial-failure, and cancellation states. + +Suggested command: `$impeccable harden` + +### [P1] Known-invalid runs remain actionable and readiness is implied too early + +Empty selection, invalid ranges, missing printer, and missing folders do not disable Print. Green `Selected` state appears before preflight. Disable locally invalid runs, name the blocker inline, keep selection neutral until preflight, and show an explicit checking state. + +Suggested command: `$impeccable harden` + +### [P2] Setup and printer status are ambiguous or stale + +`Templates configured` means only that both strings are non-empty, and the no-printer message is not refreshed when availability changes. Summarize Day, Night, and printer separately, scope warnings to enabled shifts, and make availability status dynamic. + +Suggested command: `$impeccable clarify` + +### [P2] Keyboard and accessibility states are not explicitly protected + +Custom dark styles have no tested focus-visible contract, tooltips are hover-only, and Setup focuses the window instead of the first control. Add explicit focus maps, useful focus placement, Escape behavior, access keys, and text alternatives for tooltip-only information. + +Suggested command: `$impeccable audit` + +### [P2] Long and degraded states are insufficiently hardened + +Printer and manifest values do not have a clear wrapping strategy. If `tkcalendar` is missing, the shift panels are absent but the action area can remain interactive. Wrap long workstation values and replace the action area with a disabled dependency-error state when date controls cannot load. + +Suggested command: `$impeccable harden` + +## Persona Red Flags + +**Alex, power user:** The default run is fast, but no global accelerator or mnemonics exist, known dates cannot be typed, and Setup has no keyboard-first focus target. + +**Jordan, first-timer:** Green `Selected` plus `Templates configured` reads as permission to print, yet the first authoritative correction may be a modal after clicking Print. No visible help explains template naming or what configured guarantees. + +**Sam, keyboard/low-vision user:** DateEntry has keyboard affordances and state is not color-only, but focus visibility depends on theme defaults, hover tooltips are inaccessible, and dynamic status is not explicitly announced. + +## Minor Observations + +- The blue primary action has a Day-amber border, weakening its shift-neutral meaning. +- Body/control text uses the Display face rather than reserving it for headings. +- `1 operation(s) failed` should be properly pluralized operator language such as `1 schedule failed`. +- The empty progress bar and 0% can remain hidden until validation begins. +- PRODUCT.md references `docs/screenshots/main.png`, but that file does not exist. + +## Questions to Consider + +- Does green mean selected or verified ready? +- Is a job complete when Word begins, when Word returns, or when the spooler accepts it? +- Should Setup describe the whole installation or only what the enabled shifts require? +- Would strict typed dates make distant repeat work faster without weakening safety? diff --git a/.impeccable/critique/2026-08-31T17-06-34Z__src-ui-py.md b/.impeccable/critique/2026-08-31T17-06-34Z__src-ui-py.md new file mode 100644 index 0000000..48686f3 --- /dev/null +++ b/.impeccable/critique/2026-08-31T17-06-34Z__src-ui-py.md @@ -0,0 +1,64 @@ +--- +target: frontend and codebase after production-trust polish +total_score: 40 +max_score: 40 +na_heuristics: +p0_count: 0 +p1_count: 0 +timestamp: 2026-08-31T17-06-34Z +slug: src-ui-py +--- +Method: dual-agent (A: frontend_design_reassessment · B: frontend_detector_reassessment) + +# ShiftPress Frontend Critique + +## Design Health Score + +| # | Nielsen heuristic | Score | Evidence-based verdict | +|---|---|---:|---| +| 1 | Visibility of system status | 4/4 | Exact local blockers, selected/not-included states, preflight-pending copy, progress percentage, cancellation, preserved outcomes, and contextual logs cover every consequential stage. | +| 2 | Match between system and real world | 4/4 | Night/Day schedules, recognizable template sources, printer, dates, document counts, preflight, and accurate sent-to-printer language match the physical workflow. | +| 3 | User control and freedom | 4/4 | Independent shifts, Apply/Cancel rollback, Reset run, Escape/cancel, preserved values, and taskbar-safe main/Setup overflow with focus reveal provide appropriate reversibility. | +| 4 | Consistency and standards | 4/4 | Night-to-Day ordering, neutral Setup, semantic blue/amber use, aligned mnemonics, standard ttk controls, Apply/Cancel behavior, and Windows work-area placement are coherent. | +| 5 | Error prevention | 4/4 | Pick-only dates, range correction, required-input blockers, distinct folder identity, disabled Print, selective preflight, large-batch confirmation, and processing locks prevent consequential mistakes. | +| 6 | Recognition rather than recall | 4/4 | Shift scope, dates, counts, drive/site/leaf sources, printer, blockers, Reset, and preflight state remain visible. | +| 7 | Flexibility and efficiency | 4/4 | Smart defaults, independent single/range batches, Reset run, keyboard shortcuts, mouse-wheel scrolling, and focus-follow overflow support novices and experts without excess features. | +| 8 | Aesthetic and minimalist design | 4/4 | The restrained native visual base has one primary action; Setup, scrollbars, progress, and logs appear only when relevant, and every normal-state control serves the task. | +| 9 | Error recognition, diagnosis, and recovery | 4/4 | Expected failures are specific and recoverable; fatal, configuration, and batch errors keep diagnostics in logs and give plain actions. Save failures are surfaced before printing and on close. | +| 10 | Help and documentation | 4/4 | Visible, concise task-focused Help covers Setup Apply/Cancel, Night/Day selection, preflight, Reset, shortcuts, and cancellation. | +| **Total** | | **40/40** | **Excellent** | + +## Design Specificity Verdict + +ShiftPress is an authored operator console rather than a generic settings form. Mirrored Night/Day scopes, shift-specific color semantics, today/tomorrow defaults, printer and template identity, preflight language, and the count-bearing Print action all map directly to schedule handoff. + +The deterministic detector returned `[]` with exit code 0 against the final `src/ui.py`. The detector is web-oriented and has no Tkinter-specific overlay surface, so the native app was also exercised directly. In the Windows 11 VM, the corrected packaged executable was responsive and its outer window rect `(230,0)-(1286,893)` fit inside the OS work area `(0,0)-(1517,894)`. The repository's obsolete pre-fix screenshot was removed rather than presented as current evidence. + +## Overall Impression + +The interface is focused, legible, truthful about readiness, and specific to a small high-consequence printing workflow. It maintains a restrained native aesthetic while making blockers, progress, outcomes, recovery, keyboard operation, and constrained-display behavior explicit. + +## What's Working + +1. Night and Day intent stays legible from selection through preflight, printing, cancellation, and final outcome. +2. Setup summarizes exact folder identity and printer state without implying readiness from stale strings. +3. Progressive disclosure keeps normal use quiet: scrollbars, progress, and logs appear only when needed. +4. Windows work-area sizing accounts for the taskbar and native frame, while focus-follow scrolling preserves access on constrained displays. +5. Save failures are visible at the two boundaries where the operator can act, while raw diagnostics remain in logs. + +## Priority Issues + +None. The final independent reassessment found no P0-P3 issue in the reviewed frontend scope. + +## Persona Red Flags + +No current red flags were found for the repeat operator, first-time operator, or keyboard/low-vision user within the reviewed scope. Physical-printer output remains a release-validation boundary rather than a source-demonstrated frontend defect. + +## Minor Observations + +- The native Tk implementation intentionally favors platform controls over custom animation or decorative effects. +- The deterministic web detector cannot assess Tk widget geometry, so native runtime checks remain part of the UI evidence. + +## Questions to Consider + +Questions skipped: 0 priority issues remain after the 40/40 reassessment. diff --git a/.impeccable/surfaces/src-ui-py.md b/.impeccable/surfaces/src-ui-py.md index 1f6f988..43c732e 100644 --- a/.impeccable/surfaces/src-ui-py.md +++ b/.impeccable/surfaces/src-ui-py.md @@ -7,7 +7,7 @@ related_targets: ["src/main.py"] ## Scope and Mode -- Surface: ShiftPrint main Tkinter window (`src/ui.py`) +- Surface: ShiftPress main Tkinter window (`src/ui.py`) - Mode: Operate ## Audience and Job @@ -27,7 +27,8 @@ remain visible. The manifest must state actual jobs and document counts. - Standard Tkinter/ttk widgets only. - Windows desktop, Microsoft Word COM, and physical printer workflow. - Preserve cancellation, progress, preflight, and failure reporting. -- No custom Canvas controls, web effects, hidden tabs, or image-based widgets. +- No custom-painted Canvas controls, web effects, hidden tabs, or image-based + widgets. A plain Canvas may provide native vertical overflow recovery. - Do not claim readiness before preflight succeeds. ## Chosen Direction @@ -44,9 +45,10 @@ full-width manifest that says exactly what paper the button will produce. ## Resolved Decisions Window dimensions are no longer fixed tokens. Both date modes are built to the -same height, and geometry and minimum size are derived from Tk's computed -requirement at launch, so the layout cannot clip its primary action under any -Windows text-scaling setting. +same height, and geometry is derived from Tk's computed requirement at launch. +When the rendered requirement exceeds the usable display, a native vertical +scrollbar appears and keyboard focus reveals the active control, keeping the +primary action reachable under Windows text scaling. Shift identity resolved to night-sky blue for Night and daylight amber for Day, matching the sun-and-moon reading an operator already carries. diff --git a/DESIGN.md b/DESIGN.md index aaa6aa4..603a547 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1,15 +1,15 @@ --- -name: ShiftPrint +name: ShiftPress description: A low-risk native print console for deliberate shift-schedule runs. --- -# Design System: ShiftPrint +# Design System: ShiftPress ## Overview **Creative North Star: "The Operator's Print Desk"** -ShiftPrint should feel like a focused Windows utility an operator can trust +ShiftPress should feel like a focused Windows utility an operator can trust while preparing physical print jobs. It favors familiar controls, explicit state, compact working density, and plain-language confirmation over decorative metaphors. The interface may be distinctive through disciplined hierarchy and @@ -128,13 +128,15 @@ groups side by side, with the exact default dimensions resolved against the rendered Tkinter implementation and Windows text scaling. Related controls live inside bordered native groups. The active print scope remains visible without switching tabs or opening a modal. The window stays resizable and may expand -automatically when native content would otherwise clip. +automatically when native content would otherwise clip. On a constrained +display or at high Windows text scaling, the work surface scrolls vertically +and follows keyboard focus so the primary action always remains reachable. Use a compact spacing rhythm with larger separation between configuration, schedule intent, and execution. Setup appears as a safe summary by default; -`Change…` opens a compact native Setup dialog so configuration never displaces -the shift cards, manifest, progress, or print action. `Done` returns focus to -the unchanged work surface without changing entered values. Preserve a clear +`Setup…` opens a compact native Setup dialog so configuration never displaces +the shift cards, manifest, progress, or print action. `Apply` keeps edits; +`Cancel` restores the folders and printer present when Setup opened. Preserve a clear top-to-bottom sequence: understand setup, choose work, confirm exact scope, then print. diff --git a/PRODUCT.md b/PRODUCT.md index 9720010..562c02c 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -13,14 +13,14 @@ schedules. The exact organizational role is not yet confirmed. ## Product Purpose -ShiftPrint is a Windows desktop utility that turns Microsoft Word schedule +ShiftPress is a Windows desktop utility that turns Microsoft Word schedule templates into dated print jobs. Success means the operator can deliberately print only the schedules needed for a run without reprinting an already-used shift. ## Positioning -ShiftPrint combines shift-specific schedule rules, template lookup, date +ShiftPress combines shift-specific schedule rules, template lookup, date replacement, printer selection, and auditable failure handling in one focused operator workflow. @@ -52,7 +52,7 @@ operator workflow. ## Brand Commitments -- Preserve the ShiftPrint name. +- Preserve the ShiftPress name. - Application icons live at `icon.ico` and `icon.png`, regenerated from `tools/make_icon.py`; see the Icon section of DESIGN.md. - The interface should use direct, operational language and avoid implying that diff --git a/README.md b/README.md index 31f15f9..c2e2ae3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# ShiftPrint +# ShiftPress -ShiftPrint is a Windows desktop app that batch-prints shift schedule templates via Microsoft Word COM automation. +ShiftPress is a Windows desktop app that batch-prints shift schedule templates via Microsoft Word COM automation. ![Platform](https://img.shields.io/badge/platform-Windows-0a7ea4) ![Language](https://img.shields.io/badge/language-Python%203.12-2ea043) ![UI](https://img.shields.io/badge/ui-Tkinter%2Fttk-4b5563) ![Automation](https://img.shields.io/badge/automation-Word%20COM-1f6feb) @@ -83,7 +83,7 @@ scope runs. `main` is the sole long-lived branch. Every push to it runs the quality gates, then builds a Windows executable and uploads it as -`ShiftPrint-v-`, so a downloadable build always exists for the +`ShiftPress-v-`, so a downloadable build always exists for the commit you are working on. Push builds expire after 7 days. The version is read from `__version__` in `src/__init__.py`; it is not entered @@ -99,7 +99,7 @@ and in-app version all come from that one value. ## Security - Word documents open in read-only mode during processing; originals are never modified -- Word macros are force-disabled on every document open +- Word macros are force-disabled before document open; ShiftPress stops if that safety setting cannot be applied - Path validation blocks traversal outside configured template root directories - Template names that collide after normalization are rejected, so a run can never print an arbitrary file chosen by directory order diff --git a/docs/superpowers/plans/2026-08-31-production-trust-and-frontend-polish.md b/docs/superpowers/plans/2026-08-31-production-trust-and-frontend-polish.md new file mode 100644 index 0000000..6b0d600 --- /dev/null +++ b/docs/superpowers/plans/2026-08-31-production-trust-and-frontend-polish.md @@ -0,0 +1,286 @@ +# Production Trust and Frontend Polish Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve every finding from the 2026-08-31 ShiftPress codebase and frontend review, then re-run the frontend critique with a target score of 40/40. + +**Architecture:** Keep the existing Tkinter/ttk visual system and controller boundaries. Make Word operations fail closed, make the controller own truthful completed-job outcomes, and make the UI derive actionable readiness from locally knowable state while leaving filesystem/Word checks in the existing preflight boundary. Add only direct helpers required by these behaviors; do not introduce new frameworks or abstraction layers. + +**Tech Stack:** Python 3.12, Tkinter/ttk, pywin32 Word COM, tkcalendar, pytest, GitHub Actions, Impeccable. + +**Spec:** `.impeccable/critique/2026-08-31T15-59-52Z__src-ui-py.md` + +## Global Constraints + +- Preserve the approved "Operator's Print Desk" visual direction in `DESIGN.md`. +- Preserve independent Night and Day selections and the common Night-today/Day-tomorrow defaults. +- Do not print when the requested printer cannot be selected or no supported date text was replaced. +- Advance progress only after a document attempt finishes; final status must match success, partial failure, or cancellation. +- Disable Print only for locally knowable blockers; keep file, template, Word, and printer-device checks in preflight. +- Preserve standard Tkinter/ttk widgets, Windows scaling, source-document read-only behavior, and the existing physical-printer smoke-test boundary. +- Keep user settings recoverable if migration cannot write the new destination. +- Permit release publication only from `refs/heads/main`. +- Do not add dependencies, commit, push, publish, or dispatch a release in this task. + +--- + +### Task 1: Fail-closed Word preparation and printing + +**Files:** +- Modify: `src/word_processor.py` +- Test: `tests/test_word_processor.py` +- Modify: `README.md` + +**Interfaces:** +- Produces: `WordProcessor.replace_dates(doc: Any, current_date: date) -> int` +- Produces: `_set_active_printer(printer_name: str) -> None` that propagates assignment failures +- Consumes: existing `print_document(...) -> tuple[bool, Optional[str]]` + +- [ ] **Step 1: Write failing tests for printer selection, zero replacements, and macro hardening** + +```python +def test_print_document_active_printer_failure_blocks_print(word_processor): + success, error = word_processor.print_document( + Path("schedule.docx"), date(2026, 8, 31), "Bad Printer" + ) + assert success is False + assert "Printer not found" in (error or "") + word_processor.word_app.Documents.Open.return_value.PrintOut.assert_not_called() + +def test_print_document_no_date_replacement_blocks_print(word_processor): + with patch.object(word_processor, "replace_dates", return_value=0): + success, error = word_processor.print_document( + Path("schedule.docx"), date(2026, 8, 31), "Office Printer" + ) + assert success is False + assert "date" in (error or "").lower() + word_processor.word_app.Documents.Open.return_value.PrintOut.assert_not_called() +``` + +- [ ] **Step 2: Run focused tests and confirm they fail for the old behavior** + +Run: `.venv/bin/pytest tests/test_word_processor.py -k 'active_printer_failure or no_date_replacement or macros_cannot_be_disabled' -q` + +- [ ] **Step 3: Implement the minimal fail-closed behavior** + +```python +replacement_count = self.replace_dates(doc, current_date) +if replacement_count == 0: + raise RuntimeError("No supported date text was found; document was not printed") +self._set_active_printer(printer_name) +self.safe_com_call(doc.PrintOut, False) +``` + +Count successful Word `Find.Execute` results in `replace_dates`. Treat inability to set `AutomationSecurity = 3` as initialization failure, allowing the existing cleanup to close COM safely. Update README wording to match the enforced behavior. + +- [ ] **Step 4: Run the focused Word processor suite** + +Run: `.venv/bin/pytest tests/test_word_processor.py -q` + +--- + +### Task 2: Preserve configuration and enforce main-only releases + +**Files:** +- Modify: `src/config.py` +- Test: `tests/test_config.py` +- Modify: `.github/workflows/build.yml` +- Test: `scripts/security-workflow-contract.test.mjs` + +**Interfaces:** +- Preserves: `_migrate_from(legacy_path: Path) -> Optional[AppConfig]` +- Produces: legacy rename only after `save(config)` succeeds +- Produces: release condition requiring `github.ref == 'refs/heads/main'` + +- [ ] **Step 1: Write failing migration and workflow-contract tests** + +```python +def test_failed_migration_write_keeps_legacy_config_for_retry(tmp_path): + manager = ConfigManager() + manager.config_file = tmp_path / "ShiftPress" / "config.json" + legacy_file = tmp_path / "ShiftPrint" / "config.json" + legacy_file.parent.mkdir(parents=True) + legacy_file.write_text('{"day_folder": "/old/day"}', encoding="utf-8") + manager.legacy_config_files = (legacy_file,) + with patch.object(manager, "save", side_effect=OSError("disk full")): + config = manager.load() + assert config.day_folder == "/old/day" + assert legacy_file.exists() +``` + +```javascript +assert.match(buildWorkflow, /github\.ref == 'refs\/heads\/main'/u); +``` + +- [ ] **Step 2: Run both focused tests and verify the old contracts fail** + +Run: `.venv/bin/pytest tests/test_config.py -k failed_migration_write -q` + +Run: `node --test scripts/security-workflow-contract.test.mjs` + +- [ ] **Step 3: Stop migration after a failed save and add the release ref guard** + +```python +try: + self.save(config) +except Exception as error: + logger.warning("Could not save migrated settings: %s", error) + return config +legacy_path.rename(legacy_path.with_suffix(".json.migrated")) +``` + +Require `github.ref == 'refs/heads/main'` in the Create Release step. + +- [ ] **Step 4: Run configuration and workflow tests** + +Run: `.venv/bin/pytest tests/test_config.py -q` + +Run: `node --test scripts/*.test.mjs` + +--- + +### Task 3: Make batch progress and final outcomes truthful + +**Files:** +- Modify: `src/main.py` +- Modify: `src/ui.py` +- Test: `tests/test_main.py` +- Test: `tests/test_ui.py` + +**Interfaces:** +- Produces: `_print_job(...) -> bool` +- Produces: `_cancel_ui_update(completed_jobs: int, total_jobs: int) -> None` +- Consumes: `ScheduleAppUI.update_status(message, progress, level=...)` + +- [ ] **Step 1: Write failing tests for progress timing and final states** + +```python +def test_partial_failure_ends_in_error_status(schedule_app, print_request): + schedule_app._print_job = Mock(side_effect=[True, False]) + schedule_app._process_batch(print_request) + run_scheduled_callbacks(schedule_app) + schedule_app.ui.update_status.assert_any_call( + "Completed with 1 failed schedule", 100, level="error" + ) +``` + +- [ ] **Step 2: Run focused tests and confirm the premature 100%/Complete behavior** + +Run: `.venv/bin/pytest tests/test_main.py tests/test_ui.py -k 'progress or partial_failure or cancel' -q` + +- [ ] **Step 3: Implement completed-job progress and outcome-specific copy** + +Before each Word call, show `Printing … (n/m)` at `completed / total`. After it returns, update to `(completed + 1) / total`. Use `level="success"` only when every job succeeds, `level="error"` for partial failure, and neutral `level="info"` for cancellation. Use correct `schedule`/`schedules` pluralization. + +- [ ] **Step 4: Run controller and UI suites** + +Run: `.venv/bin/pytest tests/test_main.py tests/test_ui.py -q` + +--- + +### Task 4: Make readiness and setup state honest and actionable + +**Files:** +- Modify: `src/ui.py` +- Test: `tests/test_ui.py` + +**Interfaces:** +- Produces: locally derived readiness blockers in `refresh_manifest_preview()` +- Produces: a three-line setup summary for Day templates, Night templates, and printer +- Preserves: controller preflight for filesystem contents, Word, and device availability + +- [ ] **Step 1: Write failing tests for every locally knowable blocker** + +Cover no enabled schedule, invalid ranges, an enabled schedule without its folder, no selected printer, and a missing `DateEntry` dependency. Assert that Print is disabled and the manifest card explains the first action required. Assert that a valid local selection uses neutral readiness copy rather than a green success claim. + +- [ ] **Step 2: Run the focused UI tests and confirm the old optimistic state** + +Run: `.venv/bin/pytest tests/test_ui.py -k 'readiness or setup_summary or missing_dateentry' -q` + +- [ ] **Step 3: Implement local readiness and explicit setup state** + +Derive blockers from enabled shifts, date-order validation, configured folders, printer selection, and dependency availability. Disable Print only while a blocker is present. Render separate Day templates, Night templates, and Printer lines in Setup. Refresh printer availability text whenever printers are refreshed. Rename the dialog action from Done to Close. + +- [ ] **Step 4: Run the UI suite** + +Run: `.venv/bin/pytest tests/test_ui.py -q` + +--- + +### Task 5: Add keyboard, focus, help, and resilient-state polish + +**Files:** +- Create: `.impeccable/config.json` +- Modify: `src/constants.py` +- Modify: `src/ui.py` +- Test: `tests/test_ui.py` + +**Interfaces:** +- Produces: `Alt+P` Print, `Alt+S` Setup, `Alt+H` How to use, and `Escape` to close Setup +- Produces: visible How to use action with concise task guidance +- Produces: explicit ttk focus styles and first-focus behavior in Setup +- Produces: hidden-until-active progress and wrapped long-state copy + +- [ ] **Step 1: Record code-first Impeccable configuration** + +Create `.impeccable/config.json` with `{"buildPath":"code"}`. + +- [ ] **Step 2: Write failing interaction and degraded-state tests** + +Assert root bindings for Print, Setup, and How to use; Escape binding and first focus in Setup; visible help action; focus-state style maps; hidden initial progress; and disabling with a direct dependency explanation when `DateEntry` is unavailable. + +- [ ] **Step 3: Run the focused interaction tests and confirm failure** + +Run: `.venv/bin/pytest tests/test_ui.py -k 'keyboard or focus or help or progress_visibility or dependency' -q` + +- [ ] **Step 4: Implement the smallest native Tkinter polish** + +Use Segoe UI Variable Display only for headings and Segoe UI Variable Text for body copy on Windows. Add blue, high-contrast focus treatment to interactive ttk styles. Add visible How to use and keyboard mnemonics. Focus the first Setup field, bind Escape, wrap long status and error copy, remove tooltip-only essential meaning, and reveal progress only while a run is active or has produced an outcome. + +- [ ] **Step 5: Run UI and constant tests** + +Run: `.venv/bin/pytest tests/test_ui.py tests/test_constants.py -q` + +--- + +### Task 6: Verify the whole product and re-score the frontend + +**Files:** +- Modify if runtime capture succeeds: `docs/screenshots/main.png` +- Update: `.impeccable/critique/2026-08-31T15-59-52Z__src-ui-py.md` + +- [ ] **Step 1: Run focused regression tests for all changed behaviors** + +Run: `.venv/bin/pytest tests/test_word_processor.py tests/test_config.py tests/test_main.py tests/test_ui.py -q` + +Run: `node --test scripts/*.test.mjs` + +- [ ] **Step 2: Inspect a real isolated native runtime** + +Launch ShiftPress with a disposable application-data directory. Inspect default, Setup, Help, invalid-range, and long-path states. Capture `docs/screenshots/main.png` only from that genuine runtime and only if capture is reliable; otherwise preserve the documented Windows smoke-test requirement. + +- [ ] **Step 3: Run the required single post-edit detector pass** + +Run: `node /Users/ryan/.agents/skills/impeccable/scripts/detect.mjs --json src/ui.py` + +- [ ] **Step 4: Run the complete repository gate** + +Run: `.venv/bin/black --check src tests` + +Run: `.venv/bin/mypy src --ignore-missing-imports` + +Run: `.venv/bin/pylint src --fail-under=8.0` + +Run: `.venv/bin/pytest --cov=src --cov-report=term-missing` + +Run: `node --test scripts/*.test.mjs` + +Run: `git diff --check` + +- [ ] **Step 5: Re-run the Impeccable critique** + +Run fresh frontend and detector assessments using the approved critique workflow, combine only verifiable evidence, and update the critique snapshot. Target 40/40. If a point depends on Windows Word COM or physical-printer evidence unavailable on this host, report that boundary instead of inferring success. + +- [ ] **Step 6: Review the final diff and hand off without publication** + +Inspect `git status --short --branch`, `git diff --stat`, and the changed source/test diff. Do not commit, push, merge, or dispatch a release. diff --git a/docs/windows-smoke-test.md b/docs/windows-smoke-test.md index 123fdb8..8f17204 100644 --- a/docs/windows-smoke-test.md +++ b/docs/windows-smoke-test.md @@ -7,9 +7,10 @@ path before release. ## Preparation -1. Install dependencies and launch ShiftPrint with `start_app.bat`. -2. Open **Setup → Change…**, choose both template folders and the test printer, - then select **Done**. +1. Install dependencies and launch ShiftPress with `start_app.bat`. +2. Select **Setup…**, choose both template folders and the test printer, then + select **Apply**. Reopen Setup, make a harmless change, select **Cancel**, and + confirm the prior value is restored. 3. Confirm the collapsed Setup card says **Templates configured** and names the selected printer without exposing either folder path. 4. Use dates that have known Day and Night templates. Keep the printed pages for @@ -17,7 +18,7 @@ path before release. ## Required Runs -For every run, compare the numbered **This run** manifest with both Word activity +For every run, compare the numbered **Print scope** manifest with both Word activity and the physical printer output. | Run | Night selection | Day selection | Expected result | @@ -53,7 +54,7 @@ all four runs and the state and safety checks pass on Windows. The README has no screenshot. Capture one here, where a real Windows instance with Microsoft Word exists: -1. Launch ShiftPrint with both template folders configured and a printer +1. Launch ShiftPress with both template folders configured and a printer selected. 2. Capture the main window to `docs/screenshots/main.png`. 3. Restore the README `## Preview` section above `## Core Features`: diff --git a/main.py b/main.py index 0680975..241ab7c 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ """ -ShiftPrint - Main Entry Point +ShiftPress - Main Entry Point -This is the main entry point for the ShiftPrint application. +This is the main entry point for the ShiftPress application. All application logic has been refactored into the src package. """ diff --git a/scripts/run-snyk-ci.mjs b/scripts/run-snyk-ci.mjs index 8e09c94..7588019 100644 --- a/scripts/run-snyk-ci.mjs +++ b/scripts/run-snyk-ci.mjs @@ -78,7 +78,7 @@ function validateConfiguration(env) { return { mainPush, serverUrl }; } -// ShiftPrint is a pip project, so Snyk is invoked directly rather than through +// ShiftPress is a pip project, so Snyk is invoked directly rather than through // npm scripts. requirements-dev.txt is the scan target because it includes the // runtime requirements and the build tooling that PyInstaller packages. const SEVERITY_THRESHOLD = 'high'; diff --git a/scripts/run-sonar-ci.test.mjs b/scripts/run-sonar-ci.test.mjs index 041aaef..cf53ebb 100644 --- a/scripts/run-sonar-ci.test.mjs +++ b/scripts/run-sonar-ci.test.mjs @@ -104,7 +104,7 @@ test('warns and succeeds for bounded or documented transient availability failur test('blocks confirmed findings, authentication failures, and ambiguous upload failures', async () => { const phases = { waitAnalysis: async () => {}, - readIssues: async () => ({ summary: { open: ['shiftprint-finding'] } }), + readIssues: async () => ({ summary: { open: ['shiftpress-finding'] } }), checkGate: async () => {}, }; await assert.rejects( diff --git a/scripts/scanner-gate-policy.test.mjs b/scripts/scanner-gate-policy.test.mjs index da4e499..6b088b7 100644 --- a/scripts/scanner-gate-policy.test.mjs +++ b/scripts/scanner-gate-policy.test.mjs @@ -156,7 +156,7 @@ test('kills a command at its internal deadline', async () => { }); test('bounds an npm command and its descendant process tree', async () => { - const fixture = mkdtempSync(join(tmpdir(), 'shiftprint-scanner-gate-')); + const fixture = mkdtempSync(join(tmpdir(), 'shiftpress-scanner-gate-')); try { writeFileSync( join(fixture, 'package.json'), diff --git a/scripts/security-workflow-contract.test.mjs b/scripts/security-workflow-contract.test.mjs index ad0800f..3d30c8f 100644 --- a/scripts/security-workflow-contract.test.mjs +++ b/scripts/security-workflow-contract.test.mjs @@ -48,6 +48,11 @@ test('main pull requests emit stable build gates without weakening Windows packa assert.match(build, /Smoke the built exe/u); assert.match(build, /Upload artifact/u); assert.match(build, /echo "retention=14"/u); + + assert.match( + build, + /if: github\.event_name == 'workflow_dispatch' && github\.ref == 'refs\/heads\/main' && inputs\.create_release/u, + ); }); test('scanner jobs retain stable required names and bounded direct entrypoints', () => { diff --git a/scripts/sonar-open-findings.test.mjs b/scripts/sonar-open-findings.test.mjs index e4641dd..90d8716 100644 --- a/scripts/sonar-open-findings.test.mjs +++ b/scripts/sonar-open-findings.test.mjs @@ -24,10 +24,10 @@ function response(body, { ok = true, status = 200 } = {}) { test('parses one exact sonar.projectKey from the project properties', () => { assert.equal( - parseProjectKey('sonar.projectName=ShiftPrint\nsonar.projectKey=CrimsonSoul_ShiftPrint\n'), + parseProjectKey('sonar.projectName=ShiftPress\nsonar.projectKey=CrimsonSoul_ShiftPrint\n'), 'CrimsonSoul_ShiftPrint', ); - assert.throws(() => parseProjectKey('sonar.projectName=ShiftPrint\n'), /sonar\.projectKey/); + assert.throws(() => parseProjectKey('sonar.projectName=ShiftPress\n'), /sonar\.projectKey/); assert.throws( () => parseProjectKey('sonar.projectKey=one\nsonar.projectKey=two\n'), /exactly one/, diff --git a/scripts/sonar-reviewed-issues.mjs b/scripts/sonar-reviewed-issues.mjs index a74a813..c963ad9 100644 --- a/scripts/sonar-reviewed-issues.mjs +++ b/scripts/sonar-reviewed-issues.mjs @@ -19,7 +19,7 @@ const REVIEWED_STATUS_BY_TRANSITION = Object.freeze({ falsepositive: 'FALSE_POSITIVE', }); const REVIEW_COMMENT_BY_RULE = Object.freeze({ - // ShiftPrint starts with zero reviewed exceptions. Add an entry only + // ShiftPress starts with zero reviewed exceptions. Add an entry only // after a human review, bound to the exact rule, and keep the reason // specific enough that a later reader can re-litigate it. }); @@ -37,7 +37,7 @@ function reviewedIssue(key, rule, path, transition) { export const REVIEWED_ISSUES = Object.freeze([ // Intentionally empty. Relay's manifest is NOT inherited: its exceptions // are bound to issue keys, rules, and components that do not exist here, - // and importing them would suppress real ShiftPrint findings. Any entry + // and importing them would suppress real ShiftPress findings. Any entry // added later must name the exact issue key, rule, and component, and // applies only on the integration branch. ]); @@ -123,7 +123,7 @@ function sonarApiBase(hostUrl) { return base; } -// ShiftPrint starts with zero reviewed exceptions. The exact-count guard is +// ShiftPress starts with zero reviewed exceptions. The exact-count guard is // kept so the manifest cannot grow or shrink without a deliberate edit here. export const EXPECTED_REVIEWED_ISSUE_COUNT = 0; export const EXPECTED_REVIEWED_ACCEPT_COUNT = 0; diff --git a/scripts/sonar-reviewed-issues.test.mjs b/scripts/sonar-reviewed-issues.test.mjs index f1560bf..fb73502 100644 --- a/scripts/sonar-reviewed-issues.test.mjs +++ b/scripts/sonar-reviewed-issues.test.mjs @@ -51,7 +51,7 @@ function page(issues, { pageIndex = 1, total = issues.length } = {}) { }; } -test('starts ShiftPrint with an exact empty reviewed inventory', () => { +test('starts ShiftPress with an exact empty reviewed inventory', () => { assert.equal(validateReviewedIssueManifest(), REVIEWED_ISSUES); assert.deepEqual(REVIEWED_ISSUES, []); assert.equal(Object.isFrozen(REVIEWED_ISSUES), true); @@ -67,10 +67,10 @@ test('rejects any inherited or unreviewed exception metadata', () => { test('reads one exact project key and requires an explicit apply latch on branch main', () => { assert.equal( - parseProjectKey('sonar.projectName=ShiftPrint\nsonar.projectKey=CrimsonSoul_ShiftPrint\n'), + parseProjectKey('sonar.projectName=ShiftPress\nsonar.projectKey=CrimsonSoul_ShiftPrint\n'), PROJECT_KEY, ); - assert.throws(() => parseProjectKey('sonar.projectName=ShiftPrint\n'), /sonar\.projectKey/); + assert.throws(() => parseProjectKey('sonar.projectName=ShiftPress\n'), /sonar\.projectKey/); assert.throws( () => parseProjectKey('sonar.projectKey=one\nsonar.projectKey=two\n'), /exactly one/i, diff --git a/setup.bat b/setup.bat index b2fa5ca..059b029 100644 --- a/setup.bat +++ b/setup.bat @@ -1,5 +1,5 @@ @echo off -echo Setting up ShiftPrint... +echo Setting up ShiftPress... REM Check if Python is installed python --version >nul 2>&1 diff --git a/sonar-project.properties b/sonar-project.properties index 467fdc5..1953977 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,5 +1,5 @@ sonar.projectKey=CrimsonSoul_ShiftPrint -sonar.projectName=ShiftPrint +sonar.projectName=ShiftPress sonar.sourceEncoding=UTF-8 sonar.sources=src,main.py,tools diff --git a/src/__init__.py b/src/__init__.py index 2391162..2e8c1ad 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,5 +1,5 @@ """ -ShiftPrint - Batch print shift schedules via Word COM automation. +ShiftPress - Batch print shift schedules via Word COM automation. This package provides modules for: - Configuration management (config) @@ -13,9 +13,9 @@ """ __version__ = "3.0.0" -__author__ = "ShiftPrint" +__author__ = "ShiftPress" -__all__ = ["ShiftPrintApp", "main"] +__all__ = ["ShiftPressApp", "main"] def __getattr__(name: str) -> object: @@ -25,16 +25,16 @@ def __getattr__(name: str) -> object: name: The attribute name being looked up. Returns: - The requested module-level symbol (``ShiftPrintApp`` or ``main``). + The requested module-level symbol (``ShiftPressApp`` or ``main``). Raises: AttributeError: If *name* is not a public symbol of this package. """ - if name in ("ShiftPrintApp", "main"): - from .main import ShiftPrintApp, main # noqa: F811 + if name in ("ShiftPressApp", "main"): + from .main import ShiftPressApp, main # noqa: F811 - globals()["ShiftPrintApp"] = ShiftPrintApp + globals()["ShiftPressApp"] = ShiftPressApp globals()["main"] = main return globals()[name] raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/app_paths.py b/src/app_paths.py index eb4d0a4..c7a571b 100644 --- a/src/app_paths.py +++ b/src/app_paths.py @@ -9,13 +9,13 @@ import os from pathlib import Path -APP_DIRNAME = "ShiftPrint" -APP_DOTNAME = ".shiftprint" +APP_DIRNAME = "ShiftPress" +APP_DOTNAME = ".shiftpress" -# The app was named ShiftPress before 2026-07-31. Existing installs still keep -# their config there, so the old location stays reachable for migration. -LEGACY_APP_DIRNAME = "ShiftPress" -LEGACY_APP_DOTNAME = ".shiftpress" +# The app was named ShiftPrint from 2026-07-31 until this rename. Existing +# installs may still keep their config there, so that location stays reachable. +LEGACY_APP_DIRNAME = "ShiftPrint" +LEGACY_APP_DOTNAME = ".shiftprint" def _data_dir_for(app_dirname: str, dotname: str) -> Path: @@ -45,8 +45,8 @@ def get_data_dir() -> Path: The directory is *not* created by this function; callers are responsible for calling ``mkdir()`` if needed. - Windows: %APPDATA%\\ShiftPrint (fallback to %LOCALAPPDATA%) - Other OSes (dev/test): ~/.shiftprint + Windows: %APPDATA%\\ShiftPress (fallback to %LOCALAPPDATA%) + Other OSes (dev/test): ~/.shiftpress Returns: Path to the per-user data directory. @@ -56,7 +56,7 @@ def get_data_dir() -> Path: def get_legacy_data_dir() -> Path: - """Return the per-user data directory used before the ShiftPrint rename. + """Return the per-user data directory used by ShiftPrint releases. Returns: Path to the pre-rename data directory. Not created. diff --git a/src/config.py b/src/config.py index 64e121d..a0b6a08 100644 --- a/src/config.py +++ b/src/config.py @@ -1,5 +1,5 @@ """ -Configuration management for ShiftPrint application. +Configuration management for ShiftPress application. This module handles loading, saving, and validating configuration settings. """ @@ -109,6 +109,7 @@ def _migrate_from(self, legacy_path: Path) -> Optional[AppConfig]: logger.warning( f"Could not migrate legacy config to {self.config_path}: {e}" ) + return config # Rename so the next launch does not migrate over a newer edit. try: diff --git a/src/constants.py b/src/constants.py index 0e61ac2..b5e7993 100644 --- a/src/constants.py +++ b/src/constants.py @@ -1,5 +1,5 @@ """ -Constants for ShiftPrint application. +Constants for ShiftPress application. This module contains all named constants used throughout the application to avoid magic numbers and strings. @@ -85,7 +85,7 @@ # Configuration CONFIG_FILENAME: Final = "config.json" -LOG_FILENAME: Final = "shiftprint.log" +LOG_FILENAME: Final = "shiftpress.log" # UI Constants WINDOW_WIDTH: Final = 1040 @@ -144,36 +144,36 @@ class Colors: FontSpec = Union[tuple[str, int], tuple[str, int, str]] -def _font_family() -> str: - """Return a platform-appropriate font family name. +def _font_families() -> tuple[str, str]: + """Return platform-appropriate body and display font families. Returns: - Font family name string suitable for the current OS. + Tuple containing body-copy and heading font family names. """ import sys if sys.platform == "darwin": - return "SF Pro Text" + return "SF Pro Text", "SF Pro Display" if sys.platform.startswith("linux"): - return "Ubuntu" - # Windows — prefer the variable font (Windows 11+); Tkinter silently - # falls back to "Segoe UI" (Windows 7+) if the variable font is absent. - return "Segoe UI Variable Display" + return "Ubuntu", "Ubuntu" + # Windows — use the optical role intended for each size. Tkinter silently + # falls back to Segoe UI when the variable families are unavailable. + return "Segoe UI Variable Text", "Segoe UI Variable Display" -_FONT_FAMILY: Final = _font_family() +_BODY_FONT_FAMILY, _DISPLAY_FONT_FAMILY = _font_families() @dataclass(frozen=True) class Fonts: """Font configuration for the application UI.""" - main: FontSpec = (_FONT_FAMILY, 11) - bold: FontSpec = (_FONT_FAMILY, 11, "bold") - header: FontSpec = (_FONT_FAMILY, 24, "bold") - card_title: FontSpec = (_FONT_FAMILY, 13, "bold") - sub: FontSpec = (_FONT_FAMILY, 10) - button: FontSpec = (_FONT_FAMILY, 14, "bold") + main: FontSpec = (_BODY_FONT_FAMILY, 11) + bold: FontSpec = (_BODY_FONT_FAMILY, 11, "bold") + header: FontSpec = (_DISPLAY_FONT_FAMILY, 24, "bold") + card_title: FontSpec = (_DISPLAY_FONT_FAMILY, 13, "bold") + sub: FontSpec = (_BODY_FONT_FAMILY, 10) + button: FontSpec = (_BODY_FONT_FAMILY, 14, "bold") # Global color and font instances diff --git a/src/logger.py b/src/logger.py index 99a8951..a6f50d9 100644 --- a/src/logger.py +++ b/src/logger.py @@ -1,5 +1,5 @@ """ -Logging configuration for ShiftPrint application. +Logging configuration for ShiftPress application. This module sets up the logging system with both file and console handlers. """ @@ -56,7 +56,7 @@ def setup_logging( # Remove only handlers previously added by this module to avoid # destroying third-party or test-framework handlers. - _TAG = "_shiftprint" + _TAG = "_shiftpress" for h in root_logger.handlers[:]: if getattr(h, _TAG, False): root_logger.removeHandler(h) @@ -98,9 +98,9 @@ def get_logger(name: Optional[str] = None) -> logging.Logger: Get a logger instance. Args: - name: Logger name (default: "shiftprint") + name: Logger name (default: "shiftpress") Returns: Logger instance """ - return logging.getLogger(name or "shiftprint") + return logging.getLogger(name or "shiftpress") diff --git a/src/main.py b/src/main.py index 78223e6..3dc1fae 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,5 @@ """ -ShiftPrint - Main Application Entry Point +ShiftPress - Main Application Entry Point Batch print shift schedules via Word COM automation. """ @@ -8,7 +8,7 @@ import csv from dataclasses import dataclass, replace from datetime import date, datetime -from typing import Optional, Callable, TypedDict +from typing import Optional, Callable, Literal, TypedDict import tkinter as tk @@ -65,7 +65,7 @@ class _BatchRequest: night_folder: str -class ShiftPrintApp: +class ShiftPressApp: """Main application controller. Coordinates configuration management, input validation, preflight @@ -102,7 +102,7 @@ def __init__(self, root: tk.Tk): # Handle window close gracefully self.root.protocol("WM_DELETE_WINDOW", self._on_close) - logger.info("ShiftPrint application initialized") + logger.info("ShiftPress application initialized") def _safe_after(self, callback: Callable[[], None]) -> None: """Schedule a UI callback if the window is still alive. @@ -135,25 +135,32 @@ def _load_config(self) -> None: self.ui.printer_var.set(config.printer_name) self.ui.refresh_setup_summary() logger.info("Configuration loaded successfully") - except Exception as e: + except Exception: logger.exception("Error loading configuration") self.ui.show_warning( - "Configuration Error", f"Could not load saved configuration: {e}" + "Setup could not be loaded", + "Open Setup, confirm both template folders and the printer, then " + "select Apply. No schedules have been sent to print.", ) - def _save_config(self, config: AppConfig) -> None: + def _save_config(self, config: AppConfig) -> bool: """ Save configuration (thread-safe). Args: config: Configuration to save + + Returns: + ``True`` when the configuration was persisted, otherwise ``False``. """ with self._save_lock: try: self.config_manager.save(config) logger.info("Configuration saved successfully") + return True except Exception: logger.exception("Error saving configuration") + return False @staticmethod def _normalize_folder(folder: str) -> str: @@ -313,13 +320,18 @@ def start_processing(self) -> None: return # Validate inputs + self.ui.update_status("Checking setup and templates…", 0, level="info") + self.root.update_idletasks() request, error_msg = self._validate_inputs() if request is None: + self.ui.update_status( + f"Cannot print: {error_msg or 'Unknown error'}", 0, level="error" + ) self.ui.show_warning("Validation Error", error_msg or "Unknown error") return if not self._confirm_large_batch(request.manifest): - self.ui.update_status("Cancelled by user", 0) + self.ui.update_status("Cancelled before printing", 0, level="info") return # Reset cancel flag @@ -343,7 +355,9 @@ def _request_cancel(self) -> bool: return False self._cancel_event.set() current_progress = self.ui.progress_var.get() if self.ui.progress_var else 0.0 - self.ui.update_status("Stopping after current document...", current_progress) + self.ui.update_status( + "Stopping after current document...", current_progress, level="info" + ) self.ui.set_print_button_state("disabled") return True @@ -383,9 +397,17 @@ def _cancel_if_running(self) -> None: """Cancel the current batch if one is active (Escape key handler).""" self._request_cancel() - def _cancel_ui_update(self) -> None: + def _cancel_ui_update(self, completed_jobs: int, total_jobs: int) -> None: """Schedule a 'Cancelled' status update on the UI thread.""" - self._safe_after(lambda: self.ui.update_status("Cancelled", 0)) + progress = (completed_jobs / max(total_jobs, 1)) * PROGRESS_MAX + noun = "schedule" if total_jobs == 1 else "schedules" + self._safe_after( + lambda: self.ui.update_status( + f"Cancelled after {completed_jobs} of {total_jobs} {noun}", + progress, + level="info", + ) + ) def _reset_ui(self) -> None: """Re-enable all inputs and reset the print button to its default state.""" @@ -403,7 +425,7 @@ def _print_job( job_index: int, total_jobs: int, failed_operations: list[FailedOperation], - ) -> None: + ) -> bool: """Print one concrete manifest job and record failures. Args: @@ -417,7 +439,7 @@ def _print_job( shift_label = job.shift_type.title() day_name = get_english_day_name(job.date) display_date = job.date.strftime(_DISPLAY_DATE_FORMAT) - progress = ((job_index + 1) / max(total_jobs, 1)) * 100 + progress = (job_index / max(total_jobs, 1)) * PROGRESS_MAX msg = ( f"Printing {shift_label} Shift: {day_name} {display_date} " f"({job_index + 1}/{total_jobs})..." @@ -446,6 +468,18 @@ def _update(m: str = msg, p: float = progress) -> None: logger.error( f"Failed to print {job.shift_type} shift for {job.date}: {error}" ) + completed_progress = ((job_index + 1) / max(total_jobs, 1)) * PROGRESS_MAX + completion_message = ( + f"Finished {shift_label} Shift: {day_name} {display_date} " + f"({job_index + 1}/{total_jobs})" + ) + level: Literal["info", "error"] = "info" if success else "error" + self._safe_after( + lambda: self.ui.update_status( + completion_message, completed_progress, level=level + ) + ) + return success def _process_batch(self, request: _BatchRequest) -> None: """Process exactly the concrete jobs in a validated request. @@ -464,10 +498,19 @@ def _process_batch(self, request: _BatchRequest) -> None: night_folder=request.night_folder, printer_name=request.printer_name, ) - self._save_config(config) + if not self._save_config(config): + self._safe_after( + lambda: self.ui.show_warning( + "Setup was not saved", + "This print run can continue, but these folders and printer may " + "not be restored next time. If this repeats, select Open logs " + "and contact support.", + ) + ) logger.info(f"Processing {total_jobs} selected schedules") failed_operations: list[FailedOperation] = [] + completed_jobs = 0 try: wp = self._preflight_wp or WordProcessor() @@ -479,7 +522,7 @@ def _process_batch(self, request: _BatchRequest) -> None: for job_index, job in enumerate(request.manifest): if self._cancel_event.is_set(): logger.info("Batch processing cancelled by user") - self._cancel_ui_update() + self._cancel_ui_update(completed_jobs, total_jobs) return self._print_job( @@ -490,30 +533,63 @@ def _process_batch(self, request: _BatchRequest) -> None: total_jobs, failed_operations, ) - - self._safe_after( - lambda: self.ui.update_status("Complete!", PROGRESS_MAX) - ) + completed_jobs += 1 if failed_operations: + failure_count = len(failed_operations) + failure_noun = "schedule" if failure_count == 1 else "schedules" + self._safe_after( + lambda: self.ui.update_status( + f"Completed with {failure_count} failed {failure_noun}", + PROGRESS_MAX, + level="error", + ) + ) report_path = self._write_failure_report(failed_operations) snapshot = list(failed_operations) self._safe_after( lambda: self._show_failure_summary(snapshot, report_path) ) else: + schedule_noun = "schedule" if total_jobs == 1 else "schedules" + self._safe_after( + lambda: self.ui.update_status( + f"All {total_jobs} {schedule_noun} sent to printer", + PROGRESS_MAX, + level="success", + ) + ) self._safe_after( lambda: self.ui.show_info( "Success", - f"All {total_jobs} selected schedules have been " - "processed and sent to the printer.", + f"All {total_jobs} {schedule_noun} were sent to the printer.", ) ) - except Exception as e: + except Exception: logger.exception("Error during batch processing") - err_msg = f"An error occurred during processing: {type(e).__name__}: {e}" - self._safe_after(lambda: self.ui.show_error("Processing Error", err_msg)) + progress = (completed_jobs / max(total_jobs, 1)) * PROGRESS_MAX + if completed_jobs: + status = ( + f"Printing stopped after {completed_jobs} of {total_jobs} " + f"schedule{'' if total_jobs == 1 else 's'}" + ) + else: + status = "Printing stopped before any schedules were completed" + self._safe_after( + lambda: self.ui.update_status( + status, + progress, + level="error", + ) + ) + self._safe_after( + lambda: self.ui.show_error( + "Printing stopped", + "Review the selected printer and template folders, then try again. " + "If the problem repeats, select Open logs and contact support.", + ) + ) finally: self._safe_after(self._reset_ui) @@ -545,7 +621,12 @@ def _on_close(self) -> None: night_folder=(self.ui.get_night_folder() or "").strip(), printer_name=printer, ) - self._save_config(config) + if not self._save_config(config): + self.ui.show_warning( + "Setup was not saved", + "These folders and printer may not be restored next time. Review " + "the application logs if this repeats.", + ) except Exception as e: logger.warning(f"Could not save config on close: {e}") @@ -626,13 +707,13 @@ def _write_failure_report( def main() -> None: """Main entry point for the application.""" setup_logging() - logger.info("Starting ShiftPrint") + logger.info("Starting ShiftPress") try: root = tk.Tk() - app = ShiftPrintApp(root) + app = ShiftPressApp(root) app.ui.run() - except Exception as e: + except Exception: logger.exception("Fatal error in main") try: import tkinter.messagebox as mb @@ -641,14 +722,15 @@ def main() -> None: log_path = data_dir / LOG_FILENAME mb.showerror( "Fatal Error", - "The application encountered a fatal error:\n\n" - f"{str(e)}\n\n" + "ShiftPress could not start. Close Microsoft Word, then reopen " + "ShiftPress. If the problem repeats, contact support and share " + "the log file.\n\n" f"Logs are saved to:\n{log_path}", ) except Exception: - print(f"Fatal error: {e}") + print("ShiftPress could not start. Review the application log for details.") finally: - logger.info("ShiftPrint shutting down") + logger.info("ShiftPress shutting down") if __name__ == "__main__": diff --git a/src/path_validation.py b/src/path_validation.py index 4622c6a..ddbea91 100644 --- a/src/path_validation.py +++ b/src/path_validation.py @@ -1,5 +1,5 @@ """ -Path validation and safety utilities for ShiftPrint application. +Path validation and safety utilities for ShiftPress application. This module provides functions to validate and sanitize file paths to prevent security issues like path traversal attacks. diff --git a/src/print_manifest.py b/src/print_manifest.py index 3957ea1..3dbc5c3 100644 --- a/src/print_manifest.py +++ b/src/print_manifest.py @@ -1,4 +1,4 @@ -"""Pure print-selection and manifest construction for ShiftPrint.""" +"""Pure print-selection and manifest construction for ShiftPress.""" from dataclasses import dataclass from datetime import date diff --git a/src/scheduler.py b/src/scheduler.py index e71b28e..ac18cba 100644 --- a/src/scheduler.py +++ b/src/scheduler.py @@ -1,5 +1,5 @@ """ -Date and scheduling logic for ShiftPrint application. +Date and scheduling logic for ShiftPress application. This module handles date calculations, including special scheduling rules like third Thursday detection. diff --git a/src/ui.py b/src/ui.py index 377bfa0..a8ef611 100644 --- a/src/ui.py +++ b/src/ui.py @@ -1,13 +1,16 @@ """ -UI components for ShiftPrint application. +UI components for ShiftPress application. This module contains all Tkinter UI components and styling. """ import os +import re import sys +import ctypes import subprocess import tkinter as tk +from ctypes import wintypes from dataclasses import dataclass from tkinter import messagebox, filedialog, ttk from datetime import date, timedelta @@ -49,6 +52,11 @@ logger = get_logger(__name__) +_SPI_GETWORKAREA = 0x0030 +_FALLBACK_SYSTEM_UI_RESERVE = 40 +_WINDOW_FRAME_WIDTH_RESERVE = 16 +_WINDOW_FRAME_HEIGHT_RESERVE = 40 + # Imported lazily to avoid circular dependency; only used for version display. _APP_VERSION: Optional[str] = None @@ -64,7 +72,7 @@ _STYLE_DANGER_BUTTON = "Danger.TButton" _STYLE_HEADER_LABEL = "Header.TLabel" _STYLE_SUB_LABEL = "Sub.TLabel" -_STYLE_COUNT_READY_LABEL = "CountReady.TLabel" +_STYLE_COUNT_SELECTED_LABEL = "CountSelected.TLabel" _STYLE_SUCCESS_LABEL = "Success.TLabel" _STYLE_ERROR_LABEL = "Error.TLabel" _STYLE_CARD_CHECKBUTTON = "Card.TCheckbutton" @@ -73,6 +81,31 @@ _DATE_PATTERN = "mm/dd/yyyy" +def _get_work_area(window: tk.Misc) -> tuple[int, int, int, int]: + """Return usable screen bounds, excluding the Windows taskbar when possible.""" + screen_width = window.winfo_screenwidth() + screen_height = window.winfo_screenheight() + + if sys.platform == "win32": + try: + rect = wintypes.RECT() + user32 = getattr(ctypes, "windll").user32 + if user32.SystemParametersInfoW(_SPI_GETWORKAREA, 0, ctypes.byref(rect), 0): + if rect.right > rect.left and rect.bottom > rect.top: + return rect.left, rect.top, rect.right, rect.bottom + except (AttributeError, OSError): + logger.debug("Could not query the Windows work area", exc_info=True) + + # Tk exposes the full display rather than platform-reserved areas on some + # systems. Preserve a small taskbar/dock allowance in that fallback path. + return ( + 0, + 0, + screen_width, + max(AUTO_RESIZE_MIN_HEIGHT, screen_height - _FALLBACK_SYSTEM_UI_RESERVE), + ) + + @dataclass class _ShiftPanelWidgets: """Widget and variable references for one independent shift panel.""" @@ -108,76 +141,6 @@ def _get_version() -> str: return _APP_VERSION -class _ToolTip: - """Lightweight hover tooltip for any Tkinter widget.""" - - def __init__(self, widget: Any, text: str, delay: int = 400) -> None: - """Create a tooltip that appears on hover. - - Args: - widget: The Tkinter widget to attach the tooltip to. - text: Tooltip text to display. - delay: Delay in milliseconds before showing the tooltip. - """ - self._widget = widget - self._text = text - self._delay = delay - self._tip_window: Optional[tk.Toplevel] = None - self._after_id: Optional[str] = None - widget.bind("", self._schedule, add="+") - widget.bind("", self._hide, add="+") - widget.bind("", self._hide, add="+") - widget.bind("", self._on_destroy, add="+") - - def _schedule(self, _event: Any = None) -> None: - self._cancel() - self._after_id = self._widget.after(self._delay, self._show) - - def _on_destroy(self, _event: Any = None) -> None: - self._cancel() - self._hide() - - def _show(self) -> None: - if self._tip_window: - return - try: - x = self._widget.winfo_rootx() + 20 - y = self._widget.winfo_rooty() + self._widget.winfo_height() + 4 - except Exception as e: - logger.debug(f"Tooltip geometry lookup failed: {e}") - return - try: - tw = tk.Toplevel(self._widget) - except tk.TclError: - return - tw.wm_overrideredirect(True) - tw.wm_geometry(f"+{x}+{y}") - label = tk.Label( - tw, - text=self._text, - background=COLORS.surface, - foreground=COLORS.text_main, - relief="solid", - borderwidth=1, - font=FONTS.sub, - padx=6, - pady=4, - ) - label.pack() - self._tip_window = tw - - def _hide(self, _event: Any = None) -> None: - self._cancel() - if self._tip_window: - self._tip_window.destroy() - self._tip_window = None - - def _cancel(self) -> None: - if self._after_id: - self._widget.after_cancel(self._after_id) - self._after_id = None - - def _setup_placeholder(entry: ttk.Entry, placeholder: str) -> None: """Attach placeholder text behaviour to a ttk.Entry. @@ -202,7 +165,7 @@ def _hide(_event: Any = None) -> None: class ScheduleAppUI: - """Main UI class for the ShiftPrint application.""" + """Main UI class for the ShiftPress application.""" def __init__(self, root: tk.Tk, today: Optional[date] = None): """ @@ -213,11 +176,12 @@ def __init__(self, root: tk.Tk, today: Optional[date] = None): today: Optional deterministic date used for launch defaults. """ self.root = root - self.root.title("ShiftPrint") + self.root.title("ShiftPress") self.root.resizable(WINDOW_RESIZABLE, WINDOW_RESIZABLE) self.root.configure(bg=COLORS.background) self._today = today or date.today() self._inputs_enabled = True + self._dependency_error: Optional[str] = None # Apply window icon if available. self._apply_icon() @@ -234,17 +198,29 @@ def __init__(self, root: tk.Tk, today: Optional[date] = None): self.setup_summary_label: Optional[ttk.Label] = None self._setup_details: Optional[ttk.Frame] = None self._setup_dialog: Optional[tk.Toplevel] = None + self._setup_canvas: Optional[tk.Canvas] = None + self._setup_content: Optional[ttk.Frame] = None + self._setup_content_window: Optional[int] = None + self._setup_scrollbar: Optional[ttk.Scrollbar] = None + self._setup_snapshot: Optional[tuple[str, str, str]] = None self._setup_toggle_btn: Optional[ttk.Button] = None self._manifest_card: Optional[ttk.Frame] = None self._footer_frame: Optional[ttk.Frame] = None + self._logs_btn: Optional[ttk.Button] = None + self._main_canvas: Optional[tk.Canvas] = None + self._main_content: Optional[ttk.Frame] = None + self._main_content_window: Optional[int] = None + self._main_scrollbar: Optional[ttk.Scrollbar] = None self.manifest_title_label: Optional[ttk.Label] = None self.manifest_label: Optional[ttk.Label] = None self.status_label: Optional[ttk.Label] = None self.progress_var: Optional[tk.DoubleVar] = None self.progress: Optional[ttk.Progressbar] = None + self._progress_row: Optional[ttk.Frame] = None self._progress_pct: Optional[ttk.Label] = None self.printer_dropdown: Optional[ttk.OptionMenu] = None self._refresh_btn: Optional[ttk.Button] = None + self._printer_status_label: Optional[ttk.Label] = None self.print_btn: Optional[ttk.Button] = None self._browse_buttons: list[ttk.Button] = [] self._shift_panels: dict[ShiftType, _ShiftPanelWidgets] = {} @@ -305,7 +281,7 @@ def _configure_styles(self) -> None: self.style.configure( "SetupTitle.TLabel", background=COLORS.background, - foreground=COLORS.accent, + foreground=COLORS.text_main, font=FONTS.card_title, ) self.style.configure( @@ -330,7 +306,7 @@ def _configure_styles(self) -> None: fieldbackground=COLORS.input, foreground=COLORS.text_main, insertcolor=COLORS.text_main, - selectbackground=COLORS.accent, + selectbackground=COLORS.action, selectforeground=COLORS.text_main, bordercolor=COLORS.border, borderwidth=1, @@ -405,7 +381,7 @@ def _configure_styles(self) -> None: _STYLE_PRIMARY_BUTTON, background=COLORS.action, foreground=COLORS.text_main, - bordercolor=COLORS.day_accent, + bordercolor=COLORS.action, borderwidth=1, font=FONTS.button, padding=(26, 16), @@ -473,7 +449,7 @@ def _configure_styles(self) -> None: # Semantic readiness states. Colour reinforces the label text; it is # never the only signal, per the DESIGN.md validation-state rule. for count_style, count_color in ( - (_STYLE_COUNT_READY_LABEL, COLORS.success), + (_STYLE_COUNT_SELECTED_LABEL, COLORS.text_main), ("CountMuted.TLabel", COLORS.text_dim), ("CountError.TLabel", COLORS.error), ): @@ -566,6 +542,29 @@ def _configure_styles(self) -> None: foreground=[("disabled", COLORS.text_dim)], ) + # Clam otherwise relies on subtle platform defaults for keyboard focus. + for style_name in ( + "TButton", + "TEntry", + "TCombobox", + "TMenubutton", + "TCheckbutton", + "TRadiobutton", + _STYLE_CARD_CHECKBUTTON, + _STYLE_CARD_RADIOBUTTON, + ): + self.style.configure( + style_name, + focuscolor=COLORS.night_accent, + focusthickness=2, + ) + self.style.map( + style_name, + bordercolor=[("focus", COLORS.night_accent)], + lightcolor=[("focus", COLORS.night_accent)], + darkcolor=[("focus", COLORS.night_accent)], + ) + def _apply_icon(self) -> None: """Set the window icon from the bundled icon file, if present.""" try: @@ -586,15 +585,14 @@ def _apply_icon(self) -> None: logger.debug(f"Could not set window icon: {e}") def _center_window(self) -> None: - """Center the window on the primary monitor.""" + """Center the window inside the taskbar-safe primary work area.""" try: self.root.update_idletasks() w = self.root.winfo_width() h = self.root.winfo_height() - scr_w = self.root.winfo_screenwidth() - scr_h = self.root.winfo_screenheight() - x = max(0, (scr_w - w) // 2) - y = max(0, (scr_h - h) // 2) + left, top, right, bottom = _get_work_area(self.root) + x = left + max(0, (right - left - w - _WINDOW_FRAME_WIDTH_RESERVE) // 2) + y = top + max(0, (bottom - top - h - _WINDOW_FRAME_HEIGHT_RESERVE) // 2) self.root.geometry(f"{w}x{h}+{x}+{y}") except Exception as e: logger.debug(f"Could not center window: {e}") @@ -645,6 +643,25 @@ def refresh_printers(self) -> None: self.printer_var.set(DEFAULT_PRINTER_LABEL) except Exception: logger.exception("Could not update printer dropdown") + self._update_printer_status(printers) + self.refresh_setup_summary() + self.refresh_manifest_preview() + + def _update_printer_status(self, printers: list[str]) -> None: + """Show the result of the latest printer enumeration.""" + if self._printer_status_label is None: + return + count = len(printers) + if count: + noun = "printer" if count == 1 else "printers" + self._printer_status_label.config( + text=f"{count} {noun} available", style=_STYLE_CARD_SUB_LABEL + ) + return + message = "No printers found. Check connections, then Refresh." + if win32print is None: + message = "Printing requires Windows with pywin32 installed." + self._printer_status_label.config(text=message, style=_STYLE_ERROR_LABEL) # ------------------------------------------------------------------ # Widget creation @@ -652,16 +669,118 @@ def refresh_printers(self) -> None: def _create_widgets(self) -> None: """Create all UI widgets.""" - bg_canvas = ttk.Frame(self.root, padding="28") - bg_canvas.pack(fill="both", expand=True) + viewport = ttk.Frame(self.root) + viewport.pack(fill="both", expand=True) + self._main_canvas = tk.Canvas( + viewport, + background=COLORS.background, + borderwidth=0, + highlightthickness=0, + ) + self._main_scrollbar = ttk.Scrollbar( + viewport, + orient="vertical", + command=self._main_canvas.yview, + ) + self._main_canvas.configure(yscrollcommand=self._main_scrollbar.set) + self._main_canvas.pack(side="left", fill="both", expand=True) + + bg_canvas = ttk.Frame(self._main_canvas, padding="28") + self._main_content = bg_canvas + self._main_content_window = self._main_canvas.create_window( + (0, 0), window=bg_canvas, anchor="nw" + ) + bg_canvas.bind("", self._sync_main_scroll_region) + self._main_canvas.bind("", self._resize_main_content) + self.root.bind("", self._scroll_main_content, add="+") + self.root.bind("", self._ensure_focus_visible, add="+") self._create_header(bg_canvas) self._create_setup_card(bg_canvas) self._create_shift_selection_row(bg_canvas) self._create_manifest_card(bg_canvas) self._create_footer(bg_canvas) + self.root.bind("", lambda _event: self._show_setup_dialog()) + self.root.bind("", lambda _event: self.show_help()) self.refresh_manifest_preview() + def _sync_main_scroll_region(self, _event: Any = None) -> None: + """Keep the scroll range aligned with the rendered work surface.""" + if self._main_canvas is not None: + self._main_canvas.configure(scrollregion=self._main_canvas.bbox("all")) + + def _resize_main_content(self, event: Any) -> None: + """Match the work surface width to its viewport without clipping height.""" + if self._main_canvas is None or self._main_content_window is None: + return + self._main_canvas.itemconfigure(self._main_content_window, width=event.width) + if ( + self._main_content is not None + and self._main_content.winfo_reqheight() > event.height + ): + self._show_main_scrollbar() + else: + self._hide_main_scrollbar() + + def _show_main_scrollbar(self) -> None: + """Expose native overflow navigation on constrained displays.""" + if self._main_scrollbar is not None: + self._main_scrollbar.pack(side="right", fill="y") + + def _hide_main_scrollbar(self) -> None: + """Keep the normal work surface free of unnecessary scroll chrome.""" + if self._main_scrollbar is not None: + self._main_scrollbar.pack_forget() + if self._main_canvas is not None: + self._main_canvas.yview_moveto(0.0) + + def _scroll_main_content(self, event: Any) -> None: + """Scroll overflowing content with the standard Windows mouse wheel.""" + if self._main_canvas is None or self._main_scrollbar is None: + return + if not self._main_scrollbar.winfo_ismapped(): + return + delta = getattr(event, "delta", 0) + if delta: + units = int(-delta / 120) + if units == 0: + units = -1 if delta > 0 else 1 + self._main_canvas.yview_scroll(units, "units") + + def _ensure_focus_visible(self, event: Any) -> None: + """Reveal a focused descendant when keyboard navigation reaches overflow.""" + if ( + self._main_canvas is None + or self._main_content is None + or self._main_scrollbar is None + or not self._main_scrollbar.winfo_ismapped() + ): + return + self._ensure_widget_visible(event, self._main_canvas, self._main_content) + + @staticmethod + def _ensure_widget_visible( + event: Any, canvas: tk.Canvas, content: ttk.Frame + ) -> None: + """Scroll one canvas just enough to reveal its focused descendant.""" + widget = getattr(event, "widget", None) + try: + if widget is None or not str(widget).startswith(str(content)): + return + content_height = content.winfo_reqheight() + viewport_height = canvas.winfo_height() + top = widget.winfo_rooty() - content.winfo_rooty() + bottom = top + widget.winfo_height() + visible_top = canvas.canvasy(0) + visible_bottom = visible_top + viewport_height + if top < visible_top: + canvas.yview_moveto(max(0.0, top / content_height)) + elif bottom > visible_bottom: + target = max(0, bottom - viewport_height) + canvas.yview_moveto(min(1.0, target / content_height)) + except Exception as e: + logger.debug(f"Could not reveal focused control: {e}") + def _create_header(self, parent: ttk.Frame) -> None: """Create the header section.""" header_row = ttk.Frame(parent) @@ -678,7 +797,7 @@ def _create_header(self, parent: ttk.Frame) -> None: if version: ttk.Label( title_row, - text=f"ShiftPrint · v{version}", + text=f"ShiftPress · v{version}", style=_STYLE_SUB_LABEL, ).pack(side="right", anchor="s", pady=(0, 5)) @@ -733,7 +852,8 @@ def _create_setup_card(self, parent: ttk.Frame) -> None: self.setup_summary_label.pack(side="left", anchor="w") self._setup_toggle_btn = ttk.Button( summary_row, - text="Change…", + text="Setup…", + underline=0, command=self._show_setup_dialog, width=12, ) @@ -747,13 +867,36 @@ def _create_setup_dialog(self) -> None: dialog = tk.Toplevel(self.root) self._setup_dialog = dialog dialog.withdraw() - dialog.title("ShiftPrint Setup") + dialog.title("ShiftPress Setup") dialog.configure(bg=COLORS.background) - dialog.resizable(True, False) - dialog.protocol("WM_DELETE_WINDOW", self._hide_setup_dialog) - - canvas = ttk.Frame(dialog, padding="24") - canvas.pack(fill="both", expand=True) + dialog.resizable(True, True) + dialog.protocol("WM_DELETE_WINDOW", self._cancel_setup_dialog) + dialog.bind("", lambda _event: self._cancel_setup_dialog()) + + viewport = ttk.Frame(dialog) + viewport.pack(fill="both", expand=True) + self._setup_canvas = tk.Canvas( + viewport, + background=COLORS.background, + borderwidth=0, + highlightthickness=0, + ) + self._setup_scrollbar = ttk.Scrollbar( + viewport, + orient="vertical", + command=self._setup_canvas.yview, + ) + self._setup_canvas.configure(yscrollcommand=self._setup_scrollbar.set) + self._setup_canvas.pack(side="left", fill="both", expand=True) + canvas = ttk.Frame(self._setup_canvas, padding="24") + self._setup_content = canvas + self._setup_content_window = self._setup_canvas.create_window( + (0, 0), window=canvas, anchor="nw" + ) + canvas.bind("", self._sync_setup_scroll_region) + self._setup_canvas.bind("", self._resize_setup_content) + dialog.bind("", self._scroll_setup_content, add="+") + dialog.bind("", self._ensure_setup_focus_visible, add="+") ttk.Label(canvas, text="Setup", style=_STYLE_HEADER_LABEL).pack(anchor="w") ttk.Label( canvas, @@ -768,40 +911,145 @@ def _create_setup_dialog(self) -> None: ) self._setup_details.pack(fill="both", expand=True) - self.day_entry = self._create_path_row(self._setup_details, "Day Templates", "") self.night_entry = self._create_path_row( self._setup_details, "Night Templates", "" ) + self.day_entry = self._create_path_row(self._setup_details, "Day Templates", "") _setup_placeholder(self.day_entry, _PATH_PLACEHOLDER) _setup_placeholder(self.night_entry, _PATH_PLACEHOLDER) self._create_printer_row(self._setup_details) + action_row = ttk.Frame(canvas) + action_row.pack(anchor="e", pady=(18, 0)) ttk.Button( - canvas, - text="Done", - command=self._hide_setup_dialog, + action_row, + text="Cancel", + command=self._cancel_setup_dialog, width=14, - ).pack(anchor="e", pady=(18, 0)) + ).pack(side="left", padx=(0, 10)) + ttk.Button( + action_row, + text="Apply", + command=self._apply_setup_dialog, + width=14, + ).pack(side="left") + + def _sync_setup_scroll_region(self, _event: Any = None) -> None: + """Keep Setup's scroll range aligned with its rendered content.""" + if self._setup_canvas is not None: + self._setup_canvas.configure(scrollregion=self._setup_canvas.bbox("all")) + + def _resize_setup_content(self, event: Any) -> None: + """Fit Setup content to its viewport and reveal overflow only when needed.""" + if self._setup_canvas is None or self._setup_content_window is None: + return + self._setup_canvas.itemconfigure(self._setup_content_window, width=event.width) + if ( + self._setup_content is not None + and self._setup_content.winfo_reqheight() > event.height + ): + self._show_setup_scrollbar() + else: + self._hide_setup_scrollbar() + + def _show_setup_scrollbar(self) -> None: + """Expose native overflow navigation in a constrained Setup dialog.""" + if self._setup_scrollbar is not None: + self._setup_scrollbar.pack(side="right", fill="y") + + def _hide_setup_scrollbar(self) -> None: + """Hide Setup overflow chrome when all controls already fit.""" + if self._setup_scrollbar is not None: + self._setup_scrollbar.pack_forget() + if self._setup_canvas is not None: + self._setup_canvas.yview_moveto(0.0) + + def _scroll_setup_content(self, event: Any) -> None: + """Scroll overflowing Setup content with the standard mouse wheel.""" + if self._setup_canvas is None or self._setup_scrollbar is None: + return + if not self._setup_scrollbar.winfo_ismapped(): + return + delta = getattr(event, "delta", 0) + if delta: + units = int(-delta / 120) + if units == 0: + units = -1 if delta > 0 else 1 + self._setup_canvas.yview_scroll(units, "units") + + def _ensure_setup_focus_visible(self, event: Any) -> None: + """Reveal the focused Setup control during keyboard navigation.""" + if ( + self._setup_canvas is None + or self._setup_content is None + or self._setup_scrollbar is None + or not self._setup_scrollbar.winfo_ismapped() + ): + return + self._ensure_widget_visible( + event, + self._setup_canvas, + self._setup_content, + ) def _show_setup_dialog(self) -> None: """Show setup without expanding or displacing the print work surface.""" if self._setup_dialog is None: return dialog = self._setup_dialog + self._setup_snapshot = ( + self.get_day_folder(), + self.get_night_folder(), + self.get_printer_name(), + ) dialog.deiconify() try: dialog.transient(self.root) dialog.update_idletasks() - width = max(720, dialog.winfo_reqwidth()) - height = dialog.winfo_reqheight() - x = self.root.winfo_rootx() + max(0, (self.root.winfo_width() - width) // 2) - y = self.root.winfo_rooty() + 48 + if self._setup_content is None: + return + req_width = self._setup_content.winfo_reqwidth() + req_height = self._setup_content.winfo_reqheight() + left, top, right, bottom = _get_work_area(dialog) + work_width = right - left + work_height = bottom - top + usable_width = max(520, work_width - _WINDOW_FRAME_WIDTH_RESERVE) + usable_height = max(400, work_height - _WINDOW_FRAME_HEIGHT_RESERVE) + width = min(max(720, req_width), usable_width) + height = min(max(400, req_height), usable_height) + centered_x = self.root.winfo_rootx() + max( + 0, (self.root.winfo_width() - width) // 2 + ) + x = min(max(left, centered_x), max(left, right - width)) + y = min(max(top, self.root.winfo_rooty() + 48), max(top, bottom - height)) dialog.geometry(f"{width}x{height}+{x}+{y}") + if req_height > height: + self._show_setup_scrollbar() + else: + self._hide_setup_scrollbar() dialog.grab_set() except Exception as e: logger.debug(f"Could not position setup dialog: {e}") dialog.lift() dialog.focus_force() + if self.night_entry is not None: + self.night_entry.focus_set() + + def _apply_setup_dialog(self) -> None: + """Keep the edited Setup values and return to the work surface.""" + self._setup_snapshot = None + self._hide_setup_dialog() + + def _cancel_setup_dialog(self) -> None: + """Restore the values present when Setup opened, then close it.""" + if self._setup_snapshot is not None: + day_folder, night_folder, printer = self._setup_snapshot + self.set_day_folder(day_folder) + self.set_night_folder(night_folder) + if self.printer_var is not None: + self.printer_var.set(printer) + self._setup_snapshot = None + self._hide_setup_dialog() def _hide_setup_dialog(self) -> None: """Close setup back to its compact summary.""" @@ -815,24 +1063,49 @@ def _hide_setup_dialog(self) -> None: self._setup_dialog.withdraw() def refresh_setup_summary(self) -> None: - """Summarize configured templates and printer without exposing paths.""" + """Identify configured template sources without exposing long paths.""" if self.setup_summary_label is None: return - day_configured = bool(self.get_day_folder()) - night_configured = bool(self.get_night_folder()) - if day_configured and night_configured: - template_status = "Templates configured" - elif day_configured or night_configured: - template_status = "Template folders incomplete" - else: - template_status = "Template folders not configured" + day_folder = self.get_day_folder() + night_folder = self.get_night_folder() printer = self.get_printer_name() printer_status = ( - printer - if printer and printer != DEFAULT_PRINTER_LABEL - else "Choose a printer" + printer if printer and printer != DEFAULT_PRINTER_LABEL else "Not selected" + ) + day_status = self._folder_tail(day_folder) + night_status = self._folder_tail(night_folder) + self.setup_summary_label.config( + text=( + f"Night templates: {night_status}\n" + f"Day templates: {day_status}\n" + f"Printer: {printer_status}" + ) ) - self.setup_summary_label.config(text=f"{template_status}\n{printer_status}") + + @staticmethod + def _folder_tail(folder: str) -> str: + """Return a compact, recognizable two-part folder identity.""" + if not isinstance(folder, str): + return "Not configured" + value = folder.strip().rstrip("/\\") + if not value or value == _PATH_PLACEHOLDER: + return "Not configured" + parts = [part for part in re.split(r"[/\\]+", value) if part] + if not parts: + return value + if parts[0].endswith(":"): + tokens = parts if len(parts) <= 3 else [parts[0], parts[1], parts[-1]] + elif value.startswith("\\\\") and len(parts) > 3: + tokens = [f"{parts[0]}\\{parts[1]}", parts[2], parts[-1]] + else: + tokens = parts[-3:] + compact = " › ".join(tokens) + if len(compact) <= 52: + return compact + shortened = [ + token if len(token) <= 15 else f"{token[:12]}…" for token in tokens + ] + return " › ".join(shortened) def _create_shift_selection_row(self, parent: ttk.Frame) -> None: """Create equal-width Night and Day selection panels.""" @@ -841,6 +1114,21 @@ def _create_shift_selection_row(self, parent: ttk.Frame) -> None: row.grid_columnconfigure(0, weight=1, uniform="shift") row.grid_columnconfigure(1, weight=1, uniform="shift") + if DateEntry is None: + self._dependency_error = ( + "Date selection is unavailable. Reinstall ShiftPress to restore " + "tkcalendar." + ) + ttk.Label( + row, + text=self._dependency_error, + style=_STYLE_ERROR_LABEL, + wraplength=760, + justify="left", + ).grid(row=0, column=0, columnspan=2, sticky="ew") + logger.error("tkcalendar is not installed; date pickers unavailable") + return + self._create_shift_panel( row, shift_type="night", @@ -897,15 +1185,6 @@ def _create_shift_panel( column: int, ) -> None: """Create one independent native shift selection panel.""" - if DateEntry is None: - ttk.Label( - parent, - text="Missing dependency: tkcalendar. Please reinstall requirements.txt.", - style=_STYLE_ERROR_LABEL, - ).grid(row=0, column=column, sticky="nsew", padx=8) - logger.error("tkcalendar is not installed; date pickers unavailable") - return - date_entry_cls = cast(Any, DateEntry) label = shift_type.title() accent = COLORS.night_accent if shift_type == "night" else COLORS.day_accent @@ -1004,7 +1283,7 @@ def sync_panel(selected: ShiftType = shift_type) -> None: count_label = ttk.Label( card, text="Selected · 1 document", - style=_STYLE_COUNT_READY_LABEL, + style=_STYLE_COUNT_SELECTED_LABEL, ) count_label.pack(anchor="w") @@ -1064,6 +1343,22 @@ def _on_shift_date_selected(self, shift_type: ShiftType) -> None: del shift_type self.refresh_manifest_preview() + def _reset_run(self) -> None: + """Restore the common Night-today and Day-tomorrow print scope.""" + defaults: dict[ShiftType, date] = { + "night": self._today, + "day": self._today + timedelta(days=1), + } + for shift_type, panel in self._shift_panels.items(): + default_date = defaults[shift_type] + panel.enabled_var.set(True) + panel.mode_var.set("single") + panel.single_picker.set_date(default_date) + panel.range_start_picker.set_date(default_date) + panel.range_end_picker.set_date(default_date) + self._sync_shift_panel_state(shift_type, refresh=False) + self.refresh_manifest_preview() + def _on_range_start_selected(self, shift_type: ShiftType) -> None: """Keep one shift's range end on or after its range start.""" panel = self._shift_panels[shift_type] @@ -1076,7 +1371,9 @@ def _on_range_start_selected(self, shift_type: ShiftType) -> None: logger.debug(f"Error syncing {shift_type} date pickers: {e}") self.refresh_manifest_preview() - def _sync_shift_panel_state(self, shift_type: ShiftType) -> None: + def _sync_shift_panel_state( + self, shift_type: ShiftType, refresh: bool = True + ) -> None: """Apply include/mode state to one panel without changing its values.""" panel = self._shift_panels[shift_type] enabled = bool(panel.enabled_var.get()) and self._inputs_enabled @@ -1104,7 +1401,7 @@ def _sync_shift_panel_state(self, shift_type: ShiftType) -> None: panel.range_wrap.grid_remove() panel.single_wrap.grid() - if len(self._shift_panels) == 2: + if refresh and len(self._shift_panels) == 2: self.refresh_manifest_preview() def _create_manifest_card(self, parent: ttk.Frame) -> None: @@ -1114,7 +1411,7 @@ def _create_manifest_card(self, parent: ttk.Frame) -> None: card.pack(fill="x", pady=(0, 16)) self.manifest_title_label = ttk.Label( card, - text="This run: No schedules selected", + text="Print scope: No schedules selected", style="ManifestTitle.TLabel", ) self.manifest_title_label.pack(anchor="w", pady=(0, 10)) @@ -1124,6 +1421,7 @@ def _create_manifest_card(self, parent: ttk.Frame) -> None: style=_STYLE_CARD_SUB_LABEL, justify="left", anchor="w", + wraplength=760, ) self.manifest_label.pack(fill="x") @@ -1188,21 +1486,33 @@ def _apply_content_sizing(self) -> None: try: self.root.update_idletasks() - req_w = self.root.winfo_reqwidth() - req_h = self.root.winfo_reqheight() - scr_w = self.root.winfo_screenwidth() - scr_h = self.root.winfo_screenheight() + if self._main_content is None: + return + req_w = self._main_content.winfo_reqwidth() + req_h = self._main_content.winfo_reqheight() + left, top, right, bottom = _get_work_area(self.root) + work_width = right - left + work_height = bottom - top target_w = min( - max(WINDOW_WIDTH, req_w), max(AUTO_RESIZE_MIN_WIDTH, scr_w - 80) + max(WINDOW_WIDTH, req_w), + max( + AUTO_RESIZE_MIN_WIDTH, + work_width - _WINDOW_FRAME_WIDTH_RESERVE, + ), ) - target_h = min( - max(AUTO_RESIZE_MIN_HEIGHT, req_h), - max(AUTO_RESIZE_MIN_HEIGHT, scr_h - 80), + usable_h = max( + AUTO_RESIZE_MIN_HEIGHT, + work_height - _WINDOW_FRAME_HEIGHT_RESERVE, ) + target_h = min(max(AUTO_RESIZE_MIN_HEIGHT, req_h), usable_h) - self.root.minsize(target_w, target_h) + self.root.minsize(target_w, AUTO_RESIZE_MIN_HEIGHT) self.root.geometry(f"{target_w}x{target_h}") + if req_h > target_h: + self._show_main_scrollbar() + else: + self._hide_main_scrollbar() except Exception as e: logger.debug(f"Content sizing skipped: {e}") @@ -1240,7 +1550,7 @@ def _create_printer_row(self, parent: ttk.Frame | ttk.LabelFrame) -> None: menu.configure( bg=COLORS.surface, fg=COLORS.text_main, - activebackground=COLORS.accent, + activebackground=COLORS.action, activeforeground=COLORS.text_main, borderwidth=1, relief="flat", @@ -1256,18 +1566,16 @@ def _create_printer_row(self, parent: ttk.Frame | ttk.LabelFrame) -> None: cursor="hand2", ) self._refresh_btn.pack(side="right") - _ToolTip(self._refresh_btn, "Re-scan for available printers") - if not all_printers: - msg = "No printers found. Check connections." - if win32print is None: - msg = "Printing requires Windows with pywin32 installed (win32print unavailable)." - ttk.Label( - output_row, - text=msg, - style=_STYLE_CARD_SUB_LABEL, - foreground=COLORS.error, - ).pack(anchor="w", pady=(4, 0)) + self._printer_status_label = ttk.Label( + output_row, + text="", + style=_STYLE_CARD_SUB_LABEL, + wraplength=640, + justify="left", + ) + self._printer_status_label.pack(anchor="w", pady=(6, 0)) + self._update_printer_status(all_printers) def _on_printer_changed(self, *_args: object) -> None: """Refresh setup and manifest copy after the printer changes.""" @@ -1285,8 +1593,10 @@ def _create_footer(self, parent: ttk.Frame) -> None: status_wrap.grid(row=0, column=0, sticky="ew", padx=(0, 24)) self.status_label = ttk.Label( status_wrap, - text="Review the selected schedules", + text="Complete Setup to prepare a print scope", style=_STYLE_SUB_LABEL, + wraplength=620, + justify="left", ) self.status_label.pack(side="left") @@ -1297,11 +1607,31 @@ def _create_footer(self, parent: ttk.Frame) -> None: command=self.open_logs_folder, cursor="hand2", ) - open_logs_btn.pack(side="left", padx=(12, 0)) - _ToolTip(open_logs_btn, "Open configuration, log, and report folder") + self._logs_btn = open_logs_btn + + reset_btn = ttk.Button( + status_wrap, + text="Reset run", + style=_STYLE_TERTIARY_BUTTON, + command=self._reset_run, + cursor="hand2", + ) + reset_btn.pack(side="left", padx=(12, 0)) + + help_btn = ttk.Button( + status_wrap, + text="How to use", + underline=0, + style=_STYLE_TERTIARY_BUTTON, + command=self.show_help, + cursor="hand2", + ) + help_btn.pack(side="left", padx=(12, 0)) progress_row = ttk.Frame(footer) + self._progress_row = progress_row progress_row.grid(row=1, column=0, sticky="ew", padx=(0, 24), pady=(10, 0)) + progress_row.grid_remove() self.progress_var = tk.DoubleVar() self.progress = ttk.Progressbar( @@ -1324,6 +1654,7 @@ def _create_footer(self, parent: ttk.Frame) -> None: self.print_btn = ttk.Button( footer, text="Print schedules", + underline=0, style=_STYLE_PRIMARY_BUTTON, width=20, cursor="hand2", @@ -1386,6 +1717,7 @@ def _browse_folder(self, entry: ttk.Entry) -> None: self._set_folder_entry(entry, path) logger.debug(f"Selected folder: {path}") self.refresh_setup_summary() + self.refresh_manifest_preview() # ------------------------------------------------------------------ # Public getters @@ -1533,7 +1865,7 @@ def _refresh_shift_counts( self._set_count( shift_type, f"Selected · {count} {self._document_noun(count)}", - _STYLE_COUNT_READY_LABEL, + _STYLE_COUNT_SELECTED_LABEL, ) def _describe_manifest( @@ -1543,10 +1875,11 @@ def _describe_manifest( ) -> tuple[str, list[str]]: """Return the manifest title and one numbered line per included shift.""" if not manifest: - return "This run: No schedules selected", [] + return "Print scope: No schedules selected", [] total = len(manifest) - title = f"This run: {total} schedule{'' if total == 1 else 's'}" + noun = "schedule" if total == 1 else "schedules" + title = f"Print scope: {total} {noun} selected" lines: list[str] = [] row_number = 1 for selection in selections: @@ -1559,9 +1892,19 @@ def _describe_manifest( row_number += 1 return title, lines - def refresh_manifest_preview(self) -> None: + def refresh_manifest_preview(self, update_status: bool = True) -> None: """Refresh the preflight-neutral manifest copy and count-aware action.""" if len(self._shift_panels) != 2: + if self._dependency_error: + if self.manifest_title_label is not None: + self.manifest_title_label.config( + text="Print scope: Date selection unavailable" + ) + if self.manifest_label is not None: + self.manifest_label.config(text=self._dependency_error) + if self.print_btn is not None: + self.print_btn.config(text="Print schedules") + self.print_btn.config(state="disabled") return selections = self.get_shift_selections() @@ -1574,7 +1917,7 @@ def refresh_manifest_preview(self) -> None: if invalid: manifest: tuple[PrintJob, ...] = () names = " and ".join(s.shift_type.title() for s in invalid) - title = f"This run: Check {names} date selection" + title = f"Print scope: Check {names} date selection" lines = [errors[s.shift_type] or "" for s in invalid] else: manifest = build_print_manifest(selections) @@ -1588,22 +1931,55 @@ def refresh_manifest_preview(self) -> None: ) lines.append(f"Printer: {printer_label}") + blocker: Optional[str] = None + if invalid: + names = " and ".join(s.shift_type.title() for s in invalid) + blocker = f"Fix {names} date selection" + elif not manifest: + blocker = "Select at least one Night or Day schedule" + else: + for selection in selections: + if selection.enabled and not selection.folder.strip(): + blocker = ( + f"Choose {selection.shift_type.title()} Templates in Setup" + ) + break + if blocker is None and printer_label == "Choose a printer": + blocker = "Choose a printer in Setup" + + if blocker: + lines.append(f"Cannot print: {blocker}") + if self.manifest_title_label is not None: self.manifest_title_label.config(text=title) if self.manifest_label is not None: self.manifest_label.config(text="\n".join(lines)) + if update_status and self.status_label is not None: + if blocker: + self.status_label.config(text=blocker, style=_STYLE_ERROR_LABEL) + else: + self.status_label.config( + text="Scope selected. Preflight runs when you select Print.", + style=_STYLE_SUB_LABEL, + ) if self.print_btn is not None: self.print_btn.config(text=self._print_button_text(len(manifest))) + state: Literal["normal", "disabled"] = ( + "normal" if blocker is None and self._inputs_enabled else "disabled" + ) + self.print_btn.config(state=state) def set_processing_mode(self, processing: bool) -> None: """Switch the primary action between print and cancellation states.""" if self.print_btn is None: return if processing: + if self._progress_row is not None: + self._progress_row.grid() self.print_btn.config(text="Cancel", style=_STYLE_DANGER_BUTTON) else: self.print_btn.config(style=_STYLE_PRIMARY_BUTTON) - self.refresh_manifest_preview() + self.refresh_manifest_preview(update_status=False) # ------------------------------------------------------------------ # Public setters / commands @@ -1627,6 +2003,7 @@ def set_start_command( self.print_btn.config(command=command) # Allow Enter key to trigger execution only when the button has focus. self.print_btn.bind("", lambda _event: command()) + self.root.bind("", lambda _event: command()) if cancel_command is not None: self.root.bind("", lambda _event: cancel_command()) @@ -1671,7 +2048,7 @@ def _set_shift_panel_enabled( """Set one shift panel's processing lock state.""" try: panel.include_check.config(state=state) - self._sync_shift_panel_state(shift_type) + self._sync_shift_panel_state(shift_type, refresh=False) except Exception as e: logger.debug(f"Could not set {shift_type} panel state: {e}") @@ -1700,6 +2077,8 @@ def update_status( level: Explicit style level. When ``None`` (default) the style is inferred from the message text for backward compatibility. """ + if self._progress_row is not None: + self._progress_row.grid() if self.status_label: if level == "success": style = _STYLE_SUCCESS_LABEL @@ -1719,6 +2098,11 @@ def update_status( else: style = _STYLE_SUB_LABEL self.status_label.config(text=message, style=style) + if self._logs_btn is not None: + if style == _STYLE_ERROR_LABEL: + self._logs_btn.pack(side="left", padx=(12, 0)) + else: + self._logs_btn.pack_forget() if self.progress_var: self.progress_var.set(progress) if self._progress_pct: @@ -1758,6 +2142,23 @@ def show_info(self, title: str, message: str) -> None: logger.info(f"{title}: {message}") messagebox.showinfo(title, message) + def show_help(self) -> None: + """Explain the print-run workflow in the operator's own terms.""" + self.show_info( + "How to use ShiftPress", + "1. In Setup, choose the Night and Day template folders and a printer.\n\n" + "Select Apply to keep Setup changes, or Cancel to restore the previous " + "folders and printer.\n\n" + "2. Include the Night schedule, Day schedule, or both. Choose a single " + "date or date range for each included shift.\n\n" + "3. Review Print scope, then select Print. ShiftPress runs preflight checks " + "before opening Word and stops if a required template or printer is " + "unavailable.\n\n" + "Reset run restores the common Night-today and Day-tomorrow selection.\n\n" + "Keyboard: Alt+S opens Setup, Alt+P prints, Alt+H opens this help, and " + "Escape stops after the current document.", + ) + def ask_yes_no(self, title: str, message: str) -> bool: """Ask the user a yes/no question. diff --git a/src/word_processor.py b/src/word_processor.py index 0d5c6be..99d9242 100644 --- a/src/word_processor.py +++ b/src/word_processor.py @@ -1,5 +1,5 @@ """ -Word document processing for ShiftPrint application. +Word document processing for ShiftPress application. This module handles all interactions with Microsoft Word via COM automation, including document opening, date replacement, and printing. @@ -102,26 +102,14 @@ def initialize(self) -> None: self.word_app.Visible = False self.word_app.DisplayAlerts = 0 - # Best-effort hardening: disable macro execution for automated opens. + # Disable macro execution before any automated document opens. # msoAutomationSecurityForceDisable = 3 - try: - self.word_app.AutomationSecurity = 3 - except Exception as e: - logger.debug(f"Could not set Word AutomationSecurity: {e}") + self.word_app.AutomationSecurity = 3 self._initialized = True logger.info("Word application initialized") except Exception as e: logger.exception("Failed to initialize Word application") - # If COM was initialized in this thread, uninitialize to avoid leaking. - if self._com_initialized: - try: - pythoncom.CoUninitialize() - except Exception as uninit_e: - logger.debug( - f"Error in CoUninitialize after init failure: {uninit_e}" - ) - finally: - self._com_initialized = False + self.shutdown() raise RuntimeError(f"Could not initialize Word: {e}") from e @staticmethod @@ -452,8 +440,13 @@ def print_document( f"Document is protected and could not be unprotected: {template_name}", ) - # Replace dates - self.replace_dates(doc, current_date) + # Replace dates. Printing an unchanged schedule is unsafe because + # the document can look valid while carrying the wrong date. + replacement_count = self.replace_dates(doc, current_date) + if replacement_count == 0: + raise RuntimeError( + "No supported date text was found; document was not printed" + ) # Set printer and print self._set_active_printer(printer_name) @@ -494,21 +487,27 @@ def _ensure_document_unprotected(self, doc: Any) -> bool: return bool(doc.ProtectionType == PROTECTION_NONE) def _set_active_printer(self, printer_name: str) -> None: - """Best-effort selection of the configured Word printer.""" + """Select the configured Word printer or fail before printing.""" if not self.word_app: - return + raise RuntimeError("Word processor not initialized") try: self.word_app.ActivePrinter = printer_name - except Exception: + except Exception as error: logger.exception("Could not set ActivePrinter to '%s'", printer_name) + raise RuntimeError( + f"Could not select printer '{printer_name}': {error}" + ) from error - def replace_dates(self, doc: Any, current_date: date) -> None: + def replace_dates(self, doc: Any, current_date: date) -> int: """ Replace date placeholders in the document using regex patterns. Args: doc: The Word document object current_date: The date to use for replacements + + Returns: + Number of supported date patterns that matched. """ # Normalize non-breaking spaces before running patterns self._normalize_spaces_in_doc(doc) @@ -573,12 +572,12 @@ def replace_dates(self, doc: Any, current_date: date) -> None: ), ] - any_matched = False + matched_patterns = 0 for find_text, replace_text in patterns: if self._execute_replace(doc, find_text, replace_text): - any_matched = True + matched_patterns += 1 - if not any_matched: + if matched_patterns == 0: # Dump the first ~200 chars of the document body so the log shows # exactly what text Word sees (including any invisible characters). sample = "" @@ -594,6 +593,7 @@ def replace_dates(self, doc: Any, current_date: date) -> None: ) logger.debug(f"Date replacements completed for {current_date}") + return matched_patterns def _normalize_spaces_in_doc(self, doc: Any) -> None: """Normalize invisible characters that break wildcard matching. diff --git a/start_app.bat b/start_app.bat index df388da..ac389a7 100644 --- a/start_app.bat +++ b/start_app.bat @@ -1,5 +1,5 @@ @echo off -echo Starting ShiftPrint... +echo Starting ShiftPress... REM Check if virtual environment exists if not exist ".venv" ( @@ -17,6 +17,6 @@ python main.py REM If application crashed, pause to see error if errorlevel 1 ( echo. - echo Application exited with an error. Check %%APPDATA%%\ShiftPrint\shiftprint.log for details. + echo Application exited with an error. Check %%APPDATA%%\ShiftPress\shiftpress.log for details. pause ) diff --git a/tests/__init__.py b/tests/__init__.py index 5384c02..300a9dc 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,3 +1,3 @@ """ -Tests for ShiftPrint application. +Tests for ShiftPress application. """ diff --git a/tests/test_app_paths.py b/tests/test_app_paths.py index 527898d..de5fb8d 100644 --- a/tests/test_app_paths.py +++ b/tests/test_app_paths.py @@ -45,7 +45,7 @@ def home(cls): assert str(result) == f"/mock/home/{APP_DIRNAME}" def test_non_windows(self): - """Should use ~/.shiftprint on non-Windows.""" + """Should use ~/.shiftpress on non-Windows.""" class _MockPath(PurePosixPath): @classmethod @@ -56,19 +56,19 @@ def home(cls): "src.app_paths.Path", _MockPath ): result = get_data_dir() - assert str(result) == "/home/testuser/.shiftprint" + assert str(result) == "/home/testuser/.shiftpress" def test_legacy_dir_differs_from_current_on_windows(self): """Migration needs both locations reachable and distinct. - The "ShiftPress" literal here is deliberate: it pins the pre-rename + The "ShiftPrint" literal here is deliberate: it pins the pre-rename directory an upgrading operator's config still lives in. """ with patch("src.app_paths.os.name", "nt"), patch( "src.app_paths.Path", PurePosixPath ), patch.dict(os.environ, {"APPDATA": "/mock/appdata"}, clear=False): - assert str(get_data_dir()) == "/mock/appdata/ShiftPrint" - assert str(get_legacy_data_dir()) == "/mock/appdata/ShiftPress" + assert str(get_data_dir()) == "/mock/appdata/ShiftPress" + assert str(get_legacy_data_dir()) == "/mock/appdata/ShiftPrint" def test_legacy_dir_differs_from_current_elsewhere(self): """The non-Windows dev path also has to be distinguishable.""" @@ -81,8 +81,8 @@ def home(cls): with patch("src.app_paths.os.name", "posix"), patch( "src.app_paths.Path", _MockPath ): - assert str(get_data_dir()) == "/home/testuser/.shiftprint" - assert str(get_legacy_data_dir()) == "/home/testuser/.shiftpress" + assert str(get_data_dir()) == "/home/testuser/.shiftpress" + assert str(get_legacy_data_dir()) == "/home/testuser/.shiftprint" def test_returns_path_object(self): """Should always return a Path-like object.""" diff --git a/tests/test_config.py b/tests/test_config.py index 4bdde26..6bed8a3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -217,7 +217,7 @@ class TestPreRenameMigration: """Config saved under the old ShiftPrint data directory must survive.""" def test_migrates_config_from_pre_rename_data_dir(self, tmp_path): - """An operator upgrading from ShiftPrint keeps their saved setup.""" + """An operator returning from ShiftPrint keeps their saved setup.""" legacy_dir = tmp_path / "legacy" legacy_dir.mkdir() (legacy_dir / "config.json").write_text( @@ -254,6 +254,32 @@ def test_pre_rename_config_is_renamed_after_migration(self, tmp_path): assert not legacy_file.exists() assert (legacy_dir / "config.json.migrated").exists() + def test_failed_migration_write_keeps_legacy_config_for_retry( + self, tmp_path, monkeypatch + ): + """A failed destination write must leave the recoverable source intact.""" + legacy_dir = tmp_path / "legacy" + legacy_dir.mkdir() + legacy_file = legacy_dir / "config.json" + legacy_file.write_text(json.dumps({"day_folder": "/old/day"})) + + manager = ConfigManager() + manager.config_path = tmp_path / "current" / "config.json" + manager._legacy_data_config_path = legacy_file + manager._legacy_config_path = tmp_path / "missing" / "config.json" + manager._allow_legacy_migration = True + + def fail_save(config): + raise OSError("disk full") + + monkeypatch.setattr(manager, "save", fail_save) + + config = manager.load() + + assert config.day_folder == "/old/day" + assert legacy_file.exists() + assert not legacy_file.with_suffix(".json.migrated").exists() + def test_existing_config_wins_over_pre_rename_one(self, tmp_path): """Migration must never overwrite a config the operator already has.""" legacy_dir = tmp_path / "legacy" diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..5646bca --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,14 @@ +"""Tests for platform-specific UI constants.""" + +from unittest.mock import patch + +from src.constants import _font_families + + +def test_windows_uses_text_and_display_variable_font_roles(): + """Windows body copy and headings should use their intended optical roles.""" + with patch("sys.platform", "win32"): + body, display = _font_families() + + assert body == "Segoe UI Variable Text" + assert display == "Segoe UI Variable Display" diff --git a/tests/test_logger.py b/tests/test_logger.py index e716b3b..fd7f095 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -49,7 +49,7 @@ def test_creates_log_file(self, tmp_path): setup_logging(log_dir=str(tmp_path)) log_files = list(tmp_path.glob("*.log")) assert len(log_files) == 1 - assert log_files[0].name == "shiftprint.log" + assert log_files[0].name == "shiftpress.log" def test_custom_log_filename(self, tmp_path): """Should use a custom log filename when specified.""" @@ -78,7 +78,7 @@ def test_clears_existing_handlers(self, tmp_path): setup_logging(log_dir=str(tmp_path)) # Our 2 tagged handlers are added; the third-party handler survives. - tagged = [h for h in root.handlers if getattr(h, "_shiftprint", False)] + tagged = [h for h in root.handlers if getattr(h, "_shiftpress", False)] assert len(tagged) == 2 assert third_party in root.handlers root.removeHandler(third_party) @@ -105,14 +105,14 @@ def test_returns_named_logger(self): assert logger.name == "test.module" def test_default_name(self): - """Should return 'shiftprint' when no name provided.""" + """Should return 'shiftpress' when no name provided.""" logger = get_logger() - assert logger.name == "shiftprint" + assert logger.name == "shiftpress" def test_none_name(self): - """Should return 'shiftprint' when name is None.""" + """Should return 'shiftpress' when name is None.""" logger = get_logger(None) - assert logger.name == "shiftprint" + assert logger.name == "shiftpress" class TestSetupLoggingIdempotency: @@ -124,18 +124,18 @@ def test_double_init_does_not_duplicate_handlers(self, tmp_path): # Record third-party handlers before our test pre_existing = len( - [h for h in root.handlers if not getattr(h, "_shiftprint", False)] + [h for h in root.handlers if not getattr(h, "_shiftpress", False)] ) setup_logging(log_dir=str(tmp_path)) tagged_after_first = [ - h for h in root.handlers if getattr(h, "_shiftprint", False) + h for h in root.handlers if getattr(h, "_shiftpress", False) ] count_first = len(tagged_after_first) setup_logging(log_dir=str(tmp_path)) tagged_after_second = [ - h for h in root.handlers if getattr(h, "_shiftprint", False) + h for h in root.handlers if getattr(h, "_shiftpress", False) ] count_second = len(tagged_after_second) @@ -145,6 +145,6 @@ def test_double_init_does_not_duplicate_handlers(self, tmp_path): # Third-party handlers should be untouched third_party_after = len( - [h for h in root.handlers if not getattr(h, "_shiftprint", False)] + [h for h in root.handlers if not getattr(h, "_shiftpress", False)] ) assert third_party_after == pre_existing diff --git a/tests/test_main.py b/tests/test_main.py index dbebf44..aeead66 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -12,7 +12,7 @@ # Import the class directly, then grab the actual module from sys.modules # (src.main as a name is shadowed by the main() function exported in src.__init__.py) -from src.main import ShiftPrintApp, _BatchRequest +from src.main import ShiftPressApp, _BatchRequest from src.print_manifest import PrintJob, ShiftSelection main_module = sys.modules["src.main"] @@ -28,19 +28,19 @@ def _request(*jobs: PrintJob) -> _BatchRequest: ) -def _run_scheduled_callbacks(app: ShiftPrintApp) -> None: +def _run_scheduled_callbacks(app: ShiftPressApp) -> None: """Execute callbacks captured by the mocked Tk root.""" for call in app.root.after.call_args_list: callback = call.args[1] callback() -class TestShiftPrintApp: - """Tests for ShiftPrintApp class.""" +class TestShiftPressApp: + """Tests for ShiftPressApp class.""" @pytest.fixture def app(self): - """Create a ShiftPrintApp with mocked UI and dependencies.""" + """Create a ShiftPressApp with mocked UI and dependencies.""" with patch.object(main_module, "ScheduleAppUI") as MockUI, patch.object( main_module, "ConfigManager" ) as MockConfig: @@ -77,7 +77,7 @@ def app(self): day_folder="", night_folder="", printer_name="" ) - app = ShiftPrintApp(mock_root) + app = ShiftPressApp(mock_root) yield app def test_validate_inputs_missing_day_folder(self, app): @@ -246,11 +246,17 @@ def test_process_batch_uses_exact_manifest_order(self, mock_wp_class, app): ] app.ui.show_info.assert_called_once_with( "Success", - "All 2 selected schedules have been processed and sent to the printer.", + "All 2 schedules were sent to the printer.", ) - status_messages = [call.args[0] for call in app.ui.update_status.call_args_list] - assert any("(1/2)" in message for message in status_messages) - assert any("(2/2)" in message for message in status_messages) + app.ui.update_status.assert_any_call( + "All 2 schedules sent to printer", 100, level="success" + ) + progress_updates = [ + call.args[1] + for call in app.ui.update_status.call_args_list + if call.args and call.args[0].startswith(("Printing", "Finished")) + ] + assert progress_updates == [0, 50, 50, 100] @patch.object(main_module, "WordProcessor") def test_process_batch_cancel_before_first_job(self, mock_wp_class, app): @@ -306,8 +312,12 @@ def print_once(*_args): ) app._process_batch(request) + _run_scheduled_callbacks(app) assert mock_wp.print_document.call_count == 1 + app.ui.update_status.assert_any_call( + "Cancelled after 1 of 2 schedules", 50, level="info" + ) def test_on_close_without_active_thread(self, app): """Should destroy window immediately if no thread is running.""" @@ -337,6 +347,9 @@ def test_cancel_if_running_sets_event(self, app): assert app._cancel_event.is_set() app.ui.set_print_button_state.assert_called_with("disabled") + app.ui.update_status.assert_called_with( + "Stopping after current document...", 0.0, level="info" + ) def test_cancel_if_running_noop_when_idle(self, app): """_cancel_if_running should do nothing when no thread is running.""" @@ -370,6 +383,22 @@ def test_start_processing_stop_button(self, mock_validate, mock_wp_class, app): assert app._cancel_event.is_set() app.ui.set_print_button_state.assert_called_with("disabled") + def test_start_processing_shows_preflight_before_validation(self, app): + """Potentially slow validation should begin with immediate visible feedback.""" + + def validate_after_status(): + app.ui.update_status.assert_called_with( + "Checking setup and templates…", 0, level="info" + ) + return None, "Choose a printer" + + with patch.object(app, "_validate_inputs", side_effect=validate_after_status): + app.start_processing() + + app.ui.update_status.assert_called_with( + "Cannot print: Choose a printer", 0, level="error" + ) + @patch.object(main_module, "WordProcessor") def test_process_batch_tracks_failures_with_summary(self, mock_wp_class, app): """Should call _show_failure_summary with the correct failures.""" @@ -407,6 +436,13 @@ def test_process_batch_tracks_failures_with_summary(self, mock_wp_class, app): assert failures[0]["shift"] == "night" assert "Template not found" in failures[0]["error"] assert mock_wp.print_document.call_count == 2 + app.ui.update_status.assert_any_call( + "Completed with 1 failed schedule", 100, level="error" + ) + assert not any( + call.kwargs.get("level") == "success" + for call in app.ui.update_status.call_args_list + ) def test_write_failure_report_creates_csv(self, app, tmp_path): """_write_failure_report should create a CSV with correct headers.""" @@ -577,11 +613,64 @@ def test_load_config_populates_entries(self, app): app.ui.refresh_setup_summary.assert_called_once() def test_load_config_exception_shows_warning(self, app): - """_load_config should show warning on load failure.""" + """A load failure should give recovery guidance without raw diagnostics.""" app.config_manager.load.side_effect = IOError("Corrupted") app._load_config() - app.ui.show_warning.assert_called_once() - assert "Corrupted" in app.ui.show_warning.call_args[0][1] + app.ui.show_warning.assert_called_once_with( + "Setup could not be loaded", + "Open Setup, confirm both template folders and the printer, then select " + "Apply. No schedules have been sent to print.", + ) + assert "Corrupted" not in app.ui.show_warning.call_args[0][1] + + def test_save_config_reports_failure_to_its_caller(self, app): + """Persistence failures should be recoverable by the UI boundary.""" + app.config_manager.save.side_effect = OSError("Disk full") + + saved = app._save_config(MagicMock()) + + assert saved is False + + @patch.object(main_module, "WordProcessor") + def test_process_batch_warns_when_setup_cannot_be_saved(self, mock_wp_class, app): + """Printing may continue, but stale-next-launch risk must be visible.""" + mock_wp = MagicMock() + mock_wp.__enter__ = MagicMock(return_value=mock_wp) + mock_wp.__exit__ = MagicMock(return_value=False) + mock_wp.print_document.return_value = (True, None) + mock_wp_class.return_value = mock_wp + app._save_config = MagicMock(return_value=False) + request = _request( + PrintJob( + date=date(2026, 1, 14), + shift_type="night", + template_name="Wednesday Night", + folder="/tmp/night", + ) + ) + + app._process_batch(request) + _run_scheduled_callbacks(app) + + app.ui.show_warning.assert_any_call( + "Setup was not saved", + "This print run can continue, but these folders and printer may not " + "be restored next time. If this repeats, select Open logs and contact " + "support.", + ) + + def test_close_warns_when_setup_cannot_be_saved(self, app): + """Closing must disclose that current Setup values may be lost.""" + app._save_config = MagicMock(return_value=False) + + app._on_close() + + app.ui.show_warning.assert_called_with( + "Setup was not saved", + "These folders and printer may not be restored next time. Review the " + "application logs if this repeats.", + ) + app.root.destroy.assert_called_once() def test_show_failure_summary_message_format(self, app, tmp_path): """_show_failure_summary should format failures and truncate at MAX_FAILURE_SUMMARY_SHOWN.""" @@ -643,6 +732,15 @@ def test_process_batch_exception_resets_ui(self, mock_wp_class, app): # Print button should be re-enabled app.ui.set_print_button_state.assert_called_with("normal") app.ui.set_processing_mode.assert_called_with(False) + app.ui.update_status.assert_any_call( + "Printing stopped before any schedules were completed", 0, level="error" + ) + app.ui.show_error.assert_called_with( + "Printing stopped", + "Review the selected printer and template folders, then try again. " + "If the problem repeats, select Open logs and contact support.", + ) + assert "COM catastrophe" not in app.ui.show_error.call_args.args[1] @patch.object(main_module, "WordProcessor") def test_process_batch_saves_configuration_and_consumes_preflight_cache( @@ -736,6 +834,9 @@ def test_large_batch_confirmation_uses_manifest_document_count(self, app): assert title == "Large Batch Confirm" assert "30 selected schedules" in message MockThread.assert_not_called() + app.ui.update_status.assert_called_with( + "Cancelled before printing", 0, level="info" + ) def test_show_failure_summary_with_none_report_path(self, app, tmp_path): """_show_failure_summary should handle report_path=None gracefully.""" diff --git a/tests/test_ui.py b/tests/test_ui.py index 222e953..578c689 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -77,6 +77,8 @@ def ui(self, root): ), patch( "src.ui.ttk.Button" ) as MockTtkButton, patch( + "src.ui.ttk.Scrollbar" + ) as MockScrollbar, patch( "src.ui.ttk.Checkbutton" ), patch( "src.ui.ttk.Radiobutton" @@ -89,6 +91,8 @@ def ui(self, root): ), patch( "src.ui.tk.Button" ) as MockTkButton, patch( + "src.ui.tk.Canvas" + ) as MockCanvas, patch( "src.ui.tk.Toplevel" ) as MockToplevel, patch( "src.ui.tk.StringVar", side_effect=_FakeVariable @@ -112,6 +116,8 @@ def ui(self, root): ui.printer_var = _FakeVariable(value="Test Printer") ui._test_ttk_button_class = MockTtkButton ui._test_tk_button_class = MockTkButton + ui._test_canvas_class = MockCanvas + ui._test_scrollbar_class = MockScrollbar ui._test_frame_class = MockFrame ui._test_label_class = MockLabel ui._test_label_frame_class = MockLabelFrame @@ -123,6 +129,7 @@ def test_init(self, ui): assert ui.day_entry is not None assert ui.night_entry is not None assert ui.print_btn is not None + ui.root.title.assert_called_with("ShiftPress") def test_get_day_folder(self, ui): """Should return value from day entry.""" @@ -297,18 +304,77 @@ def test_single_date_row_matches_range_row_structure(self, ui): def test_window_sizing_derives_from_rendered_content(self, ui, root): """minsize and geometry must come from content, not a hardcoded guess.""" - root.winfo_reqheight.return_value = 812 - root.winfo_reqwidth.return_value = 1000 + ui._main_content.winfo_reqheight.return_value = 812 + ui._main_content.winfo_reqwidth.return_value = 1000 root.winfo_screenwidth.return_value = 1920 root.winfo_screenheight.return_value = 1080 root.minsize.reset_mock() root.geometry.reset_mock() - ui._apply_content_sizing() + with patch("src.ui._get_work_area", return_value=(0, 0, 1920, 1040)): + ui._apply_content_sizing() - root.minsize.assert_called_once_with(1040, 812) + root.minsize.assert_called_once_with(1040, 400) root.geometry.assert_called_once_with("1040x812") + def test_constrained_display_enables_vertical_overflow_recovery(self, ui, root): + """Content taller than the usable screen must remain reachable by scrolling.""" + ui._main_content.winfo_reqheight.return_value = 1100 + ui._main_content.winfo_reqwidth.return_value = 1000 + root.winfo_screenwidth.return_value = 1366 + root.winfo_screenheight.return_value = 768 + root.geometry.reset_mock() + ui._show_main_scrollbar = MagicMock() + + with patch("src.ui._get_work_area", return_value=(0, 0, 1366, 728)): + ui._apply_content_sizing() + + root.geometry.assert_called_once_with("1040x688") + ui._show_main_scrollbar.assert_called_once() + + def test_windows_work_area_keeps_the_main_window_above_the_taskbar(self, ui, root): + """Centering must use Windows' work area, not the taskbar-covered screen.""" + root.winfo_width.return_value = 1040 + root.winfo_height.return_value = 828 + root.geometry.reset_mock() + + with patch("src.ui._get_work_area", return_value=(0, 0, 1517, 894)): + ui._center_window() + + root.geometry.assert_called_once_with("1040x828+230+13") + + def test_windows_work_area_caps_content_without_hiding_the_action(self, ui, root): + """Very tall content must leave room for native window decorations.""" + ui._main_content.winfo_reqheight.return_value = 1100 + ui._main_content.winfo_reqwidth.return_value = 1000 + root.geometry.reset_mock() + ui._show_main_scrollbar = MagicMock() + + with patch("src.ui._get_work_area", return_value=(0, 0, 1517, 894)): + ui._apply_content_sizing() + + root.geometry.assert_called_once_with("1040x854") + ui._show_main_scrollbar.assert_called_once() + + def test_setup_dialog_caps_height_and_enables_overflow_recovery(self, ui): + """Required Setup controls must remain reachable on a short display.""" + dialog = ui._setup_dialog + dialog.winfo_screenwidth.return_value = 1366 + dialog.winfo_screenheight.return_value = 600 + ui.root.winfo_rootx.return_value = 100 + ui.root.winfo_rooty.return_value = 100 + ui.root.winfo_width.return_value = 1040 + ui._setup_content.winfo_reqwidth.return_value = 700 + ui._setup_content.winfo_reqheight.return_value = 760 + ui._show_setup_scrollbar = MagicMock() + + with patch("src.ui._get_work_area", return_value=(0, 0, 1366, 560)): + ui._show_setup_dialog() + + geometry = dialog.geometry.call_args.args[0] + assert geometry.startswith("720x520+") + ui._show_setup_scrollbar.assert_called_once() + def test_manifest_preview_uses_actual_selected_job_count(self, ui): """The visible manifest and action should reflect independent jobs.""" ui.manifest_title_label = MagicMock() @@ -319,18 +385,19 @@ def test_manifest_preview_uses_actual_selected_job_count(self, ui): assert ( ui.manifest_title_label.config.call_args.kwargs["text"] - == "This run: 2 schedules" + == "Print scope: 2 schedules selected" ) manifest_text = ui.manifest_label.config.call_args.kwargs["text"] assert "1. Night — 07/30/2026 — 1 document" in manifest_text assert "2. Day — 07/31/2026 — 1 document" in manifest_text assert "Printer: Test Printer" in manifest_text - ui.print_btn.config.assert_called_with(text="Print 2 schedules") + ui.print_btn.config.assert_any_call(text="Print 2 schedules") + ui.print_btn.config.assert_any_call(state="normal") ui._shift_panels["night"].count_label.config.assert_called_with( - text="Selected · 1 document", style="CountReady.TLabel" + text="Selected · 1 document", style="CountSelected.TLabel" ) ui._shift_panels["day"].count_label.config.assert_called_with( - text="Selected · 1 document", style="CountReady.TLabel" + text="Selected · 1 document", style="CountSelected.TLabel" ) def test_single_schedule_manifest_reads_as_singular(self, ui): @@ -344,9 +411,9 @@ def test_single_schedule_manifest_reads_as_singular(self, ui): assert ( ui.manifest_title_label.config.call_args.kwargs["text"] - == "This run: 1 schedule" + == "Print scope: 1 schedule selected" ) - ui.print_btn.config.assert_called_with(text="Print 1 schedule") + ui.print_btn.config.assert_any_call(text="Print 1 schedule") def test_excluded_shift_uses_muted_state_not_success(self, ui): """An excluded shift must not render in success green.""" @@ -385,13 +452,14 @@ def test_invalid_night_range_does_not_flag_valid_day(self, ui): text="Check Night date selection", style="CountError.TLabel" ) ui._shift_panels["day"].count_label.config.assert_called_once_with( - text="Selected · 1 document", style="CountReady.TLabel" + text="Selected · 1 document", style="CountSelected.TLabel" ) title = ui.manifest_title_label.config.call_args.kwargs["text"] - assert title == "This run: Check Night date selection" + assert title == "Print scope: Check Night date selection" body = ui.manifest_label.config.call_args.kwargs["text"] assert "Night schedule: End date cannot be before start date" in body - ui.print_btn.config.assert_called_with(text="Print schedules") + ui.print_btn.config.assert_any_call(text="Print schedules") + ui.print_btn.config.assert_any_call(state="disabled") def test_manifest_preview_blocks_empty_selection(self, ui): """No included shifts should produce no jobs and no numeric promise.""" @@ -405,9 +473,72 @@ def test_manifest_preview_blocks_empty_selection(self, ui): assert ( ui.manifest_title_label.config.call_args.kwargs["text"] - == "This run: No schedules selected" + == "Print scope: No schedules selected" + ) + ui.print_btn.config.assert_any_call(text="Print schedules") + ui.print_btn.config.assert_any_call(state="disabled") + + def test_manifest_preview_blocks_enabled_shift_without_its_folder(self, ui): + """A locally missing folder should explain the exact Setup action.""" + ui.day_entry.get.return_value = "" + ui.manifest_title_label = MagicMock() + ui.manifest_label = MagicMock() + ui.print_btn = MagicMock() + + ui.refresh_manifest_preview() + + body = ui.manifest_label.config.call_args.kwargs["text"] + assert "Cannot print: Choose Day Templates in Setup" in body + ui.print_btn.config.assert_any_call(state="disabled") + ui.status_label.config.assert_called_with( + text="Choose Day Templates in Setup", style="Error.TLabel" + ) + + def test_manifest_preview_blocks_missing_printer(self, ui): + """The action should stay disabled until an actual printer is selected.""" + ui.printer_var.set("Choose Printer") + ui.manifest_title_label = MagicMock() + ui.manifest_label = MagicMock() + ui.print_btn = MagicMock() + + ui.refresh_manifest_preview() + + body = ui.manifest_label.config.call_args.kwargs["text"] + assert "Cannot print: Choose a printer in Setup" in body + ui.print_btn.config.assert_any_call(state="disabled") + ui.status_label.config.assert_called_with( + text="Choose a printer in Setup", style="Error.TLabel" + ) + + def test_missing_dateentry_dependency_disables_print_with_recovery(self, ui): + """A missing date picker dependency must become an actionable state.""" + ui._dependency_error = ( + "Date selection is unavailable. Reinstall ShiftPress to restore tkcalendar." + ) + ui._shift_panels.clear() + ui.manifest_title_label = MagicMock() + ui.manifest_label = MagicMock() + ui.print_btn = MagicMock() + + ui.refresh_manifest_preview() + + assert ( + ui.manifest_title_label.config.call_args.kwargs["text"] + == "Print scope: Date selection unavailable" + ) + assert ( + "Reinstall ShiftPress" in ui.manifest_label.config.call_args.kwargs["text"] + ) + ui.print_btn.config.assert_any_call(state="disabled") + + def test_selected_count_style_is_neutral_before_preflight(self, ui): + """A local selection must not use success green before preflight.""" + ui.style.configure.assert_any_call( + "CountSelected.TLabel", + font=FONTS.bold, + foreground=COLORS.text_main, + background=COLORS.surface, ) - ui.print_btn.config.assert_called_with(text="Print schedules") def test_primary_action_uses_themed_ttk_button(self, ui): """The primary action must not use the unreadable macOS classic Tk button.""" @@ -426,10 +557,32 @@ def test_primary_action_uses_themed_ttk_button(self, ui): assert ttk_print_calls[0].kwargs["style"] == "Primary.TButton" assert tk_print_calls == [] + def test_primary_action_uses_action_blue_focus_border(self, ui): + """The print action should not borrow the Day shift amber border.""" + ui.style.configure.assert_any_call( + "Primary.TButton", + background=COLORS.action, + foreground=COLORS.text_main, + bordercolor=COLORS.action, + borderwidth=1, + font=FONTS.button, + padding=(26, 16), + ) + + def test_interactive_styles_have_explicit_keyboard_focus(self, ui): + """Native controls need a visible high-contrast focus treatment.""" + for style_name in ("TButton", "TEntry", "TCombobox", "TMenubutton"): + ui.style.map.assert_any_call( + style_name, + bordercolor=[("focus", COLORS.night_accent)], + lightcolor=[("focus", COLORS.night_accent)], + darkcolor=[("focus", COLORS.night_accent)], + ) + def test_group_titles_use_clean_background_and_custom_card_shells(self, ui): """Section titles should sit on the window without LabelFrame patches.""" for style_name, foreground in ( - ("SetupTitle.TLabel", COLORS.accent), + ("SetupTitle.TLabel", COLORS.text_main), ("NightTitle.TLabel", COLORS.night_accent), ("DayTitle.TLabel", COLORS.day_accent), ): @@ -449,6 +602,20 @@ def test_group_titles_use_clean_background_and_custom_card_shells(self, ui): "DayCard.TFrame", } <= card_styles + def test_generic_input_selection_does_not_borrow_day_amber(self, ui): + """Amber should identify Day, not generic input or menu interaction.""" + ui.style.configure.assert_any_call( + "TEntry", + fieldbackground=COLORS.input, + foreground=COLORS.text_main, + insertcolor=COLORS.text_main, + selectbackground=COLORS.action, + selectforeground=COLORS.text_main, + bordercolor=COLORS.border, + borderwidth=1, + padding=(8, 7), + ) + def test_manifest_uses_plain_bordered_frame_without_empty_title_strip(self, ui): """The manifest should not reserve a blank LabelFrame title channel.""" manifest_frames = [ @@ -474,6 +641,38 @@ def test_footer_flows_after_manifest_and_logs_action_is_tertiary(self, ui): assert len(logs_calls) == 1 assert logs_calls[0].kwargs["style"] == "Tertiary.TButton" + help_calls = [ + call + for call in ui._test_ttk_button_class.call_args_list + if call.kwargs.get("text") == "How to use" + ] + assert len(help_calls) == 1 + assert help_calls[0].kwargs["style"] == "Tertiary.TButton" + + def test_progress_is_hidden_until_status_work_begins(self, ui): + """An idle 0% bar should not compete with the primary task.""" + ui._progress_row.grid_remove.assert_called() + + ui.update_status("Checking templates…", 0, level="info") + + ui._progress_row.grid.assert_called() + + def test_long_manifest_and_status_copy_wraps_in_the_window(self, ui): + """Long operational state should wrap instead of widening or clipping.""" + manifest_call = next( + call + for call in ui._test_label_class.call_args_list + if call.kwargs.get("text") == "Printer: Choose a printer" + ) + status_call = next( + call + for call in ui._test_label_class.call_args_list + if call.kwargs.get("text") == "Complete Setup to prepare a print scope" + ) + + assert manifest_call.kwargs["wraplength"] == 760 + assert status_call.kwargs["wraplength"] == 620 + def test_processing_mode_uses_danger_style_then_restores_manifest_action(self, ui): """Cancel state and normal print state should each use readable ttk styling.""" ui.print_btn = MagicMock() @@ -484,15 +683,101 @@ def test_processing_mode_uses_danger_style_then_restores_manifest_action(self, u ui.print_btn.config.assert_any_call(text="Cancel", style="Danger.TButton") ui.print_btn.config.assert_any_call(style="Primary.TButton") - def test_setup_summary_reports_configuration_without_exposing_paths(self, ui): - """Collapsed setup should state readiness without crowding the work surface.""" + def test_leaving_processing_mode_preserves_the_run_outcome_status(self, ui): + """Re-enabling the action must not replace the final success/error message.""" + ui.refresh_manifest_preview = MagicMock() + + ui.set_processing_mode(False) + + ui.refresh_manifest_preview.assert_called_once_with(update_status=False) + + def test_logs_action_appears_only_for_runtime_errors(self, ui): + """Troubleshooting chrome should stay out of the everyday workflow.""" + ui._logs_btn.pack.reset_mock() + + ui.update_status("Processing stopped: RuntimeError", 50, level="error") + + ui._logs_btn.pack.assert_called_once_with(side="left", padx=(12, 0)) + + def test_setup_summary_identifies_template_folder_tails(self, ui): + """Collapsed setup should identify active sources without showing long paths.""" ui.setup_summary_label = MagicMock() ui.refresh_setup_summary() summary = ui.setup_summary_label.config.call_args.kwargs["text"] - assert summary == "Templates configured\nTest Printer" - assert "C:/Templates" not in summary + assert summary == ( + "Night templates: C: › Templates › Night\n" + "Day templates: C: › Templates › Day\n" + "Printer: Test Printer" + ) + + def test_setup_fields_follow_night_then_day_workflow_order(self, ui): + """Setup should preserve the same Night-to-Day sequence as the work surface.""" + labels = [ + call.kwargs.get("text") + for call in ui._test_label_class.call_args_list + if call.kwargs.get("text") in {"Night Templates", "Day Templates"} + ] + assert labels == ["Night Templates", "Day Templates"] + + def test_folder_summary_distinguishes_same_tail_from_different_sites(self, ui): + """Compact source labels must retain the drive and site identity.""" + first = ui._folder_tail(r"C:\SiteA\Templates\Night") + second = ui._folder_tail(r"D:\SiteB\Templates\Night") + + assert first == "C: › SiteA › Night" + assert second == "D: › SiteB › Night" + assert first != second + + def test_refresh_printers_updates_visible_availability(self, ui): + """Printer refresh should update the persistent availability message.""" + ui._printer_status_label = MagicMock() + ui._enumerate_printers = MagicMock(return_value=["Office Printer"]) + + ui.refresh_printers() + + ui._printer_status_label.config.assert_called_with( + text="1 printer available", style="CardSub.TLabel" + ) + + def test_setup_dialog_uses_close_action(self, ui): + """Setup should expose explicit commit and rollback actions.""" + apply_calls = [ + call + for call in ui._test_ttk_button_class.call_args_list + if call.kwargs.get("text") == "Apply" + ] + cancel_calls = [ + call + for call in ui._test_ttk_button_class.call_args_list + if call.kwargs.get("text") == "Cancel" + ] + assert len(apply_calls) == 1 + assert len(cancel_calls) == 1 + + def test_cancel_setup_restores_values_from_when_dialog_opened(self, ui): + """An accidental Setup edit must be reversible without remembering values.""" + ui._show_setup_dialog() + ui.day_entry.get.return_value = "C:/Wrong/Day" + ui.night_entry.get.return_value = "C:/Wrong/Night" + ui.printer_var.set("Wrong Printer") + + ui._cancel_setup_dialog() + + ui.day_entry.delete.assert_called_with(0, tk.END) + ui.day_entry.insert.assert_called_with(0, "C:/Templates/Day") + ui.night_entry.insert.assert_called_with(0, "C:/Templates/Night") + assert ui.printer_var.get() == "Test Printer" + + @patch("src.ui.filedialog.askdirectory", return_value="C:/New/Day") + def test_browsing_folder_refreshes_readiness(self, _askdirectory, ui): + """Choosing a folder should immediately recompute the local blocker state.""" + ui.refresh_manifest_preview = MagicMock() + + ui._browse_folder(ui.day_entry) + + ui.refresh_manifest_preview.assert_called_once() def test_setup_dialog_preserves_main_layout_and_configured_values(self, ui): """Setup should open separately instead of expanding the main work surface.""" @@ -510,6 +795,7 @@ def test_setup_dialog_preserves_main_layout_and_configured_values(self, ui): dialog.deiconify.assert_called_once() dialog.lift.assert_called_once() dialog.focus_force.assert_called_once() + ui.night_entry.focus_set.assert_called_once() dialog.withdraw.assert_called_once() assert before == ( ui.get_day_folder(), @@ -517,6 +803,57 @@ def test_setup_dialog_preserves_main_layout_and_configured_values(self, ui): ui.get_printer_name(), ) + def test_setup_dialog_binds_escape_to_close(self, ui): + """Setup should be fully dismissible from the keyboard.""" + bound_keys = [call.args[0] for call in ui._setup_dialog.bind.call_args_list] + assert "" in bound_keys + + def test_setup_and_help_have_global_keyboard_shortcuts(self, ui): + """Frequently used secondary actions need documented mnemonics.""" + bound_keys = [call.args[0] for call in ui.root.bind.call_args_list] + assert "" in bound_keys + assert "" in bound_keys + + def test_setup_button_mnemonic_matches_alt_s_shortcut(self, ui): + """The visible Setup mnemonic must match the documented Alt+S binding.""" + setup_calls = [ + call + for call in ui._test_ttk_button_class.call_args_list + if call.kwargs.get("text") == "Setup…" + ] + assert len(setup_calls) == 1 + assert setup_calls[0].kwargs["underline"] == 0 + + def test_reset_run_restores_the_common_night_then_day_defaults(self, ui): + """Repeat operators need one action to undo accidental scope drift.""" + night = ui._shift_panels["night"] + day = ui._shift_panels["day"] + night.enabled_var.set(False) + night.mode_var.set("range") + day.enabled_var.set(False) + day.mode_var.set("range") + + ui._reset_run() + + assert night.enabled_var.get() is True + assert day.enabled_var.get() is True + assert night.mode_var.get() == "single" + assert day.mode_var.get() == "single" + assert night.single_picker.get_date() == date(2026, 7, 30) + assert day.single_picker.get_date() == date(2026, 7, 31) + + def test_show_help_explains_the_operator_flow(self, ui): + """Help copy should make selection, setup, and preflight understandable.""" + ui.show_info = MagicMock() + + ui.show_help() + + title, message = ui.show_info.call_args.args + assert title == "How to use ShiftPress" + assert "Night" in message + assert "Day" in message + assert "preflight" in message.lower() + def test_set_inputs_enabled_locks_and_restores_shift_controls(self, ui): """Processing lock state should cover every independent shift control.""" for panel in ui._shift_panels.values(): @@ -572,10 +909,12 @@ def test_show_error(self, mock_error, ui): mock_error.assert_called_with("Title", "Message") def test_set_start_command(self, ui): - """Should set the button command and bind Enter key.""" + """Should set the button command and bind keyboard actions.""" mock_cmd = MagicMock() ui.set_start_command(mock_cmd) ui.print_btn.config.assert_called_with(command=mock_cmd) + bound_keys = [call.args[0] for call in ui.root.bind.call_args_list] + assert "" in bound_keys def test_set_start_command_with_cancel(self, ui): """Should bind Escape key when cancel_command is provided.""" diff --git a/tests/test_word_processor.py b/tests/test_word_processor.py index 07f611b..ab7b5cf 100644 --- a/tests/test_word_processor.py +++ b/tests/test_word_processor.py @@ -134,7 +134,7 @@ def test_replace_dates_logic(self, wp): with patch.object(wp, "_normalize_spaces_in_doc"), patch.object( wp, "_execute_replace", return_value=True ) as mock_exec: - wp.replace_dates(mock_doc, current_date) + replacements = wp.replace_dates(mock_doc, current_date) # Should be called 6 times: 3 ordinal-suffix patterns + 3 plain patterns. # All patterns run independently; overlap is prevented by @@ -145,6 +145,7 @@ def test_replace_dates_logic(self, wp): # Replacement: "Thursday, January 15, 2026" calls = [c[0][2] for c in mock_exec.call_args_list] assert "Thursday, January 15, 2026" in calls + assert replacements == 6 @patch("src.word_processor.pythoncom.CoInitialize") @patch("src.word_processor.win32_client.DispatchEx", create=True) @@ -178,6 +179,31 @@ def test_initialize_falls_back_to_dispatch(self, mock_dispatch, mock_coinit): mock_coinit.assert_called_once() mock_dispatch.assert_called_with("Word.Application") + @patch("src.word_processor.pythoncom.CoInitialize") + @patch("src.word_processor.pythoncom.CoUninitialize") + @patch("src.word_processor.win32_client.Dispatch") + @patch("src.word_processor.win32_client.DispatchEx", new=None, create=True) + def test_initialize_fails_closed_when_macros_cannot_be_disabled( + self, mock_dispatch, mock_couninit, mock_coinit + ): + """Word automation must not continue when macro hardening fails.""" + app = MagicMock() + type(app).AutomationSecurity = property( + fset=MagicMock(side_effect=Exception("policy denied")) + ) + mock_dispatch.return_value = app + + wp = WordProcessor() + + with pytest.raises(RuntimeError, match="policy denied"): + wp.initialize() + + app.Quit.assert_called_once() + mock_coinit.assert_called_once() + mock_couninit.assert_called_once() + assert wp.word_app is None + assert wp._initialized is False + def test_safe_com_call_retry(self, wp): """Safe COM call should retry on genuinely transient COM faults.""" mock_func = MagicMock() @@ -241,8 +267,9 @@ def test_replace_dates_no_match_warning(self, wp): with patch.object(wp, "_normalize_spaces_in_doc"), patch.object( wp, "_execute_replace", return_value=False ), patch("src.word_processor.logger") as mock_logger: - wp.replace_dates(mock_doc, current_date) + replacements = wp.replace_dates(mock_doc, current_date) mock_logger.warning.assert_called() + assert replacements == 0 def test_normalize_spaces_called_before_patterns(self, wp): """Should normalize non-breaking spaces before running date patterns.""" @@ -418,7 +445,7 @@ def test_print_document_protected_document(self, wp, tmp_path): mock_doc.Unprotect.assert_called_once() def test_print_document_active_printer_failure(self, wp, tmp_path): - """print_document should continue even if ActivePrinter assignment fails.""" + """print_document must not fall back to an unintended printer.""" wp._initialized = True wp.word_app = MagicMock() @@ -440,9 +467,29 @@ def test_print_document_active_printer_failure(self, wp, tmp_path): str(tmp_path), "Wednesday", date(2026, 1, 14), "Bad Printer" ) - # Should still succeed (ActivePrinter failure is non-fatal) - assert success is True - mock_doc.PrintOut.assert_called_once_with(False) + assert success is False + assert "Printer not found" in (error or "") + mock_doc.PrintOut.assert_not_called() + + def test_print_document_no_date_replacement_blocks_print(self, wp, tmp_path): + """A template with no supported date must not be printed unchanged.""" + wp._initialized = True + wp.word_app = MagicMock() + (tmp_path / "Wednesday.docx").write_text("dummy") + + mock_doc = MagicMock() + mock_doc.ProtectionType = -1 # PROTECTION_NONE + wp.word_app.Documents.Open.return_value = mock_doc + + with patch.object(wp, "safe_com_call", side_effect=lambda f, *a, **kw: f(*a)): + with patch.object(wp, "replace_dates", return_value=0): + success, error = wp.print_document( + str(tmp_path), "Wednesday", date(2026, 1, 14), "Printer" + ) + + assert success is False + assert "date" in (error or "").lower() + mock_doc.PrintOut.assert_not_called() def test_print_document_closes_on_printout_error(self, wp, tmp_path): """print_document finally block should close doc if PrintOut raises.""" diff --git a/tools/make_icon.py b/tools/make_icon.py index a2d9032..1369d25 100644 --- a/tools/make_icon.py +++ b/tools/make_icon.py @@ -1,4 +1,4 @@ -"""Regenerate the ShiftPrint application icon. +"""Regenerate the ShiftPress application icon. Geometry is expressed as a fraction of the canvas, so the mark is resolution independent. The values come from reading the mark at true 16px: a 0.020 gap From 1e5188bb77cdf63b56e6d64a6d743f8d25bc6513 Mon Sep 17 00:00:00 2001 From: Ryan Bledsoe Date: Mon, 31 Aug 2026 12:24:23 -0500 Subject: [PATCH 2/3] fix: satisfy Sonar quality gate --- src/main.py | 138 ++++++++++++++------------- src/ui.py | 229 ++++++++++++++++++++++++++++----------------- tests/test_main.py | 8 ++ tests/test_ui.py | 22 +++++ 4 files changed, 245 insertions(+), 152 deletions(-) diff --git a/src/main.py b/src/main.py index 3dc1fae..c67c8c1 100644 --- a/src/main.py +++ b/src/main.py @@ -65,6 +65,14 @@ class _BatchRequest: night_folder: str +def _batch_stop_status(completed_jobs: int, total_jobs: int) -> str: + """Describe how far a batch progressed before an unexpected stop.""" + if completed_jobs == 0: + return "Printing stopped before any schedules were completed" + schedule_noun = "schedule" if total_jobs == 1 else "schedules" + return f"Printing stopped after {completed_jobs} of {total_jobs} {schedule_noun}" + + class ShiftPressApp: """Main application controller. @@ -481,18 +489,8 @@ def _update(m: str = msg, p: float = progress) -> None: ) return success - def _process_batch(self, request: _BatchRequest) -> None: - """Process exactly the concrete jobs in a validated request. - - Args: - request: Immutable validated manifest and persisted setup values. - """ - total_jobs = len(request.manifest) - if total_jobs == 0: - logger.error("Attempted to process an empty print manifest") - self._safe_after(self._reset_ui) - return - + def _persist_batch_config(self, request: _BatchRequest) -> None: + """Persist the validated setup without preventing the current run.""" config = AppConfig( day_folder=request.day_folder, night_folder=request.night_folder, @@ -508,6 +506,68 @@ def _process_batch(self, request: _BatchRequest) -> None: ) ) + def _show_batch_outcome( + self, + total_jobs: int, + failed_operations: list[FailedOperation], + ) -> None: + """Report a completed batch, including any individual print failures.""" + if failed_operations: + failure_count = len(failed_operations) + failure_noun = "schedule" if failure_count == 1 else "schedules" + self._safe_after( + lambda: self.ui.update_status( + f"Completed with {failure_count} failed {failure_noun}", + PROGRESS_MAX, + level="error", + ) + ) + report_path = self._write_failure_report(failed_operations) + snapshot = list(failed_operations) + self._safe_after(lambda: self._show_failure_summary(snapshot, report_path)) + return + + schedule_noun = "schedule" if total_jobs == 1 else "schedules" + self._safe_after( + lambda: self.ui.update_status( + f"All {total_jobs} {schedule_noun} sent to printer", + PROGRESS_MAX, + level="success", + ) + ) + self._safe_after( + lambda: self.ui.show_info( + "Success", + f"All {total_jobs} {schedule_noun} were sent to the printer.", + ) + ) + + def _show_batch_error(self, completed_jobs: int, total_jobs: int) -> None: + """Report an unexpected batch failure with preserved progress.""" + progress = (completed_jobs / max(total_jobs, 1)) * PROGRESS_MAX + status = _batch_stop_status(completed_jobs, total_jobs) + self._safe_after(lambda: self.ui.update_status(status, progress, level="error")) + self._safe_after( + lambda: self.ui.show_error( + "Printing stopped", + "Review the selected printer and template folders, then try again. " + "If the problem repeats, select Open logs and contact support.", + ) + ) + + def _process_batch(self, request: _BatchRequest) -> None: + """Process exactly the concrete jobs in a validated request. + + Args: + request: Immutable validated manifest and persisted setup values. + """ + total_jobs = len(request.manifest) + if total_jobs == 0: + logger.error("Attempted to process an empty print manifest") + self._safe_after(self._reset_ui) + return + + self._persist_batch_config(request) logger.info(f"Processing {total_jobs} selected schedules") failed_operations: list[FailedOperation] = [] completed_jobs = 0 @@ -535,61 +595,11 @@ def _process_batch(self, request: _BatchRequest) -> None: ) completed_jobs += 1 - if failed_operations: - failure_count = len(failed_operations) - failure_noun = "schedule" if failure_count == 1 else "schedules" - self._safe_after( - lambda: self.ui.update_status( - f"Completed with {failure_count} failed {failure_noun}", - PROGRESS_MAX, - level="error", - ) - ) - report_path = self._write_failure_report(failed_operations) - snapshot = list(failed_operations) - self._safe_after( - lambda: self._show_failure_summary(snapshot, report_path) - ) - else: - schedule_noun = "schedule" if total_jobs == 1 else "schedules" - self._safe_after( - lambda: self.ui.update_status( - f"All {total_jobs} {schedule_noun} sent to printer", - PROGRESS_MAX, - level="success", - ) - ) - self._safe_after( - lambda: self.ui.show_info( - "Success", - f"All {total_jobs} {schedule_noun} were sent to the printer.", - ) - ) + self._show_batch_outcome(total_jobs, failed_operations) except Exception: logger.exception("Error during batch processing") - progress = (completed_jobs / max(total_jobs, 1)) * PROGRESS_MAX - if completed_jobs: - status = ( - f"Printing stopped after {completed_jobs} of {total_jobs} " - f"schedule{'' if total_jobs == 1 else 's'}" - ) - else: - status = "Printing stopped before any schedules were completed" - self._safe_after( - lambda: self.ui.update_status( - status, - progress, - level="error", - ) - ) - self._safe_after( - lambda: self.ui.show_error( - "Printing stopped", - "Review the selected printer and template folders, then try again. " - "If the problem repeats, select Open logs and contact support.", - ) - ) + self._show_batch_error(completed_jobs, total_jobs) finally: self._safe_after(self._reset_ui) diff --git a/src/ui.py b/src/ui.py index a8ef611..cc77e38 100644 --- a/src/ui.py +++ b/src/ui.py @@ -79,6 +79,57 @@ _STYLE_CARD_RADIOBUTTON = "Card.TRadiobutton" _DATE_ENTRY_SELECTED_EVENT = "<>" _DATE_PATTERN = "mm/dd/yyyy" +_FOCUS_IN_EVENT = "" +_CONFIGURE_EVENT = "" +_PRINT_BUTTON_LABEL = "Print schedules" + + +def _status_style( + message: str, + level: Optional[Literal["info", "success", "error"]], +) -> str: + """Return the visual style for an explicit or inferred status level.""" + if level == "success": + return _STYLE_SUCCESS_LABEL + if level == "error": + return _STYLE_ERROR_LABEL + if level is not None: + return _STYLE_SUB_LABEL + + message_lower = message.lower() + if "complete" in message_lower: + return _STYLE_SUCCESS_LABEL + if any(token in message_lower for token in ("cancel", "error", "fail")): + return _STYLE_ERROR_LABEL + return _STYLE_SUB_LABEL + + +def _manifest_blocker( + selections: tuple[ShiftSelection, ShiftSelection], + invalid: tuple[ShiftSelection, ...], + manifest: tuple[Any, ...], + printer_label: str, +) -> Optional[str]: + """Return the first local condition that must block the Print action.""" + if invalid: + names = " and ".join(selection.shift_type.title() for selection in invalid) + return f"Fix {names} date selection" + if not manifest: + return "Select at least one Night or Day schedule" + + missing_folder = next( + ( + selection + for selection in selections + if selection.enabled and not selection.folder.strip() + ), + None, + ) + if missing_folder is not None: + return f"Choose {missing_folder.shift_type.title()} Templates in Setup" + if printer_label == "Choose a printer": + return "Choose a printer in Setup" + return None def _get_work_area(window: tk.Misc) -> tuple[int, int, int, int]: @@ -158,7 +209,7 @@ def _hide(_event: Any = None) -> None: entry.delete(0, tk.END) entry.config(foreground=COLORS.text_main) - entry.bind("", _hide, add="+") + entry.bind(_FOCUS_IN_EVENT, _hide, add="+") entry.bind("", _show, add="+") # Show placeholder initially if entry is empty. _show() @@ -690,10 +741,10 @@ def _create_widgets(self) -> None: self._main_content_window = self._main_canvas.create_window( (0, 0), window=bg_canvas, anchor="nw" ) - bg_canvas.bind("", self._sync_main_scroll_region) - self._main_canvas.bind("", self._resize_main_content) + bg_canvas.bind(_CONFIGURE_EVENT, self._sync_main_scroll_region) + self._main_canvas.bind(_CONFIGURE_EVENT, self._resize_main_content) self.root.bind("", self._scroll_main_content, add="+") - self.root.bind("", self._ensure_focus_visible, add="+") + self.root.bind(_FOCUS_IN_EVENT, self._ensure_focus_visible, add="+") self._create_header(bg_canvas) self._create_setup_card(bg_canvas) @@ -893,10 +944,10 @@ def _create_setup_dialog(self) -> None: self._setup_content_window = self._setup_canvas.create_window( (0, 0), window=canvas, anchor="nw" ) - canvas.bind("", self._sync_setup_scroll_region) - self._setup_canvas.bind("", self._resize_setup_content) + canvas.bind(_CONFIGURE_EVENT, self._sync_setup_scroll_region) + self._setup_canvas.bind(_CONFIGURE_EVENT, self._resize_setup_content) dialog.bind("", self._scroll_setup_content, add="+") - dialog.bind("", self._ensure_setup_focus_visible, add="+") + dialog.bind(_FOCUS_IN_EVENT, self._ensure_setup_focus_visible, add="+") ttk.Label(canvas, text="Setup", style=_STYLE_HEADER_LABEL).pack(anchor="w") ttk.Label( canvas, @@ -1653,7 +1704,7 @@ def _create_footer(self, parent: ttk.Frame) -> None: self.print_btn = ttk.Button( footer, - text="Print schedules", + text=_PRINT_BUTTON_LABEL, underline=0, style=_STYLE_PRIMARY_BUTTON, width=20, @@ -1835,7 +1886,7 @@ def _document_noun(count: int) -> str: def _print_button_text(count: int) -> str: """Return the count-bearing label for the primary action.""" if count == 0: - return "Print schedules" + return _PRINT_BUTTON_LABEL if count == 1: return "Print 1 schedule" return f"Print {count} schedules" @@ -1892,19 +1943,71 @@ def _describe_manifest( row_number += 1 return title, lines + def _show_dependency_manifest_error(self) -> bool: + """Render the missing-date-control state and report whether it handled view.""" + if len(self._shift_panels) == 2: + return False + if not self._dependency_error: + return True + if self.manifest_title_label is not None: + self.manifest_title_label.config( + text="Print scope: Date selection unavailable" + ) + if self.manifest_label is not None: + self.manifest_label.config(text=self._dependency_error) + if self.print_btn is not None: + self.print_btn.config(text=_PRINT_BUTTON_LABEL) + self.print_btn.config(state="disabled") + return True + + def _manifest_preview_data( + self, + selections: tuple[ShiftSelection, ShiftSelection], + errors: dict[ShiftType, Optional[str]], + ) -> tuple[tuple[PrintJob, ...], str, list[str], tuple[ShiftSelection, ...]]: + """Build the neutral preview data before local blockers are applied.""" + invalid = tuple( + selection for selection in selections if errors[selection.shift_type] + ) + if invalid: + names = " and ".join(selection.shift_type.title() for selection in invalid) + title = f"Print scope: Check {names} date selection" + lines = [errors[selection.shift_type] or "" for selection in invalid] + return (), title, lines, invalid + + manifest = build_print_manifest(selections) + title, lines = self._describe_manifest(selections, manifest) + return manifest, title, lines, invalid + + def _render_manifest_preview( + self, + title: str, + lines: list[str], + manifest_count: int, + blocker: Optional[str], + update_status: bool, + ) -> None: + """Apply prepared manifest copy and action state to optional widgets.""" + if self.manifest_title_label is not None: + self.manifest_title_label.config(text=title) + if self.manifest_label is not None: + self.manifest_label.config(text="\n".join(lines)) + if update_status and self.status_label is not None: + status_text = ( + blocker or "Scope selected. Preflight runs when you select Print." + ) + status_style = _STYLE_ERROR_LABEL if blocker else _STYLE_SUB_LABEL + self.status_label.config(text=status_text, style=status_style) + if self.print_btn is not None: + self.print_btn.config(text=self._print_button_text(manifest_count)) + state: Literal["normal", "disabled"] = ( + "normal" if blocker is None and self._inputs_enabled else "disabled" + ) + self.print_btn.config(state=state) + def refresh_manifest_preview(self, update_status: bool = True) -> None: """Refresh the preflight-neutral manifest copy and count-aware action.""" - if len(self._shift_panels) != 2: - if self._dependency_error: - if self.manifest_title_label is not None: - self.manifest_title_label.config( - text="Print scope: Date selection unavailable" - ) - if self.manifest_label is not None: - self.manifest_label.config(text=self._dependency_error) - if self.print_btn is not None: - self.print_btn.config(text="Print schedules") - self.print_btn.config(state="disabled") + if self._show_dependency_manifest_error(): return selections = self.get_shift_selections() @@ -1912,16 +2015,9 @@ def refresh_manifest_preview(self, update_status: bool = True) -> None: selection.shift_type: selection.validate() for selection in selections } self._refresh_shift_counts(selections, errors) - - invalid = [s for s in selections if errors[s.shift_type]] - if invalid: - manifest: tuple[PrintJob, ...] = () - names = " and ".join(s.shift_type.title() for s in invalid) - title = f"Print scope: Check {names} date selection" - lines = [errors[s.shift_type] or "" for s in invalid] - else: - manifest = build_print_manifest(selections) - title, lines = self._describe_manifest(selections, manifest) + manifest, title, lines, invalid = self._manifest_preview_data( + selections, errors + ) printer = self.get_printer_name() printer_label = ( @@ -1930,44 +2026,12 @@ def refresh_manifest_preview(self, update_status: bool = True) -> None: else "Choose a printer" ) lines.append(f"Printer: {printer_label}") - - blocker: Optional[str] = None - if invalid: - names = " and ".join(s.shift_type.title() for s in invalid) - blocker = f"Fix {names} date selection" - elif not manifest: - blocker = "Select at least one Night or Day schedule" - else: - for selection in selections: - if selection.enabled and not selection.folder.strip(): - blocker = ( - f"Choose {selection.shift_type.title()} Templates in Setup" - ) - break - if blocker is None and printer_label == "Choose a printer": - blocker = "Choose a printer in Setup" - + blocker = _manifest_blocker(selections, invalid, manifest, printer_label) if blocker: lines.append(f"Cannot print: {blocker}") - - if self.manifest_title_label is not None: - self.manifest_title_label.config(text=title) - if self.manifest_label is not None: - self.manifest_label.config(text="\n".join(lines)) - if update_status and self.status_label is not None: - if blocker: - self.status_label.config(text=blocker, style=_STYLE_ERROR_LABEL) - else: - self.status_label.config( - text="Scope selected. Preflight runs when you select Print.", - style=_STYLE_SUB_LABEL, - ) - if self.print_btn is not None: - self.print_btn.config(text=self._print_button_text(len(manifest))) - state: Literal["normal", "disabled"] = ( - "normal" if blocker is None and self._inputs_enabled else "disabled" - ) - self.print_btn.config(state=state) + self._render_manifest_preview( + title, lines, len(manifest), blocker, update_status + ) def set_processing_mode(self, processing: bool) -> None: """Switch the primary action between print and cancellation states.""" @@ -2080,34 +2144,23 @@ def update_status( if self._progress_row is not None: self._progress_row.grid() if self.status_label: - if level == "success": - style = _STYLE_SUCCESS_LABEL - elif level == "error": - style = _STYLE_ERROR_LABEL - elif level is not None: - style = _STYLE_SUB_LABEL - else: - # Infer from message for callers that don't pass level. - msg_lower = message.lower() - if "complete" in msg_lower: - style = _STYLE_SUCCESS_LABEL - elif ( - "cancel" in msg_lower or "error" in msg_lower or "fail" in msg_lower - ): - style = _STYLE_ERROR_LABEL - else: - style = _STYLE_SUB_LABEL + style = _status_style(message, level) self.status_label.config(text=message, style=style) - if self._logs_btn is not None: - if style == _STYLE_ERROR_LABEL: - self._logs_btn.pack(side="left", padx=(12, 0)) - else: - self._logs_btn.pack_forget() + self._set_logs_button_visibility(style) if self.progress_var: self.progress_var.set(progress) if self._progress_pct: self._progress_pct.config(text=f"{int(progress)}%") + def _set_logs_button_visibility(self, status_style: str) -> None: + """Show the logs shortcut only while an error status is visible.""" + if self._logs_btn is None: + return + if status_style == _STYLE_ERROR_LABEL: + self._logs_btn.pack(side="left", padx=(12, 0)) + return + self._logs_btn.pack_forget() + # ------------------------------------------------------------------ # Dialogs # ------------------------------------------------------------------ diff --git a/tests/test_main.py b/tests/test_main.py index aeead66..ace9b74 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -35,6 +35,14 @@ def _run_scheduled_callbacks(app: ShiftPressApp) -> None: callback() +def test_batch_stop_status_preserves_completed_progress_language() -> None: + """The extracted batch error copy must preserve zero and partial outcomes.""" + status_for = getattr(main_module, "_batch_stop_status", None) + assert callable(status_for) + assert status_for(0, 2) == "Printing stopped before any schedules were completed" + assert status_for(1, 2) == "Printing stopped after 1 of 2 schedules" + + class TestShiftPressApp: """Tests for ShiftPressApp class.""" diff --git a/tests/test_ui.py b/tests/test_ui.py index 578c689..b5bb17d 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -8,6 +8,7 @@ import pytest +import src.ui as ui_module from src.constants import COLORS, FONTS from src.ui import ScheduleAppUI, _PATH_PLACEHOLDER @@ -902,6 +903,27 @@ def test_update_status_error_style(self, ui): ui.update_status(msg, 0.0) ui.status_label.config.assert_called_with(text=msg, style="Error.TLabel") + def test_status_style_helper_preserves_explicit_and_inferred_states(self): + """The extracted status decision must preserve every public style rule.""" + style_for = getattr(ui_module, "_status_style", None) + assert callable(style_for) + assert style_for("Anything", "success") == "Success.TLabel" + assert style_for("Anything", "error") == "Error.TLabel" + assert style_for("Anything", "info") == "Sub.TLabel" + assert style_for("Print complete", None) == "Success.TLabel" + assert style_for("Print failed", None) == "Error.TLabel" + assert style_for("Processing", None) == "Sub.TLabel" + + def test_manifest_blocker_helper_keeps_missing_printer_actionable(self, ui): + """The extracted blocker decision must keep Print disabled without a printer.""" + blocker_for = getattr(ui_module, "_manifest_blocker", None) + assert callable(blocker_for) + selections = ui.get_shift_selections() + assert ( + blocker_for(selections, (), (MagicMock(),), "Choose a printer") + == "Choose a printer in Setup" + ) + @patch("tkinter.messagebox.showerror") def test_show_error(self, mock_error, ui): """Should call messagebox.showerror.""" From 08197c50a16473d21a29269f9fcc35ca188fbc75 Mon Sep 17 00:00:00 2001 From: Ryan Bledsoe Date: Mon, 31 Aug 2026 22:48:05 -0500 Subject: [PATCH 3/3] test: cover responsive overflow controls --- tests/test_ui.py | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/test_ui.py b/tests/test_ui.py index b5bb17d..0d526d4 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -333,6 +333,25 @@ def test_constrained_display_enables_vertical_overflow_recovery(self, ui, root): root.geometry.assert_called_once_with("1040x688") ui._show_main_scrollbar.assert_called_once() + def test_main_overflow_helpers_resize_and_scroll_visible_content(self, ui): + """The main viewport must expose and operate its scrollbar when constrained.""" + event = MagicMock(width=640, height=480, delta=60) + ui._main_content.winfo_reqheight.return_value = 900 + ui._main_scrollbar.winfo_ismapped.return_value = True + + ui._sync_main_scroll_region() + ui._resize_main_content(event) + ui._scroll_main_content(event) + + ui._main_canvas.configure.assert_any_call( + scrollregion=ui._main_canvas.bbox.return_value + ) + ui._main_canvas.itemconfigure.assert_called_with( + ui._main_content_window, width=640 + ) + ui._main_scrollbar.pack.assert_called_with(side="right", fill="y") + ui._main_canvas.yview_scroll.assert_called_with(-1, "units") + def test_windows_work_area_keeps_the_main_window_above_the_taskbar(self, ui, root): """Centering must use Windows' work area, not the taskbar-covered screen.""" root.winfo_width.return_value = 1040 @@ -376,6 +395,25 @@ def test_setup_dialog_caps_height_and_enables_overflow_recovery(self, ui): assert geometry.startswith("720x520+") ui._show_setup_scrollbar.assert_called_once() + def test_setup_overflow_helpers_resize_and_scroll_visible_content(self, ui): + """The Setup viewport must expose and operate its scrollbar when constrained.""" + event = MagicMock(width=620, height=400, delta=-60) + ui._setup_content.winfo_reqheight.return_value = 700 + ui._setup_scrollbar.winfo_ismapped.return_value = True + + ui._sync_setup_scroll_region() + ui._resize_setup_content(event) + ui._scroll_setup_content(event) + + ui._setup_canvas.configure.assert_any_call( + scrollregion=ui._setup_canvas.bbox.return_value + ) + ui._setup_canvas.itemconfigure.assert_called_with( + ui._setup_content_window, width=620 + ) + ui._setup_scrollbar.pack.assert_called_with(side="right", fill="y") + ui._setup_canvas.yview_scroll.assert_called_with(1, "units") + def test_manifest_preview_uses_actual_selected_job_count(self, ui): """The visible manifest and action should reflect independent jobs.""" ui.manifest_title_label = MagicMock()