From 741eeaa92b81079670f137f48916f8f7322dc190 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 24 Feb 2026 10:35:07 -0500 Subject: [PATCH 01/10] fix bug in batches where sometimes it would grab the wrong photo --- faststack/all_verification_results.txt | Bin 2854 -> 0 bytes faststack/all_verification_results_utf8.txt | 19 ----- faststack/app.py | 69 +++++++++++++---- faststack/check_daemon.py | 20 ----- faststack/check_scipy.py | 6 -- faststack/integration_results.txt | Bin 12730 -> 0 bytes faststack/integration_traceback.txt | Bin 16006 -> 0 bytes faststack/path_check.txt | Bin 96 -> 0 bytes faststack/repro_cache_lock.py | 48 ------------ faststack/repro_daemon_bug.py | 33 -------- faststack/repro_imports.py | 15 ---- faststack/repro_type_error.py | 30 -------- faststack/rotation_error.txt | Bin 1856 -> 0 bytes faststack/test_prespawn_strategy.py | 41 ---------- faststack/thumbnail_view/model.py | 5 +- faststack/traceback.txt | Bin 16444 -> 0 bytes faststack/verify_cache_fix.py | 60 --------------- faststack/verify_wb.py | 67 ----------------- scripts/smoke_verify.py | 79 -------------------- 19 files changed, 58 insertions(+), 434 deletions(-) delete mode 100644 faststack/all_verification_results.txt delete mode 100644 faststack/all_verification_results_utf8.txt delete mode 100644 faststack/check_daemon.py delete mode 100644 faststack/check_scipy.py delete mode 100644 faststack/integration_results.txt delete mode 100644 faststack/integration_traceback.txt delete mode 100644 faststack/path_check.txt delete mode 100644 faststack/repro_cache_lock.py delete mode 100644 faststack/repro_daemon_bug.py delete mode 100644 faststack/repro_imports.py delete mode 100644 faststack/repro_type_error.py delete mode 100644 faststack/rotation_error.txt delete mode 100644 faststack/test_prespawn_strategy.py delete mode 100644 faststack/traceback.txt delete mode 100644 faststack/verify_cache_fix.py delete mode 100644 faststack/verify_wb.py delete mode 100644 scripts/smoke_verify.py diff --git a/faststack/all_verification_results.txt b/faststack/all_verification_results.txt deleted file mode 100644 index 09488378c802547ecccdd74e72175348ad68eabe..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2854 zcmd6pTTk0S5QXQtQvZWr`qo5=Lx3XjfO?UT`cRd=5o84?m{8{;Cn@F6xBbpI#xBH- z8WAa4dFA!a&YUwl7ytfsYTJ&$7B;jwbHJ>(xs7aU6JIZ^Zy9@YR@+)Qwb%B_W|pFB zVK?Z`Y>d8p>`bg)4I7->$?r_=Jm%(dR}B}Q|TXNpHtx2JQdOKVRa zPB_){5xCZJcL=HgUmY-ykRGCC?plPPh>JOFi}wf_F_Fa#e~6v@CriTHbbKcuEl-p( zI;%4|?p#;GDgu{oLpQ4^IK#Ua_R&7Ds*X!v9oZYZa+HOQ-60gN6m(1Gl4WGqu;{}# zCR&C1(qpvck=G(B8$4IfxCpQ z`Q*c{?Y7>j_TI0#zpAI|--YmS#}!Qox5p@iM;j|v*`xA47I}=R&lNqU>}=oswiet{ z*$KaLi`70@-vQVAJ4d;xChZpG`uJ3rOZvQAZzCB-?Pk(!OOjhN8 Tu0pG<8gd`{8yk3L>3`-AS!wS3 diff --git a/faststack/all_verification_results_utf8.txt b/faststack/all_verification_results_utf8.txt deleted file mode 100644 index 2f22428..0000000 --- a/faststack/all_verification_results_utf8.txt +++ /dev/null @@ -1,19 +0,0 @@ -============================= test session starts ============================= -platform win32 -- Python 3.12.10, pytest-9.0.2, pluggy-1.6.0 -- C:\code\faststack\faststack\verify_venv\Scripts\python.exe -rootdir: C:\code\faststack -configfile: pyproject.toml -collecting ... collected 14 items - -tests\test_editor_rotation.py::test_rotated_rect_edge_cases PASSED [ 7%] -tests\test_editor_rotation.py::test_rotated_rect_calculation_branches[100-100-0] PASSED [ 14%] -tests\test_editor_rotation.py::test_rotated_rect_calculation_branches[200-100-45] PASSED [ 21%] -tests\test_editor_rotation.py::test_rotated_rect_calculation_branches[1000-500-15] PASSED [ 28%] -tests\test_editor_rotation.py::test_rotated_rect_calculation_branches[500-1000-15] PASSED [ 35%] -tests\test_editor_rotation.py::test_rotate_autocrop_rgb_behavior PASSED [ 42%] -tests\test_editor_rotation.py::test_boundary_clamping PASSED [ 50%] -tests\test_editor_rotation.py::test_integration_straighten_modes FAILED [ 57%] -tests\test_editor_rotation.py::test_rotate_cw PASSED [ 64%] -tests\test_editor_rotation.py::test_rotate_ccw PASSED [ 71%] -tests\test_rotation_unittest.py::TestEditorRotation::test_rotate_cw PASSED [ 78%] -tests\test_rotation_unittest.py::TestEditorRotation::test_straighten_angle PASSED [ 85%] -tests\test_editor_integration.py::TestEditorIntegration::test_missing_methods diff --git a/faststack/app.py b/faststack/app.py index d94daff..cb2790c 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -4299,6 +4299,39 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: else: self.current_index = min(previous_index, len(self.image_files) - 1) + # Adjust batch index ranges to account for removed entries. + # Deleting index d shifts every index > d down by one. Without this, + # batches that sit above any deleted image reference the wrong files. + if self.batches: + deleted_set = set(sorted_indices) + deleted_ascending = sorted(sorted_indices) + + def _shift(orig_idx: int) -> int: + return orig_idx - sum(1 for d in deleted_ascending if d < orig_idx) + + new_batches = [] + for b_start, b_end in self.batches: + # Anchor on the first and last *surviving* indices in the range. + # If every image in the batch was deleted, discard it entirely so + # it cannot migrate onto unrelated images. + first_ok = next( + (i for i in range(b_start, b_end + 1) if i not in deleted_set), + None, + ) + if first_ok is None: + continue # whole batch deleted — drop it + last_ok = next( + (i for i in range(b_end, b_start - 1, -1) if i not in deleted_set), + None, + ) + ns = _shift(first_ok) + ne = _shift(last_ok) + if ns <= ne: + new_batches.append([ns, ne]) + if new_batches != self.batches: + self.batches = new_batches + self._invalidate_batch_cache() + # Update UI immediately - this is fast since it just reads from memory # Check for existence, not truthiness (empty cache is falsy) if self.image_cache is not None: @@ -4870,6 +4903,16 @@ def shutdown_qt(self): log.info("Detaching QML engine.") self.engine = None + @staticmethod + def _safe_shutdown_executor(executor, name, *, wait=False, cancel_futures=True): + """Shut down a single executor, logging and swallowing any error.""" + if executor is None: + return + try: + executor.shutdown(wait=wait, cancel_futures=cancel_futures) + except Exception as e: + log.warning("Error shutting down %s executor: %s", name, e, exc_info=True) + def shutdown_nonqt(self): """Shutdown non-Qt resources - safe to run in background thread.""" log.info("Shutting down background resources.") @@ -4892,21 +4935,17 @@ def shutdown_nonqt(self): if not (entry[0] == "pending_delete" and entry[1] in pending_ids) ] - # Shutdown thread pool executors - try: - log.info("Shutting down background executors...") - self._hist_executor.shutdown(wait=False, cancel_futures=True) - self._preview_executor.shutdown(wait=False, cancel_futures=True) - - exif_exec = getattr(self, "_exif_executor", None) - if exif_exec: - exif_exec.shutdown(wait=False, cancel_futures=True) - - # wait=True ensures pending saves/deletes complete to avoid data loss/corruption - self._save_executor.shutdown(wait=True, cancel_futures=False) - self._delete_executor.shutdown(wait=True, cancel_futures=False) - except Exception as e: - log.warning("Error shutting down executors: %s", e) + # Shutdown thread pool executors — each isolated so one failure can't + # prevent the others (especially save/delete) from shutting down. + log.info("Shutting down background executors...") + self._safe_shutdown_executor(self._hist_executor, "histogram", wait=False) + self._safe_shutdown_executor(self._preview_executor, "preview", wait=False) + self._safe_shutdown_executor( + getattr(self, "_exif_executor", None), "exif", wait=False, + ) + # wait=True ensures pending saves/deletes complete to avoid data loss/corruption + self._safe_shutdown_executor(self._save_executor, "save", wait=True, cancel_futures=False) + self._safe_shutdown_executor(self._delete_executor, "delete", wait=True, cancel_futures=False) # Shutdown prefetcher try: diff --git a/faststack/check_daemon.py b/faststack/check_daemon.py deleted file mode 100644 index e8a3de3..0000000 --- a/faststack/check_daemon.py +++ /dev/null @@ -1,20 +0,0 @@ -import threading -from concurrent.futures import ThreadPoolExecutor - - -def set_daemon(): - try: - threading.current_thread().daemon = True - print(f"Set daemon for {threading.current_thread().name}") - except Exception as e: - print(f"Failed to set daemon for {threading.current_thread().name}: {e}") - - -def check_daemon(): - return threading.current_thread().daemon - - -if __name__ == "__main__": - with ThreadPoolExecutor(max_workers=1, initializer=set_daemon) as executor: - print(f"Result: {executor.submit(check_daemon).result()}") - diff --git a/faststack/check_scipy.py b/faststack/check_scipy.py deleted file mode 100644 index f1df113..0000000 --- a/faststack/check_scipy.py +++ /dev/null @@ -1,6 +0,0 @@ -try: - import scipy.ndimage - - print("scipy available") -except ImportError: - print("scipy NOT available") diff --git a/faststack/integration_results.txt b/faststack/integration_results.txt deleted file mode 100644 index 53f7d0426d485f4575b16dc0ab0ab32cabe82b23..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12730 zcmeI2+fEx-6o&V8rM|;Rr~;(M;Z#bZBvnK}jg$lu()I#Ui(mt89E@xyfU3TF+yC3k ziy4o{V{9V9#b{)EybtSq+H2;&f1id~uO#$CGxYSG=$pG{MfVSMC1E#QX~pRP`)a6#WzF)v6n@nFd7L+D zAA}9ftm(5dm}`eG;Y8P!U@dEWU7w|E*qh;TI0#3A-4K@tqT^6p!5KUrg-;r*g|C`B z)AvEx(^yv&p2sx@1KdViO{*^Tfv>J0cC@CUwO!#z!emVBgOW2*(Gtacw*`;g@OU1f z{-u#4L9Xf3k;K{&AN$&IHFHStM7wJ7N+Q&BwkfKTDBqUWHU*;>VSwMY-V?8WU)LJ= z0ADxMBXs6Y^bg&cUDP6tk45c!%^|<7%&wQBXmk&&Q!Jka$?Od3>!PcvS!CPLcoMe5 z^YAjf65JR1x3AAn(&P`}L!{o}+y&)$NM97Yoa&MV9&$L~p&8|NGLV+(CeIDloN|LM zX7{XgXDYSd!aGTBPf|-~Z*vEQ^@&$9g*9N&W;y7-+!iKdu3y_U+G)$1;5-RWGFV(+ z<#zpkK-FY>PNU{)?Qz?En!#Ak*RyMypNUegU*d(V`8mE-%G+WZF-p|NHuJndP-V8sTg5%X3{d#TR}zC0C?O47aG+4GZG? zQZaK?QeRgd*^ta1DbK8Fd{HtW4kmF8UThbuH^a}0Tbps+nszM(({W3hJXDlxN`jUn z%sT&w(X}DW7m9hi5$>t5&x^WwVItp=i%#OYrv8~tzmCvaqVBc!lD87AXlQm`RIe#g zZs=Oq-MVOi?nkbHetkC?+R%23eCzXGhXI7A+ldf(=++*b|0+#8ga!#WJv; z4q)QJ)E!6KWjWfovs!}Dt}d*Gs^K`UVvJZ@Q+z&+_t3CAkj_>P-a@q2OX0lGimt2+ zDfDDr%aX*+>4Zl(rn)&5HDy{zrC!7_dN|8S4KH{j+Rik7b9#^#yinBwVQGrJ=*|3$ zoy2-1nrgD9O2t-SE#)-1CR6KpH-L2|4VfO{jM{D?`Xc!Z8?eaLR%}k~A%#+pG-0=G z?VLmf^<`64ZcE=6suql!X($+mP=>uo9{9>Y_D(G_QBk_TrL+eakw$V!|+ zvmZ5wrl?mtaR>EpR%4SSQ_sM^-@fK@*p9ptbBN@P308#PWO?M|K|J@}!}1xYyWJuC znXJJgIG!c2?YuSJZ^`GWy=|<^!z+q-!@1cbrvH}7a{9=Zr{`P7zq;a+ox3e&5ZTEv zSZ>}9EK+-{hu1lM_TZfVyG^#Ss6{+l< zhV6)%b2qdkiZ@g!Py}yR+IggV>K3X>k4i+ZhA`1punNF2JHAPj!u4_Fjh*SJ8P9F%f6r z8@f5`mq~a>E6H`lds|DCG0)fxziWILW+HfCLkCv5<-1u~inI5lK9uH_;fj59{N`}H z!_$jrs#j5}eRVcoUgt34IQsb@4$$F#yfd4(IV{Zef}K-kW_8Dib=j^aR3TIoSY|ed z&PLg$Ah``f3wB7&ou!XlOcm_5R4z5^sF1Vk(Yx%#L6o?RvU?%UsgCvwd30@NPZ(HT zzi_q7`tR_M=rdWr6Q-UhZpZ2b@1Vm2J#;BKtpm^KNu|G|8ifiO&N+8{k|fRxai+0) zxL(imX;j~=TKyuLES_eg17}WZ$!zcLX7H%1z>nt|w|Fb3Dtyq-V%X5WPHQ`gaNetQ ziF(v7*ERGFpKr4x<$H@ye#YOpIn(eP$>c)3j9bOIg%f)gfA-#OY)&w?!~s<|eo4f~ zgAX-kaf__tv7HD-XUem|_!`xEwB8qTY;R+?0zAbh<66`dF#-HULOZ)23L~{}U+2G$ zw1us3IxIVDbL9GVzYcaZ&`$WqF9!Hel}}&bwi#*A9Zqf^+oKm($&2C?kZHT z$?I1{OVwSa7QjNV6pz+bceU!Sx-IbQ5rmG9_d)3gRNYl1lI`#2J1|sz#l79EzqzdE zBSJ}BpVNsQ7B{NyDzUceuF@O!j&0Rl zwccgbU9}pc>aNz+AFR5o!|xDP-PNkQ$`h%(?5@uCo~u7)IszV1GCBF5YD>e?#?t(K>w(82V?^lI^nh=IL_Ub6K7QqJQ2UtLdG} N6Ew@PEtH@!|jg diff --git a/faststack/integration_traceback.txt b/faststack/integration_traceback.txt deleted file mode 100644 index efb9cdbbd583b5ef32364d2466c8bf43ca580b97..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16006 zcmeI3ZBH9V5Xbj*rGAG~Pz8|^7f2dt6e6Vws7Og5A!%MfYJo8ijf0VG0;uX&Z~Oo4 zFx>gLI~#{Kyf~eV&wIPG^R)k+**X6CdnrtMC1DWSp|8J*{&Lq3hoKve;_pG&4m~~V z>(`_+EQRmGci}j6L~9WCM7tM0i{7c`ISLQLyzcMoO2T?LSC4~>*&l>PnA21K&W3w> zejJ}0xA()(dNQle{Kd0QI0^f@&P8iZ?T_Mfr~N!U4qKrm+Pji+OMGlgDkMY3R`^uX z_C>vW;&e}Rn}J_P`nwf2G=}!$NMq}QH{-pbUT69scTY2R)n`|Id*UGpqd8$5oE~Y` zLrKKnj_5HO9UMohf2gG;$_;(G(r72n$GA@H%`-H-uThP7C6Q|Sv@NcZsQ*LtZHq=f z(qMk~ioRs^`=MU%#-e`AsA8_*mS&(=+tDoEf!RIELQoET2Wm{1EP& z;;XHv=yq4_NmvQb!fJRax-ax=Q=gyY2|t97k$b1}EjVWd4aBkQsVQBsg0?SYz?Yhm z$+Ts*$xDN`r_x}H$yZ)FGS%Aq@K&1Jkk-7f4R64s&2zAQwJ(gGxq0c+ zSf``agyczhl9|QrRqfZ~`*Vt_ucAko?=EF#td{HPCC`aCw2yctZR_Vu%cH;-L=_}jcgA5 z9-jsm+M0 zj<|_%=Etz67Ca-7RK2K|p7d5UPbx!}b3=iFbZ`(Um?R*BwatV(nlZm0HPnJV8XA}G z1$QRY9xDm_Jc)aQaWEdY4aw?pK;CvmI+&84KzSR5Cmlvf@rZH{vv>fM#}at!i8%hO z-HtsEO&t_!oY_7^Z^P$U$8JyeN}ju>u^;qV1BoyYYqN`j(Rk{SKKp8+*UrV06G;N^ zaL|tYr24Sdp4zuXsV`s2dd^xQ$HYA*=S<;qU%lh|$FO{O$h{k)T&1C#oM&Ac2jAE| zeU;oK{a<7^_LP^}``!!Ti1@H(`5ca^Pi6U{E5CMz@zT~;xwp?;pwWR){Ek+*@nPr= zqL*Q6F>)61SJw7>xcLhd;N!dYl$^>^@xWE53WH??u${=?LeM*ReF5N_Wy&JEoNop*&AJCP-dZy zD6l7ZMzfsJ2=t5PfzFOJOVm^(o6F_M;=ld7FAWi&^(5h4+2g9*#jAfxv;8WL*_FE5 zy)4^E6t(Q>>Zpyq2@R47ZfJ%=+%)(6Fo^r9Lv6J<3J)uR&WN(?&ukoV&!$?2#Y~ei;dDuGeiG& zuQk`hN|YVUA&S}^;YF;Qtc=9S;JJ?;)=!!3EJk$y8EwHLI;l63lV$z!wxe90TQvAJ zSeK_)gm}Yyv*U_?SLyQl$d~8mCh@N+e6sA?FvCtju>P+6a(HG#YR7tHoziDR_{Poz za@aYxolhk~6XF*;HQ>3^9_4-bKKrSk(GsT-mHA$g>fU)+i8Hf@!%HH(Awq#8WINZ$ zmhOpLIJI$90=>wkIjgn^z-RXPMsbQ%dr@{qvW_i&^Roodjy>us$Irzl+@PygPn|5zW5+j(z%SUqnbq*zqd4ux_z69H5uc}VCU6G1;hR&qDh;oxCwm=uZ+$@- zTN%&8Z)zW=$*B;tVFQcYEQ3pHC|QoP_gB|Yx~~jJ>{r{zr{iZ4{g_j|j9MM&ynOX? z4+F=s&qqms4L9SR`Mf>D!`v?TIZ#F3ULZ%(JMw7m}RlXtPj8w`MlO!0QHuyH4i^>TBLk zj82fs&%CH#aRvcB<0sYrnqm|pWF#l=_@vc!TsUWn)x+_6UQXkBylC}AJQ<#5(Sg%5 zV##bpXgqqvRm{)IHECI`WL2y|&&BYeO=W9qLO8F0xkeqet8EQ`!)Mglmg=#^nV-p5 z_RK8&S~@wEEM>je%gNZY^Jnkf$EHMMSrQO+vo1k=RxoFmhFk0^j_n{6RRiA*%41YJ z&(5EAy@}NT<|)pM8_`mB9%@Kvx$CxOBo-bh|Le+H_zKx!Rj!fWm##)+{gNExW>2)x z`m%JEy`G7CPWTQb7gDv=!z%%J==5cF_)hx=d%?6?yBQx%w4<<3C$f`O8}byPQmnV> z=oRo~`IuK&=XB=sFs_g){uozQC7ZM=2cBHF&RNF8?X1sTU&6X(M;6QpA5jsp2eB$q z1(lWR8ekdH*^WxQS=a1Lm)V^>)w9Ph7Mb^iw1&yNyOTd3D%169+*PRb)it{;_H$p? z?EEdMv?{$G!I1j%EF?_m}_pEV#-)wwi)Ib3~bo|Lfy*&F)*hnUME=PBrVA9j65OoYb*3i>r0buCCd69j?48TG#CAnq6J9 z^LO1wy09}ul{57cJWG#d%fBz{7+^j8)g6FyC>V*+djNsg8ez2Z0PiF MF4m>LW(GL?7kTQ`kpKVy diff --git a/faststack/path_check.txt b/faststack/path_check.txt deleted file mode 100644 index e2538979c38e7491cf14a57be7dfeb68fb3bcdb9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 96 zcmezWFPXuLA%-EDA)g_IAr;6|4&|@fIsAS+};9>v(JV+E9 diff --git a/faststack/repro_cache_lock.py b/faststack/repro_cache_lock.py deleted file mode 100644 index 0361007..0000000 --- a/faststack/repro_cache_lock.py +++ /dev/null @@ -1,48 +0,0 @@ -import threading -from faststack.imaging.cache import ByteLRUCache - - -def repro_lock_contention(): - lock_held_during_callback = False - - def on_evict_callback(key, value): - nonlocal lock_held_during_callback - # Try to acquire the same lock. If it's held by the current thread (RLock), - # we can check if it would block others or if we can detect it's held. - # Since it's an RLock, current thread can re-acquire it. - # But we can check if the lock is "locked" by looking at internal state - # or just by the fact that we know we are in the callback. - - # A better way to check if the lock is held: - # Since it's an RLock, it doesn't expose a simple "is_locked" that works across threads easily - # but we can try to acquire it in a DIFFERENT thread. - - def check_lock(): - nonlocal lock_held_during_callback - if not cache._lock.acquire(blocking=False): - lock_held_during_callback = True - else: - cache._lock.release() - - t = threading.Thread(target=check_lock) - t.start() - t.join() - - cache = ByteLRUCache(max_bytes=100, size_of=lambda x: x, on_evict=on_evict_callback) - - print("Adding item 'a' (50 bytes)") - cache["a"] = 50 - print("Adding item 'b' (50 bytes)") - cache["b"] = 50 - - print("Adding item 'c' (50 bytes) -> should trigger eviction of 'a'") - cache["c"] = 50 - - if lock_held_during_callback: - print("FAILED: Lock was HELD during on_evict callback!") - else: - print("SUCCESS: Lock was NOT held during on_evict callback.") - - -if __name__ == "__main__": - repro_lock_contention() diff --git a/faststack/repro_daemon_bug.py b/faststack/repro_daemon_bug.py deleted file mode 100644 index 1a8ec92..0000000 --- a/faststack/repro_daemon_bug.py +++ /dev/null @@ -1,33 +0,0 @@ -import concurrent.futures -import threading -import time - - -def check_daemon(): - print( - f"Thread {threading.current_thread().name} daemon: {threading.current_thread().daemon}" - ) - - -def test_failure_mimic(): - print("Main thread daemon:", threading.current_thread().daemon) - executor_container = {} - - def creator(): - executor_container["executor"] = concurrent.futures.ThreadPoolExecutor( - max_workers=1 - ) - - t = threading.Thread(target=creator, name="CreatorThread") - t.daemon = True - t.start() - t.join() # Creator thread dies - - executor = executor_container["executor"] - # If the executor spawns worker threads when submit is called, - # it might inherit from the CURRENT thread (main) instead of the creator thread. - executor.submit(check_daemon).result() - - -if __name__ == "__main__": - test_failure_mimic() diff --git a/faststack/repro_imports.py b/faststack/repro_imports.py deleted file mode 100644 index 94b1f4f..0000000 --- a/faststack/repro_imports.py +++ /dev/null @@ -1,15 +0,0 @@ -try: - from unittest.mock import MagicMock - - print("Success: from unittest.mock import MagicMock") -except ImportError as e: - print(f"Failed: {e}") - -try: - import faststack.app - - print("Success: import faststack.app") -except ImportError as e: - print(f"Failed: import faststack.app: {e}") -except Exception as e: - print(f"Failed: import faststack.app error: {e}") diff --git a/faststack/repro_type_error.py b/faststack/repro_type_error.py deleted file mode 100644 index a536809..0000000 --- a/faststack/repro_type_error.py +++ /dev/null @@ -1,30 +0,0 @@ -import sys -from pathlib import Path - -# Ensure we can import faststack -repo_root = str(Path(__file__).resolve().parent.parent) -sys.path.insert(0, repo_root) - -from faststack.imaging.editor import ImageEditor -from PIL import Image -import numpy as np - -editor = ImageEditor() -img = Image.new("RGB", (100, 100), (255, 0, 0)) -editor.original_image = img - -print("Calling _apply_edits...") -try: - res = editor._apply_edits(img) - print(f"Result type: {type(res)}") - if res is not None: - print( - f"Result shape/size: {getattr(res, 'shape', 'N/A')} / {getattr(res, 'size', 'N/A')}" - ) - else: - print("Result is None!") -except Exception as e: # noqa: BLE001 - print(f"Caught exception: {type(e).__name__}: {e}") - import traceback - - traceback.print_exc() diff --git a/faststack/rotation_error.txt b/faststack/rotation_error.txt deleted file mode 100644 index ab0537e429550ea97958b317ac0194622549ffd9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1856 zcmb`IPjAye5XIjaiSMwS%7MB~gAh~{A)y?&0HW83C`!^2>N=5=0O7*}zc;p(KNJxR z8trPmvorH%=I#2&_Y1X^q@^ZWFehfO7Mg0NBEFXzszkTot8Ih}-Pb+MmE&uvD}0x_ z#org=6zb}X^)Zt4PHRqltfP08se{(@sUD%9hrU94tY>IW8E3UF*GkvO8ebjupM<{g z{YvNRYlQDHS@yv(Br9j;97nqO&E98Dmms~u??C=4nESfK#{2W|(XX*p*fUOjWjODW zh%?TNIa`7u>CZ@PXQTyDr)1sA1xqS#TSDKHqq@La*ZyA@w+1CBb+6V`C$acK_3N-ck zl?^9Rk%}Rk^?<6lldgD4EZ4{Uai!g_Ec%n~Kw)G?*xa@S9BS5wc|F%-#2hf{r04X@ zY+gmTUxfF>zU5`j$Lg+Rob)}u34C4+*_jaE4F1OI;f{6Ehb=McUhVfyZP~-J;)d}Q z^={J9PTV7O)8JGQbxNR}1e=SfV|z~dt@2&-+qCYNw4XW`R2Z@=#tmtVAJkhcaK(_X>P{;ds6)w4vFbA?K@ocYyTx` SxbJtrtEa)OcAagkjPNIvh8&sz diff --git a/faststack/test_prespawn_strategy.py b/faststack/test_prespawn_strategy.py deleted file mode 100644 index 2f0e25a..0000000 --- a/faststack/test_prespawn_strategy.py +++ /dev/null @@ -1,41 +0,0 @@ -import concurrent.futures -import threading -import time - - -def check_daemon(): - print( - f"Thread {threading.current_thread().name} daemon: {threading.current_thread().daemon}" - ) - - -def test_prespawn(): - print("Main thread daemon:", threading.current_thread().daemon) - executor_container = {} - max_workers = 4 - - def creator(): - print( - f"Creator thread {threading.current_thread().name} daemon: {threading.current_thread().daemon}" - ) - executor = concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) - executor_container["executor"] = executor - # Force spawn all workers while we are in this daemon thread - # We need to submit at least 'max_workers' tasks and wait for them to be - # picked up by separate threads. - futures = [executor.submit(time.sleep, 0.05) for _ in range(max_workers)] - concurrent.futures.wait(futures) - print("All workers spawned from daemon thread.") - - t = threading.Thread(target=creator, name="CreatorThread") - t.daemon = True - t.start() - t.join() - - executor = executor_container["executor"] - print("Main thread calling submit (which should reuse a daemon worker)...") - executor.submit(check_daemon).result() - - -if __name__ == "__main__": - test_prespawn() diff --git a/faststack/thumbnail_view/model.py b/faststack/thumbnail_view/model.py index f663bde..951704a 100644 --- a/faststack/thumbnail_view/model.py +++ b/faststack/thumbnail_view/model.py @@ -260,7 +260,10 @@ def _get_loupe_index_for_entry(self, entry: ThumbnailEntry) -> Optional[int]: # We'll use the parent (AppController) to look this up parent = self.parent() if parent and hasattr(parent, "_path_to_index"): - return parent._path_to_index.get(entry.path.resolve()) + # Must use the same key format as _rebuild_path_to_index (abspath, + # not realpath/resolve) so the lookup hits on Windows and Linux. + key = os.path.normcase(os.path.abspath(str(entry.path))) + return parent._path_to_index.get(key) return None def roleNames(self) -> Dict[int, bytes]: diff --git a/faststack/traceback.txt b/faststack/traceback.txt deleted file mode 100644 index b12d9982d52b221ebd60fcacd866cf31aa9692e5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16444 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 0: - print("FAIL: Black level not preserved!") - else: - print("PASS: Black level preserved.") - - # 2. Test Grey Shift - # Create a mid-grey image - grey_img = Image.new("RGB", (100, 100), (128, 128, 128)) - grey_path = "test_grey.jpg" - grey_img.save(grey_path) - - editor.load_image(grey_path) - editor.set_edit_param("white_balance_by", 0.5) # Warm - # r_gain = 1 + 0.25 = 1.25 -> 128 * 1.25 = 160 - # b_gain = 1 - 0.25 = 0.75 -> 128 * 0.75 = 96 - - processed_img = editor._apply_edits(editor.original_image.copy()) - arr = np.array(processed_img) - r, g, b = arr[0, 0] - print(f"Grey Image RGB after Warm shift: R={r}, G={g}, B={b}") - - if r > 128 and b < 128: - print("PASS: Grey shifted warm correctly.") - else: - print("FAIL: Grey did not shift as expected.") - - # Cleanup - for path in [black_path, grey_path]: - try: - os.remove(path) - except OSError: - pass # File may not exist or be locked - - -if __name__ == "__main__": - test_white_balance() diff --git a/scripts/smoke_verify.py b/scripts/smoke_verify.py deleted file mode 100644 index bf06886..0000000 --- a/scripts/smoke_verify.py +++ /dev/null @@ -1,79 +0,0 @@ -import sys -import importlib.resources - - -def check_imports(): - print("Checking imports...") - try: - import faststack - import faststack.ui - import faststack.io - import faststack.imaging - import faststack.app - - print(" [OK] Imports successful") - except ImportError as e: - print(f" [FAIL] Import failed: {e}") - return False - return True - - -def check_cli(): - print("Checking CLI entry point...") - try: - from faststack.app import cli - - if not callable(cli): - print(" [FAIL] faststack.app.cli is not callable") - return False - print(" [OK] faststack.app.cli found") - except ImportError: - print(" [FAIL] Could not import faststack.app.cli") - return False - except Exception as e: - print(f" [FAIL] Error checking CLI: {e}") - return False - return True - - -def check_assets(): - print("Checking assets (QML files)...") - try: - # For Python 3.9+ standard library importlib.resources - # We look for any .qml file in faststack package - qml_files = list(importlib.resources.files("faststack").rglob("*.qml")) - count = len(qml_files) - if count > 0: - print(f" [OK] Found {count} QML files") - for p in qml_files[:3]: - print(f" - {p.name}") - else: - print(" [FAIL] No QML files found in package resources!") - print( - " (Did you include package_data in pyproject.toml / MANIFEST.in?)" - ) - return False - except Exception as e: - print(f" [FAIL] Asset check failed: {e}") - return False - return True - - -def main(): - print("=== FastStack Smoke Verification ===") - print(f"Python: {sys.version}") - - if not check_imports(): - sys.exit(1) - - if not check_cli(): - sys.exit(1) - - if not check_assets(): - sys.exit(1) - - print("\n[SUCCESS] faststack package seems healthy.") - - -if __name__ == "__main__": - main() From e3dd937ed7da2d4bfb80e92b98a54d66a9e3e120 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 24 Feb 2026 18:50:04 -0500 Subject: [PATCH 02/10] fix a bug that caused index to be off by 1 --- faststack/app.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index cb2790c..7de5ba9 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -4294,16 +4294,21 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: del self.image_files[idx] # Reposition current_index immediately (fast, in-memory only) + deleted_set = set(sorted_indices) if not self.image_files: self.current_index = 0 - else: + elif previous_index in deleted_set: + # Current image was deleted → stay at same position (shows next image) or clamp self.current_index = min(previous_index, len(self.image_files) - 1) + else: + # Current image survived → shift index down for each deletion before it + shift = sum(1 for d in sorted_indices if d < previous_index) + self.current_index = max(0, min(previous_index - shift, len(self.image_files) - 1)) # Adjust batch index ranges to account for removed entries. # Deleting index d shifts every index > d down by one. Without this, # batches that sit above any deleted image reference the wrong files. if self.batches: - deleted_set = set(sorted_indices) deleted_ascending = sorted(sorted_indices) def _shift(orig_idx: int) -> int: From a13fa59023446fadbfc1de83c0f783874ca94990 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 24 Feb 2026 22:59:59 -0500 Subject: [PATCH 03/10] fix bug with batches --- faststack/app.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/faststack/app.py b/faststack/app.py index 7de5ba9..45a9e30 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -4305,6 +4305,12 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: shift = sum(1 for d in sorted_indices if d < previous_index) self.current_index = max(0, min(previous_index - shift, len(self.image_files) - 1)) + # Save batch state before mutation so _rollback_ui_items can restore it + # for any delete type (loupe, grid, batch). batch delete_batch_images() + # will overwrite saved_batches with the same pre-mutation value anyway. + pre_batch_snapshot = [b[:] for b in self.batches] if self.batches else None + pre_batch_start_snapshot = self.batch_start_index if self.batches else None + # Adjust batch index ranges to account for removed entries. # Deleting index d shifts every index > d down by one. Without this, # batches that sit above any deleted image reference the wrong files. @@ -4413,6 +4419,8 @@ def _shift(orig_idx: int) -> int: cancel_event=cancel_event, previous_index=previous_index, images_to_delete=images_to_delete, + saved_batches=pre_batch_snapshot, + saved_batch_start_index=pre_batch_start_snapshot, ) # Add single placeholder undo entry per job @@ -4501,7 +4509,7 @@ def delete_batch_images(self): indices_to_delete.add(i) # 2. Save batch state for rollback, then clear optimistically - saved_batches = list(self.batches) + saved_batches = [b[:] for b in self.batches] saved_batch_start = self.batch_start_index # 3. Call unified engine From c53ee36bc8c30d621699720ca7aea9854d4048cf Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 24 Feb 2026 23:16:44 -0500 Subject: [PATCH 04/10] The pending_delete branch in undo_delete duplicated the rollback logic from _rollback_ui_items but missed the batch restore block. --- ChangeLog.md | 1 + faststack/app.py | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/ChangeLog.md b/ChangeLog.md index c77e639..a30cf56 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -11,6 +11,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document - Enhanced metadata display with camera-style shutter speed formatting. - Added new thumbnail badges for Backups (Bk) and Developed (D) variants. - Improved cache eviction handling and thread-safety for concurrent operations. +- Fixed a bug where deleting an image could mess up the batch selection ranges if the delete was cancelled, failed, or undone. ## 1.5.8 (2026-02-13) diff --git a/faststack/app.py b/faststack/app.py index 45a9e30..94ab7e7 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -4715,6 +4715,11 @@ def undo_delete(self): if self.image_files: self.prefetcher.update_prefetch(self.current_index) self._rebuild_path_to_index() + # Restore batch state that was shifted during _delete_indices + if job.saved_batches and removed_items: + self.batches = job.saved_batches + self.batch_start_index = job.saved_batch_start_index + self._invalidate_batch_cache() self.sync_ui_state() count = len(removed_items) From f29fed8cd043a01a1df5003a720f02101fb67494 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 3 Mar 2026 20:08:09 -0500 Subject: [PATCH 05/10] Support partial batch rollback and validate deletion indices --- faststack/app.py | 55 ++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 94ab7e7..76c979f 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -4164,6 +4164,41 @@ def _on_perm_done(future): self._rebuild_path_to_index() self.sync_ui_state() + @staticmethod + def _recompute_batches_after_deletions( + saved_batches: List[List[int]], still_deleted: List[int] + ) -> List[List[int]]: + """Return a copy of saved_batches with index spans adjusted for still_deleted. + + Used during partial rollbacks: start from the pre-delete snapshot and + re-apply only the deletions that were not reversed. + """ + if not still_deleted: + return [b[:] for b in saved_batches] + + deleted_set = set(still_deleted) + + def _shift(orig_idx: int) -> int: + return orig_idx - sum(1 for d in still_deleted if d < orig_idx) + + new_batches = [] + for b_start, b_end in saved_batches: + first_ok = next( + (i for i in range(b_start, b_end + 1) if i not in deleted_set), + None, + ) + if first_ok is None: + continue # whole batch still deleted — drop it + last_ok = next( + (i for i in range(b_end, b_start - 1, -1) if i not in deleted_set), + None, + ) + ns = _shift(first_ok) + ne = _shift(last_ok) + if ns <= ne: + new_batches.append([ns, ne]) + return new_batches + def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> None: """Restore items to the UI list in correct order.""" # Sort reverse by index to insert correctly @@ -4193,8 +4228,18 @@ def _rollback_ui_items(self, items: List[Tuple[int, Any]], job: DeleteJob) -> No # Restore saved batch state if present if job.saved_batches and items: - self.batches = job.saved_batches - self.batch_start_index = job.saved_batch_start_index + original = {idx for idx, _ in job.removed_items} + restored = {idx for idx, _ in items} + if restored == original: + # Full rollback: restore pre-delete snapshot directly + self.batches = [b[:] for b in job.saved_batches] + self.batch_start_index = job.saved_batch_start_index + else: + # Partial rollback: re-apply the deletions that were not reversed + still_deleted = sorted(original - restored) + self.batches = self._recompute_batches_after_deletions( + job.saved_batches, still_deleted + ) self._invalidate_batch_cache() def _schedule_delete_refresh(self) -> None: @@ -4286,6 +4331,7 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: for idx in sorted(sorted_indices) if 0 <= idx < len(self.image_files) ] + original_count = len(self.image_files) previous_index = self.current_index # Remove from in-memory list immediately for instant visual feedback @@ -4294,7 +4340,8 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: del self.image_files[idx] # Reposition current_index immediately (fast, in-memory only) - deleted_set = set(sorted_indices) + validated_sorted = sorted(i for i in sorted_indices if 0 <= i < original_count) + deleted_set = set(validated_sorted) if not self.image_files: self.current_index = 0 elif previous_index in deleted_set: @@ -4302,7 +4349,7 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: self.current_index = min(previous_index, len(self.image_files) - 1) else: # Current image survived → shift index down for each deletion before it - shift = sum(1 for d in sorted_indices if d < previous_index) + shift = sum(1 for d in validated_sorted if d < previous_index) self.current_index = max(0, min(previous_index - shift, len(self.image_files) - 1)) # Save batch state before mutation so _rollback_ui_items can restore it From feec0464748f02e46d2e1ac1f9a88806d81038c2 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Tue, 3 Mar 2026 21:05:02 -0500 Subject: [PATCH 06/10] Add support for a todo flag --- faststack/app.py | 30 +++++++++++++++++++ faststack/models.py | 2 ++ faststack/qml/FilterDialog.qml | 10 +++++++ faststack/qml/Main.qml | 6 ++++ faststack/qml/ThumbnailGridView.qml | 1 + faststack/qml/ThumbnailTile.qml | 30 +++++++++++++++++-- .../tests/thumbnail_view/test_folder_stats.py | 4 +-- faststack/thumbnail_view/folder_stats.py | 24 ++++++++------- faststack/thumbnail_view/model.py | 9 ++++++ faststack/ui/keystrokes.py | 1 + faststack/ui/provider.py | 12 ++++++++ 11 files changed, 114 insertions(+), 15 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 76c979f..4ab55a1 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -2076,6 +2076,7 @@ def _get_metadata_dict(self, stem: str) -> dict: "edited": getattr(meta, "edited", False), "restacked": getattr(meta, "restacked", False), "favorite": getattr(meta, "favorite", False), + "todo": getattr(meta, "todo", False), } except Exception as e: # Broad catch for UI plumbing - don't crash grid view log.debug("Failed to get metadata for %s: %s", stem, e) @@ -2085,6 +2086,7 @@ def _get_metadata_dict(self, stem: str) -> dict: "edited": False, "restacked": False, "favorite": False, + "todo": False, } def _get_bulk_metadata_map(self) -> Dict[str, dict]: @@ -2099,6 +2101,7 @@ def _get_bulk_metadata_map(self) -> Dict[str, dict]: "edited": getattr(meta, "edited", False), "restacked": getattr(meta, "restacked", False), "favorite": getattr(meta, "favorite", False), + "todo": getattr(meta, "todo", False), } except Exception as e: log.warning("Failed to build bulk metadata map: %s", e) @@ -2162,6 +2165,31 @@ def toggle_uploaded(self): self.update_status_message(f"Marked as {status}") log.info("Toggled uploaded flag to %s for %s", meta.uploaded, stem) + def toggle_todo(self): + """Toggle todo flag for current image.""" + if not self.image_files or self.current_index >= len(self.image_files): + return + + from datetime import datetime + + today = datetime.now().strftime("%Y-%m-%d") + stem = self.image_files[self.current_index].path.stem + meta = self.sidecar.get_metadata(stem) + + meta.todo = not getattr(meta, "todo", False) + if meta.todo: + meta.todo_date = today + else: + meta.todo_date = None + + self.sidecar.save() + self._metadata_cache_index = (-1, -1) + self.dataChanged.emit() + self.sync_ui_state() + status = "todo" if meta.todo else "not todo" + self.update_status_message(f"Marked as {status}") + log.info("Toggled todo flag to %s for %s", meta.todo, stem) + def toggle_edited(self): """Toggle edited flag for current image.""" if not self.image_files or self.current_index >= len(self.image_files): @@ -2308,6 +2336,8 @@ def get_current_metadata(self) -> Dict: "restacked": meta.restacked, "restacked_date": meta.restacked_date or "", "favorite": meta.favorite, + "todo": getattr(meta, "todo", False), + "todo_date": getattr(meta, "todo_date", None) or "", "stack_info_text": stack_info, "batch_info_text": batch_info, } diff --git a/faststack/models.py b/faststack/models.py index 776e619..1207245 100644 --- a/faststack/models.py +++ b/faststack/models.py @@ -72,6 +72,8 @@ class EntryMetadata: restacked: bool = False restacked_date: Optional[str] = None favorite: bool = False + todo: bool = False + todo_date: Optional[str] = None @dataclasses.dataclass diff --git a/faststack/qml/FilterDialog.qml b/faststack/qml/FilterDialog.qml index 8246650..a27e813 100644 --- a/faststack/qml/FilterDialog.qml +++ b/faststack/qml/FilterDialog.qml @@ -110,6 +110,14 @@ Dialog { Material.accent: "#ce93d8" onCheckedChanged: _collectFlags() } + CheckBox { + id: cbTodo + text: "Todo" + checked: false + Material.foreground: filterDialog.textColor + Material.accent: "#64B5F6" + onCheckedChanged: _collectFlags() + } CheckBox { id: cbFavorite text: "Favorite" @@ -136,6 +144,7 @@ Dialog { if (cbStacked.checked) flags.push("stacked") if (cbEdited.checked) flags.push("edited") if (cbRestacked.checked) flags.push("restacked") + if (cbTodo.checked) flags.push("todo") if (cbFavorite.checked) flags.push("favorite") filterDialog.filterFlags = flags } @@ -156,6 +165,7 @@ Dialog { cbStacked.checked = currentFlags.indexOf("stacked") >= 0 cbEdited.checked = currentFlags.indexOf("edited") >= 0 cbRestacked.checked = currentFlags.indexOf("restacked") >= 0 + cbTodo.checked = currentFlags.indexOf("todo") >= 0 cbFavorite.checked = currentFlags.indexOf("favorite") >= 0 filterField.forceActiveFocus() diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index ab535ca..3ce9ec8 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -1015,6 +1015,11 @@ ApplicationWindow { color: "lightgreen" visible: uiState ? (uiState.imageCount > 0 && uiState.isUploaded) : false } + Label { + text: uiState ? ` Todo since ${uiState.todoDate}` : "" + color: "#64B5F6" + visible: uiState ? (uiState.imageCount > 0 && uiState.isTodo) : false + } Label { text: uiState ? ` Edited on ${uiState.editedDate}` : "" color: "lightgreen" @@ -1374,6 +1379,7 @@ ApplicationWindow { "  }: End current batch
" + "  \\: Clear all batches

" + "Flag Toggles:
" + + "  D: Toggle todo flag
" + "  F: Toggle favorite flag
" + "  U: Toggle uploaded flag
" + "  Ctrl+E: Toggle edited flag
" + diff --git a/faststack/qml/ThumbnailGridView.qml b/faststack/qml/ThumbnailGridView.qml index bff21ce..90df553 100644 --- a/faststack/qml/ThumbnailGridView.qml +++ b/faststack/qml/ThumbnailGridView.qml @@ -59,6 +59,7 @@ Item { tileIsEdited: isEdited || false tileIsRestacked: isRestacked || false tileIsFavorite: isFavorite || false + tileIsTodo: isTodo || false tileIsInBatch: isInBatch || false tileIsCurrent: isCurrent || false tileThumbnailSource: thumbnailSource || "" diff --git a/faststack/qml/ThumbnailTile.qml b/faststack/qml/ThumbnailTile.qml index be2de0c..8b31fc4 100644 --- a/faststack/qml/ThumbnailTile.qml +++ b/faststack/qml/ThumbnailTile.qml @@ -16,6 +16,7 @@ Item { property bool tileIsEdited: false property bool tileIsRestacked: false property bool tileIsFavorite: false + property bool tileIsTodo: false property bool tileIsInBatch: false property bool tileIsCurrent: false property string tileThumbnailSource: "" @@ -45,6 +46,7 @@ Item { // Flag colors for badges property color stackedColor: "#FF9800" // Orange for stacked (S) property color uploadedColor: "#4CAF50" // Green for uploaded (U) + property color todoColor: "#2196F3" // Blue for todo (D) property color editedColor: "#FFEB3B" // Yellow for edited (E) property color restackedColor: "#FF9800" // Orange for restacked (R) property color favoriteColor: "#FFD700" // Gold for favorite (F) @@ -203,6 +205,22 @@ Item { } } + // Todo badge (D) - Blue + Rectangle { + visible: tile.tileIsTodo + width: 18 + height: 18 + radius: 3 + color: todoColor + Text { + anchors.centerIn: parent + text: "D" + font.pixelSize: 11 + font.bold: true + color: "white" + } + } + // Favorite badge (F) - Gold Rectangle { visible: tile.tileIsFavorite @@ -429,7 +447,7 @@ Item { property string numFont: "Consolas, Monaco, monospace" property int numSize: 11 - // Coverage sparkline (dual-channel: upload green, stack orange) + // Coverage sparkline (triple-channel: upload green, stack orange, todo red) Row { id: sparklineRow anchors.horizontalCenter: parent.horizontalCenter @@ -451,7 +469,7 @@ Item { color: tile.counterUploadedCol opacity: modelData[0] * 0.9 + 0.1 // 0.1 base opacity, up to 1.0 } - // Stack bar (orange) - bottom + // Stack bar (orange) - middle Rectangle { width: 3 height: 2 @@ -459,6 +477,14 @@ Item { color: tile.counterStackedCol opacity: modelData[1] * 0.9 + 0.1 // 0.1 base opacity, up to 1.0 } + // Todo bar (red) - bottom + Rectangle { + width: 3 + height: 2 + radius: 0.5 + color: "#F44336" + opacity: modelData[2] * 0.9 + 0.1 // 0.1 base opacity, up to 1.0 + } } } } diff --git a/faststack/tests/thumbnail_view/test_folder_stats.py b/faststack/tests/thumbnail_view/test_folder_stats.py index 088ee90..90866e9 100644 --- a/faststack/tests/thumbnail_view/test_folder_stats.py +++ b/faststack/tests/thumbnail_view/test_folder_stats.py @@ -270,7 +270,7 @@ def test_single_file_uploaded(self): buckets = _compute_coverage_buckets(jpg_files, entries, num_buckets=1) assert len(buckets) == 1 - assert buckets[0] == (1.0, 0.0) # uploaded, not stacked + assert buckets[0] == (1.0, 0.0, 0.0) # uploaded, not stacked, not todo def test_single_file_stacked(self): """Test with single stacked file.""" @@ -280,7 +280,7 @@ def test_single_file_stacked(self): buckets = _compute_coverage_buckets(jpg_files, entries, num_buckets=1) assert len(buckets) == 1 - assert buckets[0] == (0.0, 1.0) # not uploaded, stacked + assert buckets[0] == (0.0, 1.0, 0.0) # not uploaded, stacked, not todo def test_even_distribution(self): """Test even distribution across buckets.""" diff --git a/faststack/thumbnail_view/folder_stats.py b/faststack/thumbnail_view/folder_stats.py index 33f1c99..8711f06 100644 --- a/faststack/thumbnail_view/folder_stats.py +++ b/faststack/thumbnail_view/folder_stats.py @@ -25,10 +25,10 @@ class FolderStats: # Named 'jpg_count' for historical reasons; displayed as "IMG" in UI jpg_count: int = 0 raw_count: int = 0 - # Coverage sparkline data: list of (upload_ratio, stack_ratio) tuples per bucket + # Coverage sparkline data: list of (upload_ratio, stack_ratio, todo_ratio) tuples per bucket # Each ratio is 0.0-1.0, representing the fraction of JPGs in that bucket # that have the flag set. Empty list if no faststack.json or no JPGs. - coverage_buckets: list[tuple[float, float]] = field(default_factory=list) + coverage_buckets: list[tuple[float, float, float]] = field(default_factory=list) # Cache by (folder_path, json_mtime_ns, folder_mtime_ns) to avoid re-parsing during scroll @@ -216,9 +216,9 @@ def _parse_faststack_json(json_path: Path) -> Optional[FolderStats]: def _compute_coverage_buckets( jpg_files: list, entries: Dict[str, dict], num_buckets: int = 40 ) -> list: - """Compute coverage sparkline buckets for uploads and stacks. + """Compute coverage sparkline buckets for uploads, stacks, and todos. - Returns a list of (upload_ratio, stack_ratio) tuples, one per bucket. + Returns a list of (upload_ratio, stack_ratio, todo_ratio) tuples, one per bucket. Each ratio is 0.0-1.0, representing the fraction of JPGs in that bucket with the respective flag set. @@ -228,7 +228,7 @@ def _compute_coverage_buckets( num_buckets: Number of buckets to divide files into (default 40) Returns: - List of (upload_ratio, stack_ratio) tuples, or empty list if no JPGs. + List of (upload_ratio, stack_ratio, todo_ratio) tuples, or empty list if no JPGs. """ if not jpg_files: return [] @@ -238,8 +238,8 @@ def _compute_coverage_buckets( num_buckets = total_files # Single-pass accumulation into buckets to avoid redundant list processing - # Each entry is [uploaded_count, stacked_count, total_in_bucket] - accumulators = [[0, 0, 0] for _ in range(num_buckets)] + # Each entry is [uploaded_count, stacked_count, todo_count, total_in_bucket] + accumulators = [[0, 0, 0, 0] for _ in range(num_buckets)] for i, filename in enumerate(jpg_files): # Map file index to bucket index using floor division @@ -254,16 +254,18 @@ def _compute_coverage_buckets( accumulators[bucket_idx][0] += 1 if meta.get("stacked", False): accumulators[bucket_idx][1] += 1 + if meta.get("todo", False): + accumulators[bucket_idx][2] += 1 - accumulators[bucket_idx][2] += 1 + accumulators[bucket_idx][3] += 1 # Convert counts to ratios buckets = [] - for uploaded, stacked, count in accumulators: + for uploaded, stacked, todo, count in accumulators: if count == 0: - buckets.append((0.0, 0.0)) + buckets.append((0.0, 0.0, 0.0)) else: - buckets.append((uploaded / count, stacked / count)) + buckets.append((uploaded / count, stacked / count, todo / count)) return buckets diff --git a/faststack/thumbnail_view/model.py b/faststack/thumbnail_view/model.py index 951704a..3ce0fa9 100644 --- a/faststack/thumbnail_view/model.py +++ b/faststack/thumbnail_view/model.py @@ -80,6 +80,7 @@ class ThumbnailEntry: is_edited: bool = False is_restacked: bool = False is_favorite: bool = False + is_todo: bool = False folder_stats: Optional[FolderStats] = None has_backups: bool = False has_developed: bool = False @@ -116,6 +117,7 @@ class ThumbnailModel(QAbstractListModel): IsFavoriteRole = Qt.ItemDataRole.UserRole + 17 HasBackupsRole = Qt.ItemDataRole.UserRole + 18 HasDevelopedRole = Qt.ItemDataRole.UserRole + 19 + IsTodoRole = Qt.ItemDataRole.UserRole + 20 # Signal emitted when a thumbnail is ready (id = "{size}/{path_hash}/{mtime_ns}") thumbnailReady = Signal(str) @@ -232,6 +234,8 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole): return entry.is_restacked elif role == self.IsFavoriteRole: return entry.is_favorite + elif role == self.IsTodoRole: + return entry.is_todo elif role == self.IsInBatchRole: # Check if this row's corresponding loupe index is in any batch if self._get_batch_indices and not entry.is_folder: @@ -288,6 +292,7 @@ def roleNames(self) -> Dict[int, bytes]: self.IsFavoriteRole: b"isFavorite", self.HasBackupsRole: b"hasBackups", self.HasDevelopedRole: b"hasDeveloped", + self.IsTodoRole: b"isTodo", } def _get_thumbnail_source( @@ -600,6 +605,7 @@ def _add_images_to_entries( is_edited = False is_restacked = False is_favorite = False + is_todo = False if metadata_map: meta = metadata_map.get(img.path.stem, {}) @@ -608,6 +614,7 @@ def _add_images_to_entries( is_edited = meta.get("edited", False) is_restacked = meta.get("restacked", False) is_favorite = meta.get("favorite", False) + is_todo = meta.get("todo", False) elif self._get_metadata: try: meta = self._get_metadata(img.path.stem) @@ -617,6 +624,7 @@ def _add_images_to_entries( is_edited = meta.get("edited", False) is_restacked = meta.get("restacked", False) is_favorite = meta.get("favorite", False) + is_todo = meta.get("todo", False) else: log.debug( "Metadata for %s is not a dict: %r", img.path.stem, meta @@ -637,6 +645,7 @@ def _add_images_to_entries( is_edited=is_edited, is_restacked=is_restacked, is_favorite=is_favorite, + is_todo=is_todo, has_backups=has_backups, has_developed=has_developed, mtime_ns=mtime_ns, diff --git a/faststack/ui/keystrokes.py b/faststack/ui/keystrokes.py index d65f4d1..7807528 100644 --- a/faststack/ui/keystrokes.py +++ b/faststack/ui/keystrokes.py @@ -39,6 +39,7 @@ def __init__(self, controller): # Toggle flags Qt.Key_U: "toggle_uploaded", Qt.Key_F: "toggle_favorite", + Qt.Key_D: "toggle_todo", Qt.Key_I: "show_exif_dialog", # Actions Qt.Key_Enter: "launch_helicon", diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index 762f6a2..c557427 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -455,6 +455,18 @@ def uploadedDate(self): return "" return self.app_controller.get_current_metadata().get("uploaded_date", "") + @Property(bool, notify=metadataChanged) + def isTodo(self): + if not self.app_controller.image_files: + return False + return self.app_controller.get_current_metadata().get("todo", False) + + @Property(str, notify=metadataChanged) + def todoDate(self): + if not self.app_controller.image_files: + return "" + return self.app_controller.get_current_metadata().get("todo_date", "") + @Property(str, notify=metadataChanged) def batchInfoText(self): if not self.app_controller.image_files: From 5e86ea2a607b64d858ce2c3b8cc42cca52d5d257 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 5 Mar 2026 13:27:49 -0500 Subject: [PATCH 07/10] Fix minor bug with cache thrashing error message --- faststack/app.py | 4 ++-- faststack/imaging/cache.py | 20 ++++++++----------- faststack/qml/Main.qml | 4 +++- .../tests/test_cache_replacement_callback.py | 15 ++++++++------ 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 4ab55a1..0b9b430 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -4392,7 +4392,7 @@ def _delete_indices(self, indices: List[int], action_type: str) -> dict: # Deleting index d shifts every index > d down by one. Without this, # batches that sit above any deleted image reference the wrong files. if self.batches: - deleted_ascending = sorted(sorted_indices) + deleted_ascending = sorted(validated_sorted) def _shift(orig_idx: int) -> int: return orig_idx - sum(1 for d in deleted_ascending if d < orig_idx) @@ -6438,7 +6438,7 @@ def execute_crop(self): @Slot() def auto_levels(self): """Calculates and applies auto levels (preview only). Returns False if skipped.""" - if not self.image_files: + if not self.image_files or self.current_index >= len(self.image_files): self.update_status_message("No image to adjust") return False diff --git a/faststack/imaging/cache.py b/faststack/imaging/cache.py index 1e190e0..5df6429 100644 --- a/faststack/imaging/cache.py +++ b/faststack/imaging/cache.py @@ -309,15 +309,17 @@ def evict_paths(self, paths: list[Union[Path, str]]): if str(key).startswith(prefix_tuple): keys_to_remove.append(key) - # 4. Remove keys + # 4. Remove keys — capture eviction callbacks but discard them, + # since these are intentional removals, not LRU pressure. + # We use _pending_callbacks to collect (and then drop) rather than + # setting on_evict=None, which would race with closures that read + # on_evict outside the lock. removed_bytes = 0 - pending_callbacks = [] - self._pending_callbacks = pending_callbacks + _discard = [] + self._pending_callbacks = _discard self._pending_callbacks_owner = threading.get_ident() try: for k in keys_to_remove: - # Use self.pop (which calls __delitem__) to trigger eviction callbacks. - # It will re-acquire our RLock safely. val = self.pop(k, None) if val is not None: try: @@ -328,13 +330,7 @@ def evict_paths(self, paths: list[Union[Path, str]]): finally: self._pending_callbacks = None self._pending_callbacks_owner = None - - # Execute all captured eviction callbacks OUTSIDE the lock - for callback in pending_callbacks: - try: - callback() - except Exception: - log.exception("Error in eviction callback") + # _discard is intentionally not executed if keys_to_remove: log.info( diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 3ce9ec8..e09063d 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -1641,7 +1641,9 @@ ApplicationWindow { wrapMode: Text.WrapAnywhere readOnly: true selectByMouse: true - background: null + background: Rectangle { + color: "transparent" + } } } } diff --git a/faststack/tests/test_cache_replacement_callback.py b/faststack/tests/test_cache_replacement_callback.py index faacb7e..f269afb 100644 --- a/faststack/tests/test_cache_replacement_callback.py +++ b/faststack/tests/test_cache_replacement_callback.py @@ -158,8 +158,8 @@ def test_on_evict_fires_for_both_overflow_and_replacement(): # ── evict_paths + tombstones ──────────────────────────────────────── -def test_evict_paths_fires_callbacks(): - """evict_paths() should trigger on_evict for each removed key.""" +def test_evict_paths_suppresses_callbacks(): + """evict_paths() should NOT trigger on_evict (intentional removal, not LRU).""" evicted = [] cache = _make_cache(10_000, lambda k, v: evicted.append((k, v))) @@ -171,10 +171,13 @@ def test_evict_paths_fires_callbacks(): cache.evict_paths([Path("photo.jpg")]) - evicted_keys = {k for k, _ in evicted} - assert "photo.jpg::0" in evicted_keys - assert "photo.jpg::1" in evicted_keys - assert "other.jpg::0" not in evicted_keys + # Keys should be removed from cache + assert "photo.jpg::0" not in cache + assert "photo.jpg::1" not in cache + assert "other.jpg::0" in cache + + # But on_evict should NOT have been called (intentional removal) + assert len(evicted) == 0 def test_evict_paths_tombstone_blocks_reinsert(): From 6ecac3942533d8f57ba357a3e6e0b80b31f29d25 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Thu, 5 Mar 2026 15:46:57 -0500 Subject: [PATCH 08/10] Fix cache even more --- faststack/app.py | 61 ++++++++++++++++++---- faststack/imaging/cache.py | 102 ++++++++++++++++++++++++++++++++----- 2 files changed, 140 insertions(+), 23 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 0b9b430..f0f2244 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -16,6 +16,7 @@ import uuid import bisect import functools +from collections import deque # Must set before importing PySide6 os.environ["QT_LOGGING_RULES"] = "qt.qpa.mime.warning=false" @@ -268,7 +269,7 @@ def __init__( # Cache Warning State self._last_cache_warning_time = 0 self._eviction_lock = threading.Lock() - self._eviction_timestamps = [] # List of eviction timestamps for rate detection + self._eviction_timestamps: deque[float] = deque() # Rolling window for rate detection self.display_ready = False # Track if display size has been reported self.pending_prefetch_index: Optional[int] = None # Deferred prefetch index @@ -5106,17 +5107,55 @@ def empty_recycle_bin(self): clear_raw_count_cache() log.info("Emptied recycle bins and cleared delete history") - def _on_cache_evict(self, key, value): - """Callback for when the image cache evicts an item.""" + def _on_cache_evict(self, key, value, info=None): + """Callback for when the image cache evicts an item. + + Args: + key: Cache key that was evicted. + value: Cached value that was evicted. + info: Optional dict with eviction context captured at eviction time: + reason ("pressure"|"replace"|"manual"), usage_bytes, max_bytes, + entry_count, thread_id. + """ + reason = info.get("reason", "unknown") if info else "unknown" + + # Only count capacity-pressure evictions toward thrashing detection. + # Replacements and manual removals (pop_path, popitem resize) are not + # indicators of cache size being too small. + if reason != "pressure": + if self.debug_cache: + log.debug( + "Cache evict (skipped for thrash): reason=%s key=%s", + reason, + key, + ) + return + + # Use usage captured at eviction time (inside the lock), not current + # currsize which may be stale if clear()/evict_paths() ran between + # the eviction and this callback executing outside the lock. + eviction_usage = info.get("usage_bytes", 0) if info else 0 + eviction_max = info.get("max_bytes", 1) if info else 1 + + if self.debug_cache: + log.debug( + "Cache evict (pressure): key=%s usage=%.2fMB/%.2fMB " + "entries=%d thread=%s", + key, + eviction_usage / (1024**2), + eviction_max / (1024**2), + info.get("entry_count", -1) if info else -1, + info.get("thread_id", "?") if info else "?", + ) + now = time.time() with self._eviction_lock: - # 1. Record eviction timestamp / prune + # 1. Record eviction timestamp / prune oldest outside window self._eviction_timestamps.append(now) cutoff = now - CACHE_THRASH_WINDOW_SECS - self._eviction_timestamps = [ - t for t in self._eviction_timestamps if t > cutoff - ] + while self._eviction_timestamps and self._eviction_timestamps[0] <= cutoff: + self._eviction_timestamps.popleft() # 2. Check for thrashing (e.g., > threshold evictions in window) if len(self._eviction_timestamps) > CACHE_THRASH_THRESHOLD: @@ -5125,11 +5164,11 @@ def _on_cache_evict(self, key, value): self._last_cache_warning_time = now self._has_warned_cache_full = True - # UI update logic - used_gb = self.image_cache.currsize / (1024**3) - max_gb = self.image_cache.max_bytes / (1024**3) + # Use captured usage from eviction time for accurate reporting + used_gb = eviction_usage / (1024**3) + max_gb = eviction_max / (1024**3) - # Include key/value summary to fix lint error and provide context + # Include key/value summary for context val_summary = "" if hasattr(value, "width") and hasattr(value, "height"): val_summary = f" ({value.width}x{value.height})" diff --git a/faststack/imaging/cache.py b/faststack/imaging/cache.py index 5df6429..7692c43 100644 --- a/faststack/imaging/cache.py +++ b/faststack/imaging/cache.py @@ -1,6 +1,8 @@ """Byte-aware LRU cache for storing decoded image data (CPU and GPU).""" +import inspect import logging +from collections import deque from pathlib import Path from typing import Any, Callable, Optional, Union import time @@ -51,9 +53,10 @@ def __init__( self, max_bytes: int, size_of: Callable[[Any], int] = get_decoded_image_size, - on_evict: Optional[Callable[[Any, Any], None]] = None, + on_evict: Optional[Callable[..., None]] = None, ): super().__init__(maxsize=max_bytes, getsizeof=size_of) + self._on_evict_arity = self._detect_arity(on_evict) self.on_evict = on_evict # RLock is required: __setitem__ holds _lock and calls super().__setitem__(), # which may call our overridden popitem() for LRU eviction. A non-reentrant @@ -66,6 +69,10 @@ def __init__( self._tombstone_expiry: dict[str, float] = {} self._pending_callbacks: Optional[list[Callable[[], None]]] = None self._pending_callbacks_owner: Optional[int] = None + # Flag: True when __delitem__ is being called from __setitem__'s capacity + # eviction path (popitem), as opposed to targeted removal (pop_path, evict_paths). + self._pressure_eviction_active = False + self._pressure_eviction_owner: Optional[int] = None log.info( f"Initialized byte-aware LRU cache with {max_bytes / 1024**2:.2f} MB capacity." ) @@ -82,6 +89,47 @@ def max_bytes(self, value: int) -> None: self.maxsize = v log.debug(f"Cache max_bytes updated to {v / 1024**2:.2f} MB") + @staticmethod + def _detect_arity(callback: Optional[Callable]) -> int: + """Detect whether callback accepts 2 args (key, value) or 3 (key, value, info).""" + if callback is None: + return 2 + try: + sig = inspect.signature(callback) + # Count parameters that can accept positional args + positional = sum( + 1 + for p in sig.parameters.values() + if p.kind + in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + and p.default is inspect.Parameter.empty + ) + return 3 if positional >= 3 else 2 + except (ValueError, TypeError): + return 2 + + def _fire_evict(self, key: Any, value: Any, info: dict) -> None: + """Invoke on_evict, dispatching by detected arity.""" + if not self.on_evict: + return + if self._on_evict_arity >= 3: + self.on_evict(key, value, info) + else: + self.on_evict(key, value) + + def _build_eviction_info(self, reason: str, pre_usage: int) -> dict: + """Build eviction context dict captured at eviction time (inside lock).""" + return { + "reason": reason, + "usage_bytes": pre_usage, + "max_bytes": self.maxsize, + "entry_count": len(self), + "thread_id": threading.get_ident(), + } + def __setitem__(self, key, value): pending_callbacks = [] with self._lock: @@ -122,6 +170,9 @@ def __setitem__(self, key, value): # callbacks triggered by popitem() -> __delitem__(). self._pending_callbacks = pending_callbacks self._pending_callbacks_owner = threading.get_ident() + # Mark that any __delitem__ calls from here are capacity-pressure evictions + self._pressure_eviction_active = True + self._pressure_eviction_owner = threading.get_ident() try: super().__setitem__(key, value) @@ -129,13 +180,16 @@ def __setitem__(self, key, value): # for the old value, because cachetools.__setitem__ for replacements # does not call __delitem__ (it just overwrites the dict entry). if old_value is not _MISSING and self.on_evict: + info = self._build_eviction_info("replace", self.currsize) + info["inserting_key"] = str(key) - def _replace_cb(k=key, v=old_value): - if self.on_evict: - self.on_evict(k, v) + def _replace_cb(k=key, v=old_value, _info=info): + self._fire_evict(k, v, _info) pending_callbacks.append(_replace_cb) finally: + self._pressure_eviction_active = False + self._pressure_eviction_owner = None self._pending_callbacks = None self._pending_callbacks_owner = None @@ -173,16 +227,34 @@ def __delitem__(self, key): except KeyError: raise KeyError(key) from None + # Capture usage BEFORE deletion for accurate thrashing detection. + # After super().__delitem__, currsize will already be decremented. + pre_usage = self.currsize + + # Determine eviction reason based on calling context. + # This is a heuristic: _pressure_eviction_active is only True when + # __setitem__ is executing super().__setitem__(), which calls + # popitem() when currsize + new_size > maxsize (cachetools LRU). + # Any other path into __delitem__ — pop_path(), direct del, + # popitem() from manual cache resize — is classified as "manual" + # by design, since those are intentional removals, not capacity + # pressure indicating the cache is too small. + is_pressure = ( + self._pressure_eviction_active + and threading.get_ident() == self._pressure_eviction_owner + ) + reason = "pressure" if is_pressure else "manual" + super().__delitem__(key) log.debug( f"Removed item '{key}'. Cache size: {self.currsize / 1024**2:.2f} MB" ) if self.on_evict: + info = self._build_eviction_info(reason, pre_usage) - def _callback_func(k=key, v=value): - if self.on_evict: - self.on_evict(k, v) + def _callback_func(k=key, v=value, _info=info): + self._fire_evict(k, v, _info) # If we are inside a call that defers callbacks (like __setitem__ or evict_paths), # append to the shared list. @@ -206,15 +278,21 @@ def get(self, key, default=None): return super().get(key, default) def clear(self): - """Clear cache without triggering eviction callbacks.""" - # Temporarily disable callback to prevent "thrashing" warnings during mass clear + """Clear cache without triggering eviction callbacks. + + Uses _pending_callbacks discard pattern (same as evict_paths) rather + than setting on_evict=None, which would race with closures that read + on_evict outside the lock on other threads. + """ with self._lock: - saved_callback = self.on_evict - self.on_evict = None + _discard: list[Callable[[], None]] = [] + self._pending_callbacks = _discard + self._pending_callbacks_owner = threading.get_ident() try: super().clear() finally: - self.on_evict = saved_callback + self._pending_callbacks = None + self._pending_callbacks_owner = None def pop_path(self, path: Union[Path, str]): """Targeted invalidation of all generations for a given path. From d4125f7ec453aa1e23cefc74af243560e1172d8e Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 6 Mar 2026 00:40:16 -0500 Subject: [PATCH 09/10] Fix bug where recycle bin wouldn't show you the files being deleted --- faststack/qml/Main.qml | 6 ++++-- faststack/ui/provider.py | 7 +++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index e09063d..59e06b1 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -68,6 +68,7 @@ ApplicationWindow { } if (uiState && uiState.hasRecycleBinItems) { close.accepted = false + uiState.refreshRecycleBinStats() recycleBinCleanupDialog.open() } else { close.accepted = true @@ -1626,13 +1627,14 @@ ApplicationWindow { Behavior on height { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } } ScrollView { + id: detailsScrollView anchors.fill: parent anchors.margins: 8 - + TextArea { id: detailsText - width: parent.width + width: detailsScrollView.availableWidth text: uiState ? uiState.recycleBinDetailedText : "" color: root.isDarkTheme ? "#efefef" : "#333333" font.family: "Consolas, 'Courier New', monospace" diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index c557427..e379822 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -1616,6 +1616,13 @@ def hasRecycleBinItems(self): stats = self.app_controller.get_recycle_bin_stats() return len(stats) > 0 + @Slot() + def refreshRecycleBinStats(self): + """Notify QML that recycle-bin properties should be re-read.""" + self.recycleBinStatsTextChanged.emit() + self.recycleBinDetailedTextChanged.emit() + self.hasRecycleBinItemsChanged.emit() + @Slot() def cleanupRecycleBins(self): """Deletes all tracked recycle bins.""" From e6262a17f1a307908a9554805c90350790048c03 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Fri, 6 Mar 2026 12:49:03 -0500 Subject: [PATCH 10/10] Fix minor bugs --- faststack/app.py | 24 +++++++++++++++++------- faststack/qml/Main.qml | 2 +- faststack/ui/provider.py | 5 +---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index f0f2244..5436eb6 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -4421,6 +4421,16 @@ def _shift(orig_idx: int) -> int: self.batches = new_batches self._invalidate_batch_cache() + # Adjust batch_start_index for removed entries + if pre_batch_start_snapshot is not None: + if pre_batch_start_snapshot in deleted_set: + self.batch_start_index = None + else: + shifted = _shift(pre_batch_start_snapshot) + if shifted != self.batch_start_index: + self.batch_start_index = shifted + self._invalidate_batch_cache() + # Update UI immediately - this is fast since it just reads from memory # Check for existence, not truthiness (empty cache is falsy) if self.image_cache is not None: @@ -5107,17 +5117,17 @@ def empty_recycle_bin(self): clear_raw_count_cache() log.info("Emptied recycle bins and cleared delete history") - def _on_cache_evict(self, key, value, info=None): + def _on_cache_evict(self, key, value, info): """Callback for when the image cache evicts an item. Args: key: Cache key that was evicted. value: Cached value that was evicted. - info: Optional dict with eviction context captured at eviction time: + info: Dict with eviction context captured at eviction time: reason ("pressure"|"replace"|"manual"), usage_bytes, max_bytes, entry_count, thread_id. """ - reason = info.get("reason", "unknown") if info else "unknown" + reason = info.get("reason", "unknown") # Only count capacity-pressure evictions toward thrashing detection. # Replacements and manual removals (pop_path, popitem resize) are not @@ -5134,8 +5144,8 @@ def _on_cache_evict(self, key, value, info=None): # Use usage captured at eviction time (inside the lock), not current # currsize which may be stale if clear()/evict_paths() ran between # the eviction and this callback executing outside the lock. - eviction_usage = info.get("usage_bytes", 0) if info else 0 - eviction_max = info.get("max_bytes", 1) if info else 1 + eviction_usage = info.get("usage_bytes", 0) + eviction_max = info.get("max_bytes", 1) if self.debug_cache: log.debug( @@ -5144,8 +5154,8 @@ def _on_cache_evict(self, key, value, info=None): key, eviction_usage / (1024**2), eviction_max / (1024**2), - info.get("entry_count", -1) if info else -1, - info.get("thread_id", "?") if info else "?", + info.get("entry_count", -1), + info.get("thread_id", "?"), ) now = time.time() diff --git a/faststack/qml/Main.qml b/faststack/qml/Main.qml index 59e06b1..a088fc0 100644 --- a/faststack/qml/Main.qml +++ b/faststack/qml/Main.qml @@ -1017,7 +1017,7 @@ ApplicationWindow { visible: uiState ? (uiState.imageCount > 0 && uiState.isUploaded) : false } Label { - text: uiState ? ` Todo since ${uiState.todoDate}` : "" + text: uiState ? (uiState.todoDate ? ` Todo since ${uiState.todoDate}` : " Todo") : "" color: "#64B5F6" visible: uiState ? (uiState.imageCount > 0 && uiState.isTodo) : false } diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index e379822..b5e6ffc 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -1627,7 +1627,4 @@ def refreshRecycleBinStats(self): def cleanupRecycleBins(self): """Deletes all tracked recycle bins.""" self.app_controller.cleanup_recycle_bins() - - self.recycleBinStatsTextChanged.emit() - self.recycleBinDetailedTextChanged.emit() - self.hasRecycleBinItemsChanged.emit() + self.refreshRecycleBinStats()