:3\xe84\xb1\xb9\x01\x0f\x96*\xb7{\x5c\xb0\xad\xf8mgZ\x9d\xf6qX?\x1a\
-\xe8I\xfc/\x82z\x18p\xc1\xb6X\x91\x1e\x0f\x97j\
-\x94\x0c\x83\x92\x10\x14\x84\xc0\x14\x02\xa1\xc5\x9f\x19\xc2\x02\
-\xdaS\xfe$Q[\xf9Y\xff9{5-\xe5w\xf0\
-\xb5\x06\xc6\x9c\x1c\xda0L\x0e\x16J\xdcY(S\x10\
-\x82\x02\x22\x98\x0f\xa0M0mB\xf1\xc32\xdfU\x8a\
-o\xdd\x01W\x5c\x9b\xb6\xf4\xe2\x97\x8f\x9e\x1c\x1a\xb2\xd1\
-\xf4\xf0\x8a\x10\x1c0\x8b\xdcY(\x0e\xbf\x104\xd3'\
-\xac\xe9\x7f\xeb:\x5c\xf5\x1c\xac\xf4\xa6\xe0\xcd\xa7\x87\x87\
-\xe8\x05\x22n9:\xc0\xe3\xd1\x94\x1f\x92\xda\x1d\x1c\xac\
-$q\x10\x7fy\x11M\xb69\x8d?\x1d\xe8\
-E\xa2nU\xb6\xbfHT\x9c\xa0\xc5\xf0%\xe0Y\xfc\
-\xd9DU\xa0\xb8\x95ghv\x04\x058@\x0f\xff+\
-\xee\x14\xf0\xf2\xb8\xcb\xc4i4\x1a\x8dF\xa3\xc9'\xe3\
-|\x05\xe8N\xbf\x0c\xd3\xea\xb47\xd4X\x0f\x0b\xcf9\
-\xda\x009G\x1b \xe7\x146\xbfd=\xbfk\x1cL\
-\xad8T\xcc\x02\xb7\x95\x12K\x91\xed8\xd1\x11J\x02\
-\x85T\xc9\xd1\xcc\xdb!\xda\xdf)\x00C\xf8\xe3 \xc2\
-\xe3i\xb4\x82-\xdb}\xac\x94\xf5\x80\x15\xf0\xc7\xf6\x95\
--=k\xcb\x06\x98%\x86\xc3\xa0\x94\x0a\xfa\xc3\xd7\xba\
-F\xd7\x86Hm\xdf\x00\xa1\xd0\x86\x10\x08%0E0\
-\x14f\xc4X\xc9,\x91Y\x03H\xfc\x14\xef\xa9\xa0/\
-<\x18\x02\xe5\xaa0l\xf44\xb7\xad\xe0\x8b\xef\x8f\x82\
-2\x83\x011Ea\x0cWK/\x08\x81Dd\xb6,\
-\xcd\xa4\x01\xc2aP\xc3\x110\xd2\xa3/%\xfd` \
-\xab\x8d\xc4S\xfe\xdc\x86\xed\xceb0\x00\x11\x8c\x82*\
-aP2\x0c\xe6\x0c\xd3\x1f&g\xf8#\x22L\x91\xdd\
-\xe1q\x993@\x98\xb5{\xc1\x0a\x99\x03)Y\x95.\
-\x97\x06\xab|=\xb0\xf8\x9f\xe7\xd0\x93\x92\xc1\x84'\xb0\
-\x94\x85A\xd50\xd8g\x16\xf9q\xb9\xc2O\xca5j\
-\x0a\x94aP4\x8c\xe1\x0a\x5cY3A6\x0d\x10d\
-\xfb\x8e\x92\xfc\xb3w\x83O\xac\x15n\xa4o\x9201\
-\x06J2\xf0$\xcb\x9e\xcbe\xdb\xe2\xef\xbd\x15\x1e\xa9\
-,\xf0\xd3\xda\x1eL%0P\x90\xc1es3g\x00\
-\xf0\xcbvW)\xde\xeb.\xf3\xd1\xea\xf5\xa9\xc4\xe1\x86\
-\xe7\xf2n\xb7\x8d\xa5$\xcd\xfa\xbe\xb5\x8aa\xc6\xc8T\
-\xdde\xad\xec\x87\xf7\xba\xed\xa9\x89\x1f\xe5\xa3\xd5\xeb\xbc\
-\xd7m\xe3\xa9\xb5\xe1\xd9Y\x22s9\x80R\xf0q\xef\
-\x06\xefw\x97\x13\xa7\x80W\x80\xb7\x80OZ\x9d\xf6w\
-\x93|o\xb3\xde\xb8\x03x\x04x\x0ax\x81Hq\xff\
-~w\x99\xbdf\x91\x9f\xd7\xf6\x92\xb5\xe5\x14\xb3g\x00\
-HK\xf9\xdf\x01\xbfiu\xda\xef\xee\xd4{\x03C\xbd\
-\x03\xbc\xd3\xac7\xfe\x0c\xfc\x09\xb8#<\xff\xd1\xeau\
-~V\xdb\x9b\xb9B SE\x00\xc0\xd7\x83\x1e\xd7\xd6\
-OvP\xec\xb0\xf8qZ\x9d\xf6\xdf\x80\xdf\x12it\
-\xbc\xe6\xda|=\xe8\xedV\x14&F\xe6\x0c\xf0y?\
-1\xd4\xed\x95\xdd\x14?$0\xc1\xab\xd1\xb0\x94\xb8\xcd\
-<\x993\xc0\x95\xe4VhoM#\x1ei\xefN\x89\
-\xdb\xcc\x93)\x03\x08`9\xf9\xbd\xff\xc9\x14\xa2\x12\xf2\
-q\xf4`\xd9su\x1d@\x93-2g\x80\xdb\x92\x9b\
-V<2\x8dx\x04<\x1a=H\x89\xdb\xcc\x939\x03\
-,&\xb7?{j\x1a\xf1H{\xf7\xe2\x14\xc6Cl\
-\x97\xcc\x19\xe0P%1_\xf5\x85f\xbd\xf1\xcb\xdd\x8e\
-G\xb3\xdex\x02x>\x1av(\x83\x1bmf\xce\x00\
-\xf7\x94\xab\x1c(\xac\xdb\x18I\x00\xaf\x05\x82\xec\x0a\xc1\
-\xbb^#\xd2\x1ax\xa0P\xe2\x9e\xf2\xe8\x1d\xd7f\x95\
-\xcc\xb5\x04\x02\x1c\x9do\xf0\xe6\xf5\xffF\x83\xee\x00\xfe\
-\xda\xac7^\xc5\xff4\xfbx\x87\x9a\x82\x1f\xc5\xcf\xf6\
-\x9f'\xd6\xf3{t\xbe1\xc9\xd7\xed\x1a\x994\xc0c\
-\xb5=\xacH\x97\xd6\xca\xb5h\xb0\xc0o\xa3\x7f\x01\xa0\
-Y\xdf=AN,\xec\xe7\xb1Zb\x93\xe6L\x90\xb9\
-\x22 \xa4Y\xdf\xc7\x89\x85\xfd\xd3\x8e\x06'\x16\xf6\xf3\
-D}\xdf\xb4\xa3q\xd3d2\x07\x08y\xa2\xbe\x8f\xba\
-Q\xe0l\xb7\xcd\xd5\xe4bH;\xca\x81B\x89\xa3\xf3\
-\x8d\xcc\xa6\xfc\x90L\x1b\x00\xfc\xe2\xe0\xb1\xda\x1e.\x0f\
-z\x5c\xecwY\xb2\xfb,{\x0e\x9d\x09\x8f\x10\xaa\x9b\
-\x05n3\x8b,\x96\xe6847\x9f\xc9\x0a_\x1a\x99\
-7@\xc8=\xe5\xea-#\xcan\x92\xd9:\x80f2\
-h\x03\xe4\x1cm\x80\x9c\xa3\x0d\x90s\xb4\x01r\x8e6\
-@\xce\xd1\x06\xc89\xda\x009G\x1b \xe7h\x03\xe4\
-\x1cm\x80\x9c\xa3\x0d\x90s\xb4\x01r\x8e6@\xce\xd1\
-\x06\xc89\xda\x009G\x1b \xe7h\x03\xe4\x1cm\x80\
-\x9c\xa3\x0d\x90s\xb4\x01r\x8e6\xc0-\xc5\xd6\x17\xa9\
-\xdb\xb2\x01F-\xc0\x9c\xb5\xf5\xf1\xb2\xcc\xa8\xff\xff\xcd\
-h0\x8e\x01\xd6=7}_b\x90\xa3Nh&\xce\
-\xa8\xffuJ\xf0\xa6\xa2\x8cc\x00w\xfdA\xfa*\xfc\
-\x8e\x94:\x17\xd8\x05\x14\xfe\xff:\x1e\x06\x0a7\xb96\
-\xba\xb3\xd9\xf3\xc61\xc0j\xf4\xa0/%RE_\x1c\
-\xfeVX\xde\xa6\xef\xd3l\x13\xcbs\xd6%\xbf\xf0\x97\
-T\xd0\x97\x89$\xb8\xe9\xc2\x85\xe3\x18`)zp~\
-\xd0\xc5\xc3_\x8b?\x1a\x01\x80\x15\xc7\xc6\x9d\xf02\xed\
-\x9a5\x5c%Yq\xd6&\xc1\x86\xff{\xa5\x14\x1e\x8a\
-\xf3\x83\xc4:\x85K\xf1\x808\xe3\x18\xe0\xf5\xe8\xc1%\
-\xbb\x8f#\xa5o\x02\xd6\x9b@*\xc5\xb5\x81E\xcfs\
-tq0A\x14\xd0\xf3\x1c\xae\x0d\xaca\xf9?\x14\x1f\
-_|GJ.\xd9\x89u\x0aOm\xf6\xecq6\x8e\
-\x9c\x07V\xa2\xd7\x1e\xad\xd4\xb9\xbf\x5c\xa5(\x0cL\x04\
-\x22e\xb3\x04\x81\xa0h\x18\xfe>;\x9b\xbdD\x93\x8a\
-\xc2OT~\xfd*\x99\xed\x87)\xdfQ\x92/\x06=\
-\xceZ\x9d\xf8\xed\x0b\x9b\xed\x22\xbe\xe9\xec\xe0V\xa7\xdd\
-m\xd6\x1b\x17\x81\x07\xc3\xb0\xcbv\x9f\x1f\x15\xcb(\x81\
-\xbf[\x86Z\xbf\x9bV\xf8~[z\x9b\xff\x95\x9a\xb1\
-P\xb1#\xa9\x18\xa6\xfc\xbe\xf2\xb8\x9cL\xfd\x17\xc7\xd9\
-B\xde\x1c\xe7\xe5w\x97+g\x81\x17\xc3\xe3\xae\x92t\
-\xa5\xc7\xedf1\x99\xbaE\x98\x13\xe8t?Y\x82\x9d\
-\xd0B\xe1\x83\x1dS,)\xf9\xb0\xdfa)\xb9@\xc6\
-\x89ol\xeb\xfb\xcd\x9e:\xb6J\xcdz\xe34p,\
-\x1a\xf6`\xa9\xc2\x03\xa5\xaa\xbf\x81\x92\xf0\xb3{3\xd8\
-aK\x90\xdd\x8d\x94f\x89p\x93\x0c\xbf8\xf0\xc5\x97\
-Ja+I_J>\xb7{\x5c\xb0\xad\xf8mgZ\
-\x9d\xf6\xf1q\x9e\xbf\x95\x05\x22\x9e\xc4\xafU\xd6\xc3\x80\
-\x0b\xb6\xc5\x8a\xf4x\xb8T\xa3d\x18\x94\x82m\xd5L\
-!\x86\xfb\xedi\xb6O\xd8\xf22\xdc%M)l)\
-9g\xaf\xa6\xa5\xfc\x0e\xbeVc\xb1%\x8d\x9a\xf5\xc6\
-a\xe0\x1c\xb1\xa2\xa3a\x98\xfc\xa0P\xe2\x87\x852\x05\
-!( \x82\xca\x9f6\xc1v\x09\xc5\x97J\xe1\x06{\
-%\xfd\xdb\x1d\xf0\x1f\xd7\xa6\x9d\xaccy\xc0\x91V\xa7\
-}~\xdc\xe7oY\x9ff\xbd\xf14\xf0\x06)\xf5\x87\
-\x8a\x10\x1c0\x8b,\x16\x8a\x14\x85\x81\x01\x18\xda\x02\xdb\
-B\xe2\xef}\xe8(\xc9\x92\xebp\xd5s\xb0\xd2\x9b\x82\
-=\xe0\x99V\xa7\xfd\xe6V\x9e\x7fS\xea\x049\xc1\x07\
-@\xf6\xd6F\xbd5\xe9\x00\x8fo%\xe5\x87\xdcTw\
-p\xab\xd3\xfe\x0c8\x08\x9c\xb9\x99\xfb5\x13\xe54\xb0\
-x3\xe2\xc3\x04\xbe\xd5\x82\xdc\xe0$ph\x12\xcf\xd3\
-\x8c\x85\x02.\x02\xcf\x05\x89\xf1\xa6\x99\x98`A\x8b\xe1\
-K\xc0\xb3\xc0\x22P\x05\x8a\x93|GNQ\xf8\xbdz\
-=\xfc\xaf\xb0S\xc0\xcb\xe34\xf2h4\x1a\x8dF\xa3\
-\xd1h4\x1a\x8dF\xa3\xd1D\xf9?\x88\x82\xa9@\x0f\
-\x8d\xc9Y\x00\x00\x00\x00IEND\xaeB`\x82\
-\x00\x00\x19\xd7\
-\x89\
-PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
-\x00\x00\x80\x00\x00\x00\x80\x08\x06\x00\x00\x00\xc3>a\xcb\
-\x00\x00\x05\xc9iTXtXML:com.\
-adobe.xmp\x00\x00\x00\x00\x00\
-xpacket begin=\x22\xef\
-\xbb\xbf\x22 id=\x22W5M0MpCe\
-hiHzreSzNTczkc9d\
-\x22?>\x0a\x0a \x0a \x0a \x0a \
- \x0a \
- \x0a \x0a \x0a \
- \x0a <\
-/xmpMM:History>\x0a\
- \x0a \
-\x0a\x0a\
-xpacket end=\x22r\x22?\
->.\x8f\x5ci\x00\x00\x01\x81iCCPsRG\
-B IEC61966-2.1\x00\x00\
-(\x91u\x91\xcbKBA\x14\x87\xbf\xb4\xb2'\x06E\
-\xb4h!Q\xad4\xac@j\xd3B\xe9\x05\xd5B\x0d\
-\xb2\xda\xe8\xf5\x15\xa8]\xeeUB\xda\x06m\x83\x82\xa8\
-M\xafE\xfd\x05\xb5\x0dZ\x07AQ\x04\xd1\xb6\xd6E\
-mJn\xe7j\xa0D\xce0\xe7|\xf3\x9bs\x0e3\
-g\xc0\x12L)i\xbd\xd6\x0d\xe9LV\xf3Oz\x1d\
-\x0b\xa1E\x87\xed\x85F:\xa9\x17k\x09+\xba:\x1b\
-\x98\x08Ru|\xdeSc\xfa[\x97Y\xabz\xdc\xbf\
-\xa39\x1a\xd3\x15\xa8i\x10\x1eST-+<%<\
-\xb3\x96UM\xde\x11\xeeP\x92\xe1\xa8\xf0\x99\xb0S\x93\
-\x0b\x0a\xdf\x99z\xa4\xc4\xaf&'J\xfcm\xb2\x16\xf4\
-\xfb\xc0\xd2&\xecHTp\xa4\x82\x95\xa4\x96\x16\x96\x97\
-\xd3\x9bN\xe5\x94\xdf\xfb\x98/i\x89e\xe6\x03\xe2{\
-du\xa3\xe3g\x12/\x0e\xa6\x19\xc7\x87\x87AF\xc5\
-zp1\xc4\x80\xec\xa8\x92\xef.\xe6\xcf\xb1*\xb9\x8a\
-X\x95<\x1a+$H\x92\xc5)jN\xaa\xc7\xc4\xc7\
-E\x8f\xc9L\x917\xfb\xff\xb7\xafz|x\xa8T\xbd\
-\xc5\x0bu\xcf\x86\xf1\xde\x07\xb6m(l\x19\xc6\xd7\x91\
-a\x14\x8e\xc1\xfa\x04\x97\x99r\xfe\xea!\x8c|\x88\xbe\
-U\xd6z\x0f\xc0\xbe\x01\xe7We-\xb2\x0b\x17\x9b\xd0\
-\xf5\xa8\x86\xb5pQ\xb2\xca\xb2\xc4\xe3\xf0v\x0a\xad!\
-h\xbf\x81\xa6\xa5R\xcf~\xcf9y\x80\xe0\xba|\xd5\
-5\xec\xedC\xbf\xc4\xdb\x97\x7f\x00\x00\x04\x5c(4#\xe4\xb1\xdc\x98\x92\x8c\x16\
-@\xc7\xfb\xce\x07\xe8\x09\xfc\xd8\x07\xb91%\x19\x15\xe0\
-\x1fU\x0b~\xb6\xb1\x08cS\xb1/\xb2\x13\x85\xa4R\
-\x00i\xea)\xc0/T\xcb\xaf\xfe\xac\x88\xd5\x9f\x9dp\
-R\xc5/\xec:\x92\x86\xa4\xfag\x80\xf1@7\x95\x82\
-\x95Ua6|Q|\xe6u\xbb\xb6J\xf7B\x160\
-\x0eX\x1da\xfb\xe2\x8e\xa4\xb2\x008\x98\xa9o\xf8\xbc\
-\x84\xd3\x15aNW\x84\xd9\xf0y\x89/u$\x02I\
-\xa3\x00\xd2\xd4\xdb\x007\xa9\x96_m\xd4\x99\xfeO\x0c\
-G\xc3\xc0T\xbb\xae\xa4 i\x14\x00\xb8\x06\xe8\xa2R\
-\xf0tE\x98\x8d_\xd4\xdd\xf5\x1b\xbf,\xa1\xa2By\
-\x91\xaf\x0b\xd6\xce\xa2\xa4 \x99\x14@y\x86\xbenS\
-1\x95Uu\x1d^Q\x19f\xdd\xe6\x96\xf94\x90\x14\
-\x0a M\xbd\x1d0E\xb5\xfc\xea\x06L~C\x7fk\
-\x82)\xd2\xd4\xdb:\xb9 ^I\x0a\x05\x00~\x06t\
-R)X~*\xc4\xe6\xad\xe7O\xfa6m)\xa1\xfc\
-\x94\xf2\x22_\xa6]g\xc2\x93,\x0a\xa0<376\
-\x15S]}\xfe\xde\x8e\xeaj\xe9tQ()\x9e\x06\
-\x12^\x01\xa4\xa9\xb7\x07&\xab\x96oj\xe1\xc7\xe1\xa2\
-\xd0di\xea\xa9N.\x88G\x12^\x01\xb0\xb6zw\
-P)XZ\x16\xe2\xcbm'\x1b\xfd\xfc\xcbm')\
--S\x1e\x062P\xdff\x1e\xb7$\x83\x02(\x9b\xe2\
-O7\x14Q]\xd3\xf8\xd6\xbe\xea\x1a\xc9g\x1b\x8b|\
-\xa9;^Ih\x05\x90\xa6\x9e\x0e\xdc\xa0Z^e\xa6\
-\xefp\x18\xf8\xb94\xf54'\x17\xc4\x1b\x09\xad\x00\xc0\
-$@\xa9\x03\x8aKj\xd8\xfau\xf31\x1e[\xbe.\
-\xa5\xe4d\x8dj\xfdiv\x1b\x12\x96DW\x00e\x13\
-\xbcvC\x11\xa1P\xf3;\xbbC!\xc9\xda\xf5-g\
-\x18HX\x05\xb0C\xbe\xaeW-\xefd\xbd\xdf\xe1\xa2\
-\xd0$\xbb-\x09I\xc2*\x00p#\xa0\xf4\x18v\xa2\
-\xa8\x9am;\xca\x94\x05o\xdbQ\xc6\x89\xe2j\xd5\xe2\
-\xa9\xc0\xcf\x95\x85\xc7\x19\x89\xac\x00\xca\xa6w\xcd\xfa\x22\
-\xc2a\xf5\xc0\x9epX\xb2v]\xcb\x18\x06\x12R\x01\
-\xa4\xa9g\x02\xd7\xaa\x96w8\xb3\xb7\xaeq6\x0c\x5c\
-k\xb7)\xe1HH\x05\xc0Z\xf9S\xf2\xc9\x17\x1e\xaf\
-b\xc77\xea\xe6\xbf\x96<\xb3\x8c\xc2\xe3U\xaa\xc5\xdb\
-b\x0dI\x09G\xa2*\x80\xb2\xc9\xfd\xc4(\xc2\x81\xf5\
-?CX\xc2\x9a\x160\x0c$\x9c\x02HS\xbf\x00\xf8\
-\xa9jy\x87\xa6\xdc\xcd\xb5?\x93\xa6\xae\xb4!%\x9e\
-H8\x05\xc0\xf2\xfb+mf=|\xb4\x8aovG\
-\x9e\xe5\xc5\xdcU\xce\x91B\xe5a \x05\x07{\x12\xe2\
-\x85DT\x00\x07\xe6\xff\x04\xd2ET\xbf\x94\x8e\xf7\x0b\
-&\xdc0\xe0\xf9\xb6p;27\xa5\xdeO\xb0\x89\xf7\
-M}\xd6P\xd9\xf6\xc0\x04\xd5\xb6\xb81\xffu2\x8a\
-\x98v\x93r\xa0\xd1\x04i\xea3\x80\xd3@\x08\xa8\xb1\
-\x7f\xea\xbfn\xee}\x93e\xbd\x8ePnP\x01\xec\xec\
-\x1aWc\x85Y]\x86\x95O/\x0d\xb5\x8e\x8a\x0b\x0e\
-\x1c\xaad\xf7\xdeS\xae\xe5\xec\xfa\xb6\x9c\x83\x87+\xc9\
-\xee\xd1N\xa5x\x10x\xcbu\xa5M M\x1d\xd4\x94\
-\xe5\x14V\xfe\xc3\x1dX\x09\xaf\xd64\x94\xe5\xe4\xbc\x0e\
-\x93\xa6\xfec\xe0\xf7$x\x1c\xdcj\x97\xe6\xbf\x16)\
--Y3n\xee\xe9^\x98w\xa8\xdel\x97Q\x17)\
-eHS\xff\xad\xd0\x8c\xf5\xf5\x0b\x9c5\x07\x90\xa6\xfe\
-\x18\x96\xb6$t\xe7Cd\x8b?\x8d\xcbr\xf48\x18\
-\xaf\xe8\xc0:i\xea\xbf\xa9\xff\xc73\x0a M}6\
-\xb0 \xda\xad\xf2\x03sW9\x05\xfbN{&o\xef\
-\xf7\xa70](\x08\x06\xea\
-d\xb6i#\xb8l\xa0R|L}\xd6\xa4\x00\xbbp\
-\xa8\x00UU\x92\xb9/\x16p\xa2Hy\xdf\x5cR\x13\
-\x96p\xf4X\x15G\x8f\xc5fnpA\xe76\xf2\x99\
-\xc7\xfaD\x92\xf2oW\x00\xc8sz\xd5\xf0\xa1\x19\xe4\
-.\xd1\x181,a7\xc3&\x0d#\x86e\xc8\x15\xaf\
-hb\xf8\xd0\x8e\x91\x5c\x9e'\xa4\xa9w\xc2\xca\xab\xab\
-\x14^]\x1f)a\xc5\xbb\x07\xc9}\xef\x90\xa3M\x97\
-\xad\xb8'\x10\x10\xdc\xf9\xcb\x9er\xe6\xb4\x9eB\x88\x88\
-\xf2}\x16\x03\xbd\x03B3J\x80\x97#\x91 \x04\xcc\
-\xba5\x9bE\xf3\xfb\xd3%3i\xd2\xe6\xc4=]2\
-\xdb\xf0\xd2\xb3\xfd\xe5\xac[\xb3#\xed|\x80\x97\x85f\
-\x94\xd4N]_\xc2r\x1bF\xc4\x95\x97w$w\x89\
-\xc6\x15CZ\x87\x04\xbf\xb9bH\x06+\x96h\x8c\x18\
-\xd6\xd1M\x9a\xdf<\xec\x9b\xfe\x8c\x10i\xea\xdd\x81O\
-\x81\x01\x91J\x0dKX\xfe\xce\x01\xde^u(nf\
-\xe8\xc9B \x98qs\x0fy\xd7\xf4l\x11\x08\xb8\
-J\xf1\xbc\x13\x18/4\xe3\x08\x9c\x93,Z\x9az/\
-\xe0\xaf\xc0p75|\xf1\xd5I\xe6/,\xf0uU\
-\xae%\xd193\x85\xa7\x1f\xed\xc3\xa8+\x1cO\xd3\xce\
-e+p\xa3\xd0\x8c\x03\xb5\x7f8O\x95\xec\xac\x17\x0b\
-\x81_\xb9\xa9\xe9\xd8\x89j\xe6.(`\xdb\x8e\xa4>\
-u\xcdw.\xbf,\x83\xb9\x8f\xf7\xa1k\x17\xd7\xfe\x87\
-\xa5\xc0\xa3B3*\xeb\xff\xb1Q[\x22M\xfd\x16\xe0\
-\x0d\xacL\x18\x11\x11\x96\xf0\xc6[\x07x\xe7\xbfZ\x87\
-\x04\xa7\x04\x04\xdcvsO\xee\xbe-\x1b\x97&\xbf\x14\
-\xb8[h\xc6\xfb\x0d}\xd8\xa4di\xea\xfd\xb1N\xdc\
-\xbc\xdcM\x0b6o)a\xfe\xc2\xef\x9c\xc4\xdd\xb7h\
-2;\xa5\xf0\xf4\xec>\x5c5\xc2\xb5\xc9\xdf\x86u\x02\
-\xea\xb7\x8d\x15hV\xb5\xec!\xe1%\xe0~7-)\
-<^\xc5\x9c\x17\x0a\xc83\x93\xca\xa7\xee9C\xb5\x0e\
-\xcc{\xbc/\xdd\xba\xba6\xf9\xaf\x01\xb3\x85fT4\
-UH\xd9\xb6HS\x9f\x06,C1!SC\x84\xc3\
-\x92\xd7W\x1e\xe0\xdd?\x1dn\x1d\x12\xce! `\xda\
-\xd4\x1e\xdc;\xa3\x17\xc1\xa0+\x93_\x06\xdc#4C\
-\xe9\xd4SG5IS\x1f\x805$\x0c\x8d\xa0ag\
-\xd8\xf0E\x09\xcf-\xfa\x8e\x92\xd2\xd6!\x01\xa0SF\
-\x0aO=\xda\x87\xd1W\xba6\xf9_c\x99\xfc\xdd\xaa\
-\x178V5;/\xdfb\xe0\x1e\xa7\xd7\xd6\xe7\xe8\xb1\
-*\xe6<_\xc0\x8e\x9d-{H\x182\xa8\x03\xf3\x9e\
-\xe8K\x96{\x93\xbf\x0cxXh\x86\xa3M\x11\x11\xdb\
-\x1ai\xea\xd3\x81\xd7\x81\xf4He\x84B\x92\xd7r\xf7\
-\xb3\xea\xbf\x8fx\xba\x893\x11\x08\x08\xb8eJ\x0f\xee\
-\xbb\xa3\x17)\xeeL~9p\x9f\xd0\x8cw\x22\xb9\xd8\
-U\xcd\xd2\xd4\x07\x02\xff\x89\x15\x80\x101\xeb?/\xe6\
-\xb9\x97\xbe\xe7d\x0b\x19\x12:f\xa4\xf0\xd4#}\x18\
-3\xca\xb5\xc9\xdf\x81e\xf2wF*\xc0\xf5\xb1qv\
-\x9e\xbcW\x80\xbb\xdc\xc89RX\xc5\x9c\x17\xf6\x92\xbf\
-3i\xf6\xdf7\xc8\xe0\x81\xe9\xcc\x7f\xa2\x1f\xdd\xbb\xb9\
-6\xf9\xcb\x81\x87\x84f\xb8\x8a\x7f\xf3\xec\xdc@i\xea\
-\xb7c=zD\x9c81\x14\x92\xbc\xbab?\xef\xff\
-%\xf9\x86\x04!\xe0\x96\xc9\xddy`f\x8e\xdbY\xfe\
-)\xe0~\xa1\x19o{\xd2./\x84\xd4\x22M]\xc3\
-\x1a\x1247r\x8cM\xc5\xcc\xfbC\x81\x93S<\xe2\
-\x9a\xd4\xd4\x00\xcf\xfc\xa6/\xfa\x8f\x5c\xa7\x11\xca\xc72\
-\xf9\xdfx\xd0,\xc0\x87\x93C\xed\xf4\xadK\x81;\xdc\
-\xc8\xd9\x9e_\xc6\xe3\xf3\xf68\xc9\xe1\x1f\x97\xa4\xa7\x05\
-yqn\x7f\x86j\x11/\x9f\xd4\x92\x0b<(4\xc3\
-\xd31\xd2\xb7\xa3c\xa5\xa9\xdf\x09\xbc\x8a\x15\xd3\x1f\x11\
-_\x9be<\xf8\xe4\xae\x84\xddm\x14\x08\x08\xfe\xf8\xfc\
-\x00\xb7\x9d\x7f\x1a\xf8\x95\xd0\x8c\x5coZu6\xbe\xed\
-e\xb6\x1b<\x0a\xcb\xff\x1c\x11C\xb5\x0e\xdc4I\xe9\
-\x18\xc0\xb8d\xea\x0d\xdd\xdcv\xfe7\xc0H\xbf:\x1f\
-|N\x11#4c\x070\x12\xf8\xf7He\xdc{{\
-/:g\xc6M\xde\x09e:g\xa6p\xef\xed\xaeN\
-\xb0\x7f\x1b\x18%4#\xdf\xa3&5\x88\xef\xd1\x0cB\
-3\xca\x80\xdb\x81\xbb\x81&\x1d\x13\x0d\x91\xd6>\xc8\xf8\
-1\x9d=o\x97\xdf\x8c\x1f\x1bq\xb0H\x05\xd6#\xf5\
-\x1d\xf6w\xe7+Q\x09g\x11\x9a!\x85f,\xc7\x1a\
-\x12v9\xbd~\x5c\x02*\xc0\xb81\x11\xcd\xf8wa\
-\xdd\xf5o6\x94\xce\xc5\x0f\xa2\x1a\xcf$4#\x0fk\
-H\xf8\x0f'\xd7\x0dv\x1e\xf1\x12s.\x1b\xe0\xb8\xcd\
-\xef\x00W\xda\xdfQ\xd4\x88z@\x9b\xd0\x8cR\xe06\
-\xe0>\xa0\xb2\x99\xe2\x00\xb4O\x0d\x90\x92\xe2\xdb\x03\x8b\
-\xe7\xb4I\x11\xa4\xaa\x9b\xffJ\xe0^`F4L\xfe\
-\xb9\xc4$\xa2\xd1\x1e\x12\x96\x01\x1f\xaa^\x93\x9e\x16\xf4\
-\xb1E\xde\x92\xe6\xa0\xad\xe1\xb0\xfcPh\xc6\x1b\xd12\
-\xf9\xe7\x12\xb3\x90V\xfb\xb4O\xa5\x94\xaf\x15\x15aJ\
-\x13\xc8QTZZ\xa3\xbc\x8a\x19\x08\x88\x9f\xda\xdfE\
-L\x88eL\xf3\x04\x14w\x17\x15\xec;\x9dP;\x88\
-\xc2\x12\x0a\xd4s\x15t\xc0\xca\xc9\x18\x13b\xa9\x00\xca\
-\xe9\xd5\xf3w%\x9e\x87\xd0t\xd6\xe6\x98\xa5\x9a\x8f\x89\
-\x02\xd8\xe9d\x95\xffi\x87'{\xc7\x05\x0e\xdb|\xa3\
-\x9d\xac+\xea\xc4\xca\x02\x8c\x00\xb2U\x0a\x96\x95\x87\xd8\
-\x9e\x9fx\xc1%\xdbv\x94RV\xae\xec\xc8\xea\x85\xf5\
-\x9dD\x9dX)\x80\xf2Y\xbf\x9b\xbe,\xa1\xa6\x89\xd3\
->\xe3\x95\x9a\x1a\xc9\xa6-\x8e\xd2\xd5)\x7f'^\x12\
-+\x05P6\xff\x0eO\xf4\x8e+\xd69k{L\xe6\
-\x01QW\x00i\xea}\x80!*ekj$\x9b\xb7\
-z\x9f\xf41Zl\xdaR\xa2tX\xa5\xcdP\xfb\xbb\
-\x89*\xb1\xb0\x00\xca\xa6\xee\xab\x0c\xf8\xaa\x00\xb6cC\xdd\xf9\xd3\xcc\x97\
-#\x04L\xba\xa6+\xb9K\x06\xa3\x0d\x8887U-\
-?`-NM\xb0_G\xcc\xe0\x81\xe9\xacxE\xe3\
-\xfak\xba\xd2\x5c\xfa\xfexs\x0e\xf9m\x01\x1c9\x7f\
-\x8e\x146\xee\xfc\xe9\x98\x91\xc2\xb3O\xf6\xe3\xb7\x0f\xf7\
-\xf6\xe2\x84\xae\xf7\x80aB3>\x15\x9a\xb1\x16\xeb\xd4\
-\xb4Un\x04\xa6\xb5\x0f\xf2/\x0f\xf7f\xfe\x93\xfd\xe8\
-\x98\xd1\xf8\x02\xeb\xe1\xa3\x8e\x9dC\xd7\xb9iWs\xf8\
-\xad\x00\x9e\x98\xff\x11\xc3:\xb2\xf2\x8f\x1a\xe3\xc7\xba\xbe\
-\x19J\x81\x19\xc0\xadB3\xce\x1c\x09n\xbf\x9e\x86\x15\
-\xc5\xecj\x07\xea\xd5c;\xb3\xb2\x99\xf3\x94\xe2i\x18\
-\xf03C\x88#\xe7\xcf\xac\x7f6\xd9\xbd\xf7\xec;\xa3\
-M\x1b\xc1}\xb7\xe7\xf0\xcb)\xdd\xbdh\xd2z\xac\xf8\
-\xbb\xef\x9a*d?\xb5\xbc\x0d\x8cu[\xe1\xbb\x7f:\
-\xcc\xb2\xb7\x0fP]}\xf64#\x9e\x9cC~Z\x80\
-\xf1(v\xfe\xd1c\xe7\x9b\xc5\xde\x17\xb5\xe7\x8dE\x83\
-\xbc\xe8\xfc\x10\xf04\xd6)\x19Mv>\x80]f<\
-0\xc7\xbe6b\xa6\xddd-G\xf7\xbe\xf0\xec\xe5\xe8\
-Z\xe7\x90\x22\x19\xc087\xedh\x0a?\x15\xc0\xd1\xe2\
-O\xad\xf3G\x08\x98zC\x16\xcb_\x1e\xc4%}\x5c\
-;b\xbe\x05\xc6\x0a\xcd\xf8\x9d\xd0\x0c\xe5\xe0B\xa1\x19\
-5B3\x9e\xc5\xb2\x02{\xdd4\xa0\x7f_\xcb!u\
-\xd3\x0dYg&\x88R\xc2\xfa8\x19\x06|Q\x00\xc7\
-\xce\x1f\xfb\xcb\xe8\x92\xd9\x86\x05s.\xe1\x91\xfb/\xa2\
-][\xd7M[\x0e\x5c!4cs\xa4\x02\xeck/\
-\xb7eEL\xbb\xb6\x01f\xdf\x7f\x11\x0b\xe6\x5cr\xe6\
-t5\xa7{\x05\xfdr\x0e\xf9e\x01\x86c94\x9a\
-\xa5\xfcT\x88my\xa5\x8c\x19\xd9I\xae\x5c\xaa1z\
-\xa4\xeb\x5cz'\x80\xa9B3\xee\xf6\x22\xde^hF\
-\x99\xd0\x8c\xbb\x81\xa9\xb6\xec\x88\x19=2\x93\x95K5\
-\xc6\x8c\xea\xc4W\xce\x9cC9\xc0\x15n\xean\x0c_\
-\xb4J\x9a\xfa|\xacq\xb7Y\xd6m.\xa6\xf0x\x95\
-\x9cr}\x96\x17m\xf9\x18+\xb7\xceA\x0fd\x9d\x87\
-}\xa8V.p\x8d[Y\x7f\xfe\xe0(Y\xdd\xda2\
-v\x94\xb2\xc2\xcf\x17\x9a\xf1\x8c\xdbz\xcf\xc5/\x05X\
-\x8b\x8f\x13\x97\x06\xa8\x02\x9e\x04\x16\x0b\xcd\xf05\xbd\xa8\
-\x1d\xd8\xfa0\xf0{\xc0u\xc2_\x07\xac\x15\x9a\xe1y\
-\x18\xb9_[\xc2.\xf1InC\xe4c=\xd7\x7f\x1d\
-\x8d\xcal\x05[$M}5\xd6\xe6\xcd\xc1\xd1\xa8\x17\
-\x9f\xbeS\xcf\xe7\x00v\xf6p\xa5\xf1\xdf\x03^\xc1J\
-\xa4\x18\x95\xce\xaf\x8f\xd0\x8c\xedX\x09\xaf\x96D\xa9\xca\
-\x1c\xfb\xbb\xf5\x14?,@4\x1cLG\x80;\x85f\
-\xfc-\x0au5\x8a}:\xc7C\xd2\xd4?\x02V\x00\
-\x9e\xacX5\x81\xe7\xdf\xad\xe7\x02\xed\x99\xf7>\xaf\xe5\
-\xd6\xe3\xaf\xc0\x90Xw~}\x84f|\x84\xb5\xe7\xe1\
-\x7f|\xac\xe6{?\xb2\x88\xf9u\xb7F|\x10u\x13\
-\x9c\xc6:\xba\xee\x1f\x84f\x14\xfa \xdf\x15v\x9b&\
-c\xb5\xd1\xd1\xb9=\x8a\xf8\x922\xd6/\x05p\x94\x08\
-R\x81-X\x8b:\xaf\xc7*\x9d\x9a\x0av\xfa\xbb\xd7\
-\xb1\xd6A\xb6z,\xde\x97h!\xbf\x14`\x15`z\
- Gb=n\x8d\x11\x9a\xe18\xc5l\xac\xb0\xcf\xf0\
-\x19\x0d<\x8f\x8b\x0d'\xf5\xc8\xc7\xa5\xbb\xba1\xfc\xf4\
-\x06^\x0f\xfc\xaf\x8b:~\xc0\xf2\xde}\xea]\xab\xa2\
-\x8f4\xf5qX\xde\xc5\x0b#\x15\x01\xdc 4C9\
-\xa9\xa6\x13\xfc\xe0C`&\x91\xdd\x01\xef\x02C\
-\x13\xbd\xf3\x01\xec\xffa\x18\xd6&\x14\xa7H`\xa6_\
-\x9d\x0fQ\x08\x0e\x95\xa6~\x07\xd6\x112*{\xb8N\
-`\x9d\x84\x15\xb5\xe8\xd8ha;s\xa6c\x1d\xba\xa9\
-\x12\x5cP\x8euR\xc8[~\xb6+*\xe1G\xd2\xd4\
-\xb3\x80\xc7\x81_\x03\x0d\xc5j\x1d\x07\xfe\x00,\xb5\x93\
-I'-\xd2\xd43\x80\x07\x81G\x81\x86\xf6\xb2W`\
-\xdd0/D\xe3i'\xaa\xc9\x09\xed\x7f^\xb3\x7f\xb2\
-\x81\x02`7`:=\xf24\xd1\xb1\x8f\xe0\xd5\x80K\
-\x81\xbe\xc0A\xac\x89\xb3\x99\xec7A+\xad\xb4\x12/\
-\xfc?\x7f%^\xa5\xe3\xc4\x98!\x00\x00\x00\x00IE\
-ND\xaeB`\x82\
-\x00\x00\x0ef\
-\x89\
-PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\
-\x00\x00\x80\x00\x00\x00\x80\x08\x06\x00\x00\x00\xc3>a\xcb\
-\x00\x00\x05\xc9iTXtXML:com.\
-adobe.xmp\x00\x00\x00\x00\x00\
-xpacket begin=\x22\xef\
-\xbb\xbf\x22 id=\x22W5M0MpCe\
-hiHzreSzNTczkc9d\
-\x22?>\x0a\x0a \x0a \x0a \x0a \
- \x0a \
- \x0a \x0a \x0a \
- \x0a <\
-/xmpMM:History>\x0a\
- \x0a \
-\x0a\x0a\
-xpacket end=\x22r\x22?\
->\x14o\x1eC\x00\x00\x01\x81iCCPsRG\
-B IEC61966-2.1\x00\x00\
-(\x91u\x91\xcbKBA\x14\x87\xbf\xb4\xb2'\x06E\
-\xb4h!Q\xad4\xac@j\xd3B\xe9\x05\xd5B\x0d\
-\xb2\xda\xe8\xf5\x15\xa8]\xeeUB\xda\x06m\x83\x82\xa8\
-M\xafE\xfd\x05\xb5\x0dZ\x07AQ\x04\xd1\xb6\xd6E\
-mJn\xe7j\xa0D\xce0\xe7|\xf3\x9bs\x0e3\
-g\xc0\x12L)i\xbd\xd6\x0d\xe9LV\xf3Oz\x1d\
-\x0b\xa1E\x87\xed\x85F:\xa9\x17k\x09+\xba:\x1b\
-\x98\x08Ru|\xdeSc\xfa[\x97Y\xabz\xdc\xbf\
-\xa39\x1a\xd3\x15\xa8i\x10\x1eST-+<%<\
-\xb3\x96UM\xde\x11\xeeP\x92\xe1\xa8\xf0\x99\xb0S\x93\
-\x0b\x0a\xdf\x99z\xa4\xc4\xaf&'J\xfcm\xb2\x16\xf4\
-\xfb\xc0\xd2&\xecHTp\xa4\x82\x95\xa4\x96\x16\x96\x97\
-\xd3\x9bN\xe5\x94\xdf\xfb\x98/i\x89e\xe6\x03\xe2{\
-du\xa3\xe3g\x12/\x0e\xa6\x19\xc7\x87\x87AF\xc5\
-zp1\xc4\x80\xec\xa8\x92\xef.\xe6\xcf\xb1*\xb9\x8a\
-X\x95<\x1a+$H\x92\xc5)jN\xaa\xc7\xc4\xc7\
-E\x8f\xc9L\x917\xfb\xff\xb7\xafz|x\xa8T\xbd\
-\xc5\x0bu\xcf\x86\xf1\xde\x07\xb6m(l\x19\xc6\xd7\x91\
-a\x14\x8e\xc1\xfa\x04\x97\x99r\xfe\xea!\x8c|\x88\xbe\
-U\xd6z\x0f\xc0\xbe\x01\xe7We-\xb2\x0b\x17\x9b\xd0\
-\xf5\xa8\x86\xb5pQ\xb2\xca\xb2\xc4\xe3\xf0v\x0a\xad!\
-h\xbf\x81\xa6\xa5R\xcf~\xcf9y\x80\xe0\xba|\xd5\
-5\xec\xedC\xbf\xc4\xdb\x97\x7f\x00\x00J\x03\x13\xc0\
-h\x22\x95\x5c8\xa8\xe1{\x030\xdb\xdb\x7f\x0c\xf8\x0e\
-\xb8\x0a\xc4<\x1d\x9e\xf0K\x06\x18\x03n%R\xc9\xad\
-\xfd\x1a\xec\x1b\x00\xfb\xa8\xbf\x0d|Z\xbe\xb1\x09\x1fM\
-\x01_\xedw6\xf8_\x00f{\xfb?\x01&\x81N\
-\x1f\x06&\xfc\xf3\x12\x18N\xa4\x92\xd3\xbb\x7f\xf8\x9f\x00\
-\xd8G\xfe#\xa4\xf8A5\x07\xf4%R\xc9\xc5\xe2\x0f\
-B\xc5\xbf\xd8\xd7\xfc\xdbH\xf1\x83\xac\x0b\xb8m\xd7\x1a\
-\xd8\x15\x00\xac\x1b>\xb9\xe6\x07\xdfy\xacZ\x03\xf6%\
-\xc0>\xf5\xff\x89\xdc\xed\xeb\x22\x03|\x94H%\x17\x8a\
-g\x80\xebH\xf1u\x12\xc3\xaa9\x86=\xc3\xb7\x0c\xc4\
-\xdd\xec!\xb3\x93g&\xfb\x8e\xd5\x5c\x8e<\x852\x8c\
-Q811h\x0a\x87\xe9\x8e\xd6\x12\x0b\x99n7O\
-\x03\xeda\xac\xe9]\xe5\xe2\xaf\xed\xe4\x19_\x9a\xe3\xfe\
-\xdak\xb6\x0bR\xf8\xa3 b\x18\x0c\xc6[\x18i\xef\
-\x22\xae\x1e\x84\x06` \x8c5\xb7\xafdm'\xcf\xc8\
-\xdc\x1f\xccf7K\x19\xa7(\x93\xedB\x81_\xd2+\
-<\xdd\xcc0\xdeu\xc6M\x08.\x87\xb0\x1e\xec(\x19\
-_\x9a\x93\xe2\x1fa\xb3\xd9M\xc6\x97\xe6\xdclr6\
-\x84\xf5T\xcfQf'\xcf\xfd\xb5\xd7%\x0dL\xf8\xe7\
-\xfe\xdak2\xf9\xbcj\xf3\x8e\x10\xd6#]G3\xd9\
-wr\xcd\xaf\x02\xdb\x85\x02/\xb2\xefT\x9bw\x84P\
-|\x9e\xbf\x9a\xcb\x95<(\xe1\xaf7\xf9\x9c\xea\x91Z\
-\x1frnc\x91\x8fz\xd5#\xef\xe2L\xad\x1c\x00\x11\
-L\x12\x00\xcdI\x004'\x01\xd0\x9c\x04 \x88\x0c\xf5\
-;v\xe5\x00\x98\xeao\x90\x8b\x0asS\xab\x10\xd6{\
-\xe5\x8e\x9a\xccp\xa9\xe3\x11>k6\xc3\xaa\x09X\x0f\
-a-*p\xd4\x1d\xad%b\xc8Y\xe0\xa8\x8b\x18\x06\
-\xa7\xa2\xb5\xaa\x85\x9a\x0fa\xad(q\x143M\x06\xe3\
--\xa5\x8fL\xf8b0\xdeB\xccT~\x1a8\x1f\xc2\
-ZN\xa4d\xa4\xad\x93D\xb4\xa6\xa4\x81\x89\xf2\xfb \
-Z\xc3\xb7\xed]n6y\x1a\xc2ZK\xa6$n\x86\
-\x19\xef\xeca(\xdeZ\x90\xcb\xc1\xd1\x111\x0c\x86\x1a\
-Z\x19\xef:C\xbd\xbb7\x83\xee\x14_\x09[\xc2\xe5\
-\xba\xbf\x5c\xa1@\x81\x02\x11C>IV\xd2v\xa1@\
-\x080\xdd\x1f\x90i\xa0=d\xaf\x22\x9dp\xbbu\xd8\
-0\xa4\xf8G@\xc40J)>\xc0D\x22\x95\xcc\x16\
-+8\x8a\xf5\xaa\xb0\xd0C\x06\xab\xe6\xd6D\x90\xbdh\
-p\xac\x92#\x12\xbe\x1a+.\x14\xdd}\x0e\xbf\x85\xb5\
-\x8aT\x04\xdb\x14V\xad\x01Y\x1c\xaa\x9b\xf7/\x0e\x85\
-\x7f/\x05\xc3XK\x89E\xb0\xcc\x01\x97v\x17\x1f\xf6\
-y\x18d\xaf\x1f\xef\x03\x1e\xfa40Q~SXG\
-\xfe\xf4\xde\x7f\xd8\xf7s\x9c}&\xb8\x88}\xa7(\xaa\
-\xda\x0d``\xef\x91_\xa4\xf2%Q\xf26h\x15K\
-\xa4\x92\x07\xd6Xfr4\xe7\xd9C\xfe\xdf\xd7\xdf\xf0\
-\xfd\xab\xbf\xbc\xda\x9d8\xc0\x8d\x93\x1f\xf2Y}\xb3'\
-\xfb\x923\x80\xe6$\x00\x9a\x93\x00hN\x02\xa09\x09\
-\x80\xe6$\x00\x9a\x93\x00hN\x02\xa09\x09\x80\xe6$\
-\x00\x9a\x93\x00hN\x02\xa09\x09\x80\xe6$\x00\x9a\x93\
-\x00h.0\x8b\xfe\x0d`\xb8\xb1\x8d\xfeX\x13\xa7\x8f\
-\xd5\xd1\x1a\x8ex\xba\xff\x95\xdc6\xcf\xb76HfV\
-\x99|\xbb\x1c\x98/\xcd\x0bD\x00Z\xc3\x11\xae\x1d\xef\
-\xe6\x5c\x9d\xabo\xbcw\xdd\xc7\xf9p#\xe7c\x8d\x0c\
-\xd6\xb7psq\x86\x95\xdcv\xd9\xfa\xf3K\xd5_\x02\
-\x0c({\xf1\xf7:W\x17\xe7\xda\xf1\xee@|iN\
-\xd5\x07`\xb8\xb1\xcd\xd7\xe2\x17\x9d\xab\x8b3\xdc\xd0\xe6\
-{\xbf^\xab\xfa\x00\xf4\xc7\x9a*\xd6\xf7\x85\xfa\xca\xf5\
-\xed\x95\xaa\x0f\xc0\xe9cu\x15\xeb\xbb\xa7\x82}{\xa5\
-\xea\x03 \x0e\xa7\xea\x03\xf0|k\xa3b}?\xab`\
-\xdf^\xa9\xfa\x00$3\xab\x15\xeb\xfb\xc1z\xe5\xfa\xf6\
-J\xd5\x07`\xf2\xed2\x8f7\xd6|\xef\xf7\xf1\xc6\x1a\
-\x93\xe9e\xdf\xfb\xf5Z\xd5\x07\xa0\x00\xdc\x5c\x9c\xf15\
-\x04\x8f7\xd6\xf8aa&\x10\xb3\x81\x81\x98\x09\x5c\xc9\
-ms\xf5\xef\xe7\x0c7\xb4q\xa1\xbe\x89\x9e2M\x05\
-?\xdb\xda\xe0\xc1\xfa*\x93i\x99\x0a>r\x0a\xc0\xbd\
-\xf42\xf7\x02pZ\xf6S\xd5_\x02\xc4\xe1H\x004\
-'\x01\xd0\x9c\x04@s\x12\x00\xcdI\x004'\x01\xd0\
-\x9c\x04@s\x12\x00\xcdI\x004'\x01\xd0\x9c\x04@\
-s\x12\x00\xcdI\x004'\x01\xd0\x9c\x04@s\x12\x00\
-\xcdI\x004'\x01\xd0\x9c\x04@s\x12\x00\xcdI\x00\
-4'\x01\xd0\x9c\x04@s\x12\x00\xcdI\x004\xe7Y\
-\x00\xcc@|eRu\xf0\xf2w\xad\x12\x80u\x95\x1d\
-5\x99\x81Yfx\xe45\xab/|u\xac\x9dJ\x00\
-^\xa9\xf4\xd4\x1d\xad%\x8c\x11\x94E\xb3GV\xc40\
-8\x15\xadQm>\xef\xd4@%\x00\x8e;\x01\x88\x99\
-&_4\xb4H\x00\xcal0\xdeB,d\xaa6\xf7\
-$\x00OT{\x1bi\xeb4\x12\x91\x9a\x1d\xd5\xf6\xc2\
-\x9d\x0f\xa25\x8c\xb4u\xba\xd9\xe4\xa9S\x03\x95\x00\xdc\
-U\xed-n\x86\x8d\xf1\xae\x9e\xd0P\xbc\xb5\x101\xe4\
-\xa6\xd0+\x11\xc3`\xa8\xa1\x95\x1f;{\x88\xbb\xbb\xd7\
-\xba\xe3\xd4@\xe5\xbf\x8f\x8f\x02K@\x83\x9b\x9e3;\
-y^d7y\x93\xdb&\x1f\x98\xef\xd3\xf0\x97\x89A\
-s8\xc2\xa9h\x8d\x9b\xd3~Q\x1ahO\xa4\x92\xd9\
-\x83\x1a)\x1d\xa6\xb3\xbd\xfd?\x01_\xbb\x1d\x81\xa8\xa8\
-\x9f\x13\xa9\xe47N\x8dT\xe7\x01F\x81\xcc\xe1\xc6#\
-|\x94\xc1\xaa\x99#\xa5\x00$R\xc9\x05`\xec0#\
-\x12\xbe\x1a\xb3k\xe6\xc8\xcdL\xe0-`\xaa\xb4\xf1\x08\
-\x1fMa\xd5J\x89\xab[\xf5\xd9\xde\xfe\x13\xc0#\xc0\
-\xd5g\x11\xe1\x9b9\xa0/\x91J.\xaan\xe0\xeaY\
-\x80}Z\x19\x06^\xba\x1c\x98(\xbf9\xe0\x92\x9b\xe2\
-C\x09\x0f\x83\x12\xa9\xe44\xd0\x074\xb5\x8e\xf5Pg\x1ekn\xff\x0e\xf0\
-\x9b\xd3\x0c\x9f\x10B\x08!\x84\x10B\x08!\xc4^\xff\
-\x00u\xf7\x9a\xe8\x02\xe0|U\x00\x00\x00\x00IEN\
-D\xaeB`\x82\
-"
-
-qt_resource_name = b"\
-\x00\x06\
-\x07\x03}\xc3\
-\x00i\
-\x00m\x00a\x00g\x00e\x00s\
-\x00\x18\
-\x07\xf3~\xc7\
-\x00s\
-\x00t\x00a\x00r\x00_\x00i\x00c\x00o\x00n\x00_\x00f\x00i\x00l\x00l\x00e\x00d\x00_\
-\x001\x002\x008\x00.\x00p\x00n\x00g\
-\x00\x17\
-\x01\xdaN\xc7\
-\x00b\
-\x00o\x00x\x00_\x00i\x00c\x00o\x00n\x00_\x00f\x00i\x00l\x00l\x00e\x00d\x00_\x001\
-\x002\x008\x00.\x00p\x00n\x00g\
-\x00\x17\
-\x00\xe8\xfcG\
-\x00s\
-\x00t\x00a\x00r\x00_\x00i\x00c\x00o\x00n\x00_\x00e\x00m\x00p\x00t\x00y\x00_\x001\
-\x002\x008\x00.\x00p\x00n\x00g\
-\x00\x16\
-\x03\x0aoG\
-\x00b\
-\x00o\x00x\x00_\x00i\x00c\x00o\x00n\x00_\x00e\x00m\x00p\x00t\x00y\x00_\x001\x002\
-\x008\x00.\x00p\x00n\x00g\
-"
-
-qt_resource_struct = b"\
-\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
-\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x00\x00\x00\x02\x00\x00\x00\x04\x00\x00\x00\x02\
-\x00\x00\x00\x00\x00\x00\x00\x00\
-\x00\x00\x00|\x00\x00\x00\x00\x00\x01\x00\x005'\
-\x00\x00\x01\x93MeU\xc5\
-\x00\x00\x00H\x00\x00\x00\x00\x00\x01\x00\x00\x22\x83\
-\x00\x00\x01\x93MeU\xc3\
-\x00\x00\x00\xb0\x00\x00\x00\x00\x00\x01\x00\x00O\x02\
-\x00\x00\x01\x93MeU\xc3\
-\x00\x00\x00\x12\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
-\x00\x00\x01\x93MeU\xc5\
-"
-
-def qInitResources():
- QtCore.qRegisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data)
-
-def qCleanupResources():
- QtCore.qUnregisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data)
-
-qInitResources()
diff --git a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
index 3fd065b08e..7bb4b53511 100644
--- a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
+++ b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
@@ -12,12 +12,11 @@
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QLayout, QLayoutItem, QScrollArea
-from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.enums import ItemType
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
from tagstudio.previews.media_probe import can_have_duration, get_duration
-from tagstudio.qt.mixed.item_thumb import BadgeType, ItemThumb
+from tagstudio.qt.mixed.item_thumb import ItemThumb
from tagstudio.qt.qt_file_renderer import QtFileRenderer
if TYPE_CHECKING:
@@ -43,8 +42,6 @@ def __init__(self, driver: QtDriver, scroll_area: QScrollArea, bottom_padding: i
self._entry_ids: list[int] = []
self._entries: dict[int, Entry] = {}
- # Tag.id -> {Entry.id}
- self._tag_entries: dict[int, set[int]] = {}
self._entry_paths: dict[Path, int] = {}
# Entry.id -> _items[index]
self._entry_items: dict[int, int] = {}
@@ -53,6 +50,7 @@ def __init__(self, driver: QtDriver, scroll_area: QScrollArea, bottom_padding: i
self._renderer: QtFileRenderer = QtFileRenderer(self.driver.lib, self.driver.settings)
self._renderer.updated.connect(self._on_rendered)
self._render_cutoff: float = 0.0
+ self.driver.entry_state.tags_changed.connect(self._on_tags_changed)
self._durations: dict[Path, float | None] = {}
self._pending_durations: set[Path] = set()
@@ -69,7 +67,7 @@ def scroll_to(self, entry_id: int):
def set_entries(self, entry_ids: list[int]):
self._entry_ids = entry_ids
self._entries.clear()
- self._tag_entries.clear()
+ self.driver.entry_state.unload_badges()
self._entry_paths.clear()
self._entry_items.clear()
@@ -104,13 +102,14 @@ def update_selected(self):
value = item_thumb.item_id in self.driver._selected
item_thumb.thumb_button.set_selected(value)
- def add_tags(self, entry_ids: Iterable[int], tag_ids: Iterable[int]):
- for tag_id in tag_ids:
- self._tag_entries.setdefault(tag_id, set()).update(entry_ids)
+ def _assign_badges(self, item_thumb: ItemThumb, entry_id: int) -> None:
+ for tag_id in self.driver.entry_state.badge_tag_ids:
+ item_thumb.assign_badge(tag_id, self.driver.entry_state.has_badge(entry_id, tag_id))
- def remove_tags(self, entry_ids: Iterable[int], tag_ids: Iterable[int]):
- for tag_id in tag_ids:
- self._tag_entries.setdefault(tag_id, set()).difference_update(entry_ids)
+ def _on_tags_changed(self, entry_ids: set[int]) -> None:
+ for item_thumb in self._item_thumbs:
+ if item_thumb.item_id in entry_ids:
+ self._assign_badges(item_thumb, item_thumb.item_id)
def _fetch_entries(self, ids: Iterable[int]):
ids = [id for id in ids if id not in self._entries]
@@ -119,10 +118,7 @@ def _fetch_entries(self, ids: Iterable[int]):
self._entry_paths[unwrap(self.driver.lib.library_dir) / entry.path] = entry.id
self._entries[entry.id] = entry
- tag_ids = [TAG_ARCHIVED, TAG_FAVORITE]
- tag_entries = self.driver.lib.get_tag_entries(tag_ids, ids)
- for tag_id, entries in tag_entries.items():
- self._tag_entries.setdefault(tag_id, set()).update(entries)
+ self.driver.entry_state.load_badges(ids)
def _on_rendered(self, timestamp: float, image: QPixmap, size: QSize, file_path: Path):
if timestamp < self._render_cutoff:
@@ -365,9 +361,7 @@ def setGeometry(self, arg__1: QRect) -> None:
item_index = self._entry_items[entry_id]
item_thumb = self._item_thumbs[item_index]
item_thumb.thumb_button.set_selected(entry_id in self.driver._selected)
-
- item_thumb.assign_badge(BadgeType.ARCHIVED, entry_id in self._tag_entries[TAG_ARCHIVED])
- item_thumb.assign_badge(BadgeType.FAVORITE, entry_id in self._tag_entries[TAG_FAVORITE])
+ self._assign_badges(item_thumb, entry_id)
@override
def addItem(self, arg__1: QLayoutItem) -> None:
diff --git a/tests/conftest.py b/tests/conftest.py
index cd83063baf..e463281303 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -27,6 +27,7 @@
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.library.ignore import GLOBAL_IGNORE, Ignore, IgnoreMatcher
+from tagstudio.qt.entry_state import EntryState
from tagstudio.qt.qt_driver import QtDriver
from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout
@@ -203,6 +204,8 @@ class Args:
patch("tagstudio.qt.qt_driver.QThreadPool"),
):
driver = QtDriver(Args()) # pyright: ignore[reportArgumentType]
+ driver.lib = library
+ driver.entry_state = EntryState(library)
driver.app = Mock()
driver.main_window = Mock()
@@ -212,7 +215,6 @@ class Args:
driver.copy_buffer = {"fields": [], "tags": []}
- driver.lib = library
# TODO - downsize this method and use it
# driver.start()
driver.frame_content = [e.id for e in library.all_entries()]
diff --git a/tests/core/macros/test_folders_tags.py b/tests/core/macros/test_folders_tags.py
index c3fd32becb..b8ae7d1ce5 100644
--- a/tests/core/macros/test_folders_tags.py
+++ b/tests/core/macros/test_folders_tags.py
@@ -3,10 +3,11 @@
from tagstudio.core.library.alchemy.library import Library
+from tagstudio.qt.entry_state import EntryState
from tagstudio.qt.mixed.folders_to_tags import folders_to_tags
def test_folders_to_tags(library: Library):
- folders_to_tags(library)
+ folders_to_tags(library, EntryState(library))
entry = [x for x in library.all_entries(with_joins=True) if "bar.md" in str(x.path)][0]
assert {x.name for x in entry.tags} == {"two", "bar"}
diff --git a/tests/qt/test_entry_state.py b/tests/qt/test_entry_state.py
new file mode 100644
index 0000000000..fb47e81ac7
--- /dev/null
+++ b/tests/qt/test_entry_state.py
@@ -0,0 +1,62 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+# pyright: reportPrivateUsage=false
+
+
+from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
+from tagstudio.core.library.alchemy.enums import ItemType
+from tagstudio.core.library.alchemy.library import Library
+from tagstudio.qt.entry_state import EntryState
+from tagstudio.qt.mixed.item_thumb import ItemThumb
+from tagstudio.qt.qt_driver import QtDriver
+
+
+def test_badge_tags_update_state_and_emit(library: Library):
+ entry_state = EntryState(library)
+ changed: list[set[int]] = []
+ entry_state.tags_changed.connect(changed.append)
+
+ entry_state.add_tags([1, 2], TAG_FAVORITE)
+ assert entry_state.has_badge(1, TAG_FAVORITE)
+ assert entry_state.has_badge(2, TAG_FAVORITE)
+ assert library.get_tag_entries([TAG_FAVORITE], [1, 2])[TAG_FAVORITE] == {1, 2}
+ assert changed == [{1, 2}]
+
+ entry_state.remove_tags(2, TAG_FAVORITE)
+ assert entry_state.has_badge(1, TAG_FAVORITE)
+ assert not entry_state.has_badge(2, TAG_FAVORITE)
+ assert changed[-1] == {2}
+
+
+def test_non_badge_tags_emit(library: Library):
+ entry_state = EntryState(library)
+ changed: list[set[int]] = []
+ entry_state.tags_changed.connect(changed.append)
+
+ non_badge_tag = 2000
+ entry_state.add_tags(1, non_badge_tag)
+ assert changed == [{1}]
+
+
+def test_unload_badges_rereads_from_library(library: Library):
+ entry_state = EntryState(library)
+ library.add_tags_to_entries(1, TAG_ARCHIVED)
+ entry_state.unload_badges()
+ assert entry_state.has_badge(1, TAG_ARCHIVED)
+
+
+def test_badge_click_updates_whole_selection(qt_driver: QtDriver):
+ thumbs: list[ItemThumb] = []
+ for entry_id in (1, 2):
+ thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100))
+ thumb.set_item_id(entry_id)
+ thumbs.append(thumb)
+ qt_driver.main_window.thumb_layout._item_thumbs.extend(thumbs)
+
+ qt_driver.toggle_item_selection(1, append=False, bridge=False)
+ qt_driver.toggle_item_selection(2, append=True, bridge=False)
+ thumbs[0].badges[TAG_FAVORITE].setChecked(True)
+
+ assert all(thumb.badges[TAG_FAVORITE].isChecked() for thumb in thumbs)
+ assert qt_driver.lib.get_tag_entries([TAG_FAVORITE], [1, 2])[TAG_FAVORITE] == {1, 2}
diff --git a/tests/qt/test_inspector.py b/tests/qt/test_inspector.py
index f5c42fa6d6..27356dc363 100644
--- a/tests/qt/test_inspector.py
+++ b/tests/qt/test_inspector.py
@@ -3,8 +3,13 @@
# pyright: reportPrivateUsage=false
+from unittest.mock import patch
+
+from tagstudio.core.constants import TAG_FAVORITE
from tagstudio.core.library.alchemy.models import Entry
+from tagstudio.core.utils.types import unwrap
from tagstudio.qt.controllers.inspector import Inspector
+from tagstudio.qt.controllers.tiles.tag_data import TagData
from tagstudio.qt.qt_driver import QtDriver
@@ -47,3 +52,31 @@ def test_update_selection_multiple(qt_driver: QtDriver):
# Panel should enable UI that allows for entry modification
assert panel.layout().add_tag_button.isEnabled() == panel.layout().add_field_button.isEnabled()
assert panel.layout().add_tag_button.isEnabled() and panel.layout().add_field_button.isEnabled()
+
+
+def test_tag_changes_refresh_once(qt_driver: QtDriver, entry_full: Entry):
+ panel = Inspector(qt_driver)
+ qt_driver.toggle_item_selection(entry_full.id, append=False, bridge=False)
+ panel.set_selection(qt_driver.selected)
+ containers = panel.containers
+
+ def shown_tag_ids() -> set[int]:
+ return {tag.id for tag in containers.cached_entries[0].tags}
+
+ with patch.object(
+ containers, "update_from_entry", wraps=containers.update_from_entry
+ ) as refresh:
+ panel.layout().tag_search_box.item_chosen.emit(2000)
+ assert refresh.call_count == 1
+ assert shown_tag_ids() == {1000, 2000}
+ assert set(panel.layout().tag_search_box.added) == {1000, 2000}
+
+ tag_data = containers._tiles[0].get_inner_widget()
+ assert isinstance(tag_data, TagData)
+ tag_data._on_remove(unwrap(qt_driver.lib.get_tag(2000)))
+ assert refresh.call_count == 2
+ assert shown_tag_ids() == {1000}
+
+ qt_driver.entry_state.add_tags(entry_full.id, TAG_FAVORITE)
+ assert refresh.call_count == 3
+ assert shown_tag_ids() == {1000, TAG_FAVORITE}
diff --git a/tests/qt/test_item_thumb.py b/tests/qt/test_item_thumb.py
index 0506c97b30..c179c104d5 100644
--- a/tests/qt/test_item_thumb.py
+++ b/tests/qt/test_item_thumb.py
@@ -4,8 +4,9 @@
import pytest
+from tagstudio.core.constants import TAG_FAVORITE
from tagstudio.core.library.alchemy.enums import ItemType
-from tagstudio.qt.mixed.item_thumb import BadgeType, ItemThumb
+from tagstudio.qt.mixed.item_thumb import ItemThumb
from tagstudio.qt.qt_driver import QtDriver
@@ -18,8 +19,7 @@ def test_badge_visual_state(qt_driver: QtDriver, entry_min: int, new_value: bool
qt_driver.frame_content = [entry_min]
qt_driver.toggle_item_selection(0, append=False, bridge=False)
- thumb.badges[BadgeType.FAVORITE].setChecked(new_value)
- assert thumb.badges[BadgeType.FAVORITE].isChecked() == new_value
+ thumb.badges[TAG_FAVORITE].setChecked(new_value)
+ assert thumb.badges[TAG_FAVORITE].isChecked() == new_value
# TODO
# assert thumb.favorite_badge.isHidden() == initial_state
- assert thumb.is_favorite == new_value
From 143ddfb41d048d09efbc416425eee1b8cca0c501 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sat, 10 Oct 2026 18:53:44 -0700
Subject: [PATCH 03/10] refactor: MVC split ItemThumb and absorb ThumbButton
---
src/tagstudio/qt/controllers/item_thumb.py | 384 ++++++++++++++
src/tagstudio/qt/mixed/item_thumb.py | 470 ------------------
src/tagstudio/qt/qt_driver.py | 8 +-
src/tagstudio/qt/views/item_thumb_view.py | 166 +++++++
.../qt/views/layouts/thumb_grid_layout.py | 9 +-
src/tagstudio/qt/views/styles/palette.py | 12 +-
src/tagstudio/qt/views/styles/stylesheets.py | 68 ++-
src/tagstudio/qt/views/thumb_button.py | 96 ----
tests/qt/test_entry_state.py | 6 +-
tests/qt/test_item_thumb.py | 8 +-
10 files changed, 635 insertions(+), 592 deletions(-)
create mode 100644 src/tagstudio/qt/controllers/item_thumb.py
delete mode 100644 src/tagstudio/qt/mixed/item_thumb.py
create mode 100644 src/tagstudio/qt/views/item_thumb_view.py
delete mode 100644 src/tagstudio/qt/views/thumb_button.py
diff --git a/src/tagstudio/qt/controllers/item_thumb.py b/src/tagstudio/qt/controllers/item_thumb.py
new file mode 100644
index 0000000000..db6f08149c
--- /dev/null
+++ b/src/tagstudio/qt/controllers/item_thumb.py
@@ -0,0 +1,384 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from pathlib import Path
+from typing import TYPE_CHECKING, override
+
+import structlog
+from PySide6.QtCore import QEvent, QMimeData, QPoint, QRect, QRectF, QSignalBlocker, QSize, Qt, QUrl
+from PySide6.QtGui import (
+ QColor,
+ QContextMenuEvent,
+ QDrag,
+ QEnterEvent,
+ QGuiApplication,
+ QMouseEvent,
+ QPainter,
+ QPainterPath,
+ QPaintEvent,
+ QPen,
+ QPixmap,
+)
+from PySide6.QtWidgets import QApplication, QMenu, QWidget
+
+from tagstudio.core.library.alchemy.enums import ItemType
+from tagstudio.core.library.alchemy.library import Library
+from tagstudio.core.media_types import MediaTypes
+from tagstudio.core.query_lang.file_groups import SEARCH
+from tagstudio.core.utils.decompression import get_display_ext
+from tagstudio.core.utils.str_formatting import format_duration
+from tagstudio.core.utils.types import unwrap
+from tagstudio.qt.utils.file_opener import FileOpenerHelper
+from tagstudio.qt.views.item_thumb_view import ItemThumbView
+from tagstudio.qt.views.styles.palette import Palette
+from tagstudio.qt.views.styles.stylesheets import (
+ RADIUS,
+ item_thumb_faded_select_color,
+ item_thumb_filename_style,
+ item_thumb_hover_color,
+ item_thumb_label_style,
+)
+
+if TYPE_CHECKING:
+ from tagstudio.core.library.alchemy.models import Entry
+ from tagstudio.qt.qt_driver import QtDriver
+
+logger = structlog.get_logger(__name__)
+
+
+class ItemThumb(QWidget):
+ """The thumbnail widget for a library item (Entry, Entry Group, etc.)."""
+
+ def __init__(
+ self,
+ mode: ItemType | None,
+ library: Library,
+ driver: QtDriver,
+ thumb_size: tuple[int, int],
+ show_filename_label: bool = False,
+ ):
+ super().__init__()
+ self._driver = driver
+ self._lib = library
+ self._opener = FileOpenerHelper(Path())
+
+ self._hover_color = item_thumb_hover_color()
+ self._select_color = Palette.accent_alt()
+ self._select_color_faded = item_thumb_faded_select_color()
+
+ self._image_hovered: bool = False
+ self._image: QPixmap | None = None
+ self._item_path: Path | None = None
+ self._mode: ItemType | None = mode
+ self._press_pos: QPoint | None = None
+ self._select_on_release: bool = False
+ self._selected: bool = False
+ self._show_filename_label: bool = show_filename_label
+ self._thumb_size: tuple[int, int] = thumb_size
+ self.item_id: int = -1
+ self.rendered_path: Path | None = None
+
+ self.setMouseTracking(True)
+ self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
+ self.setFixedSize(*thumb_size)
+ self.setLayout(ItemThumbView(driver.entry_state.badge_tag_ids))
+ self._connect_callbacks()
+ self.set_filename_visibility(show_filename_label)
+ self.set_mode(mode)
+
+ @override
+ def layout(self) -> ItemThumbView:
+ return super().layout() # pyright: ignore[reportReturnType]
+
+ def _connect_callbacks(self) -> None:
+ view = self.layout()
+ view.open_file_action.triggered.connect(self._opener.open_file)
+ view.open_explorer_action.triggered.connect(self._opener.open_explorer)
+ view.delete_action.triggered.connect(self._on_delete)
+ for tag_id, badge in view.badges.items():
+ badge.stateChanged.connect(lambda _, t=tag_id: self._on_badge_check(t))
+
+ def _on_delete(self) -> None:
+ if self.item_id != -1 and self._item_path is not None:
+ self._driver.delete_files_callback(self._item_path, self.item_id)
+
+ def _toggle_item_selection(self) -> None:
+ self._driver.toggle_item_selection(
+ self.item_id,
+ append=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier),
+ bridge=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier),
+ )
+
+ def _image_rect(self) -> QRect:
+ return QRect(0, 0, *self._thumb_size)
+
+ def _set_image_hovered(self, hovered: bool) -> None:
+ if hovered == self._image_hovered:
+ return
+ self._image_hovered = hovered
+ self._show_check_badges(show=hovered)
+ if hovered:
+ self.setCursor(Qt.CursorShape.PointingHandCursor)
+ else:
+ self.unsetCursor()
+ self.update()
+
+ def set_mode(self, mode: ItemType | None) -> None:
+ view = self.layout()
+ if mode is None:
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True)
+ self._set_image_hovered(hovered=False)
+ elif mode == ItemType.ENTRY:
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
+ view.badge_container.setHidden(False)
+ # Count Label depends on file extension (video length, word count)
+ view.item_type_icon.setHidden(True)
+ view.count_label.setStyleSheet(item_thumb_label_style())
+ view.count_label.setHidden(True)
+ view.ext_label.setHidden(True)
+ elif mode == ItemType.ENTRY_GROUP:
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
+ view.badge_container.setHidden(True)
+ view.ext_label.setHidden(True)
+ view.count_label.setStyleSheet(item_thumb_label_style(is_large=True))
+ view.count_label.setHidden(False)
+ view.item_type_icon.setHidden(False)
+ self._mode = mode
+ self.update()
+
+ def set_extension(self, filename: Path) -> None:
+ show_ext_label = False
+ show_count_label = False
+
+ # TODO: Make these configurable by the user (on, off, or custom)
+ ext = get_display_ext(filename)
+ if ext and ext.startswith(".") is False:
+ ext = "." + ext
+ if (
+ not MediaTypes.contains("image.raster", ext, SEARCH)
+ or MediaTypes.contains("image.raster.raw", ext, SEARCH)
+ or MediaTypes.contains("image.vector", ext, SEARCH)
+ or MediaTypes.contains("adobe.photoshop", ext, SEARCH)
+ or ext
+ in [
+ ".apng",
+ ".avif",
+ ".exr",
+ ".gif",
+ ".jxl",
+ ".webp",
+ ]
+ ) and (ext or filename.stem):
+ self.layout().ext_label.setText(ext.upper()[1:] or filename.stem.upper())
+ show_ext_label = True
+
+ if MediaTypes.contains("video", ext, SEARCH) or MediaTypes.contains("audio", ext, SEARCH):
+ show_count_label = True
+
+ self.layout().ext_label.setHidden(not show_ext_label)
+ self.layout().count_label.setHidden(not show_count_label)
+
+ def set_count(self, count: str) -> None:
+ view = self.layout()
+ if count:
+ view.count_label.setHidden(False)
+ view.count_label.setText(count)
+ else:
+ if self._mode == ItemType.ENTRY:
+ view.ext_label.setHidden(True)
+ view.count_label.setHidden(True)
+
+ def set_duration(self, duration: float | None) -> None:
+ """Show a media duration on the count label, or "-:--" while it's unknown."""
+ count_label = self.layout().count_label
+ if duration is None:
+ count_label.setText("-:--")
+ else:
+ count_label.setText(format_duration(duration))
+ count_label.setHidden(False)
+
+ def set_filename_text(self, filename: Path):
+ self.layout().file_label.setText(str(filename.name))
+
+ def set_filename_visibility(self, set_visible: bool):
+ """Toggle the visibility of the filename label.
+
+ Args:
+ set_visible (bool): Show the filename, true or false.
+ """
+ file_label = self.layout().file_label
+ if set_visible:
+ if file_label.isHidden():
+ file_label.setHidden(False)
+ self.setFixedHeight(
+ self._thumb_size[1]
+ + ItemThumbView.FILE_LABEL_HEIGHT
+ + ItemThumbView.FILE_LABEL_SPACING
+ )
+ else:
+ file_label.setHidden(True)
+ self.setFixedHeight(self._thumb_size[1])
+ self._show_filename_label = set_visible
+
+ def set_selected(self, value: bool) -> None:
+ if value != self._selected:
+ self._selected = value
+ self.layout().file_label.setStyleSheet(item_thumb_filename_style(is_selected=value))
+ self.update()
+
+ def set_thumb_size(self, size: int) -> None:
+ self._thumb_size = (size, size)
+ self._image = None
+ self.setFixedSize(size, size)
+ self.set_filename_visibility(self._show_filename_label)
+ self.update()
+
+ def set_image(self, image: QPixmap, size: QSize, file_path: Path) -> None:
+ self._image = image
+ self._thumb_size = size.width(), size.height()
+ self.rendered_path = file_path
+ self.update()
+
+ def set_item(self, entry: Entry):
+ self.set_item_id(entry.id)
+ self._item_path = unwrap(self._lib.library_dir) / entry.path
+ self._opener.set_filepath(self._item_path)
+
+ def set_item_id(self, item_id: int):
+ self.item_id = item_id
+
+ def assign_badge(self, tag_id: int, value: bool) -> None:
+ badge = self.layout().badges[tag_id]
+ if badge.isChecked() != value:
+ with QSignalBlocker(badge):
+ badge.setChecked(value)
+ badge.setHidden(not value)
+
+ def _show_check_badges(self, show: bool):
+ if self._mode != ItemType.TAG_GROUP:
+ for badge in self.layout().badges.values():
+ badge.setHidden(not (show or badge.isChecked()))
+
+ def _on_badge_check(self, tag_id: int):
+ selected = self._driver.selected
+ entry_ids = selected if self.item_id in selected else [self.item_id]
+ is_checked = self.layout().badges[tag_id].isChecked()
+ self._driver.entry_state.set_tag(entry_ids, tag_id, is_checked)
+
+ def _start_drag(self) -> None:
+ drag = QDrag(self._driver)
+ paths: list[QUrl] = []
+ mimedata = QMimeData()
+
+ selected_ids = self._driver.selected
+
+ for entry_id in selected_ids:
+ entry = self._lib.get_entry(entry_id)
+ if not entry:
+ continue
+
+ url = QUrl.fromLocalFile(Path(unwrap(self._lib.library_dir)) / entry.path)
+ paths.append(url)
+
+ mimedata.setUrls(paths)
+ drag.setMimeData(mimedata)
+ drag.exec(Qt.DropAction.CopyAction)
+ logger.info("[ItemThumb] Dragging Files:", entry_ids=selected_ids)
+
+ @override
+ def paintEvent(self, event: QPaintEvent) -> None:
+ if self._mode is None:
+ return
+
+ painter = QPainter(self)
+ rect = self._image_rect()
+ if self._image is not None:
+ painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform)
+ painter.drawPixmap(rect, self._image)
+
+ if self._image_hovered or self._selected:
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ width = 3
+ path = QPainterPath()
+ path.addRoundedRect(
+ QRectF(width / 2, width / 2, rect.width() - width, rect.height() - width),
+ RADIUS,
+ RADIUS,
+ )
+
+ if self._selected:
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_HardLight)
+ painter.setPen(QPen(self._select_color_faded, width))
+ painter.fillPath(path, self._select_color_faded)
+ painter.drawPath(path)
+
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
+ color: QColor = self._hover_color if self._image_hovered else self._select_color
+ painter.setPen(QPen(color, width))
+ painter.drawPath(path)
+ else:
+ painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
+ painter.setPen(QPen(self._hover_color, width))
+ painter.drawPath(path)
+
+ painter.end()
+
+ @override
+ def enterEvent(self, event: QEnterEvent) -> None:
+ self._set_image_hovered(self._image_rect().contains(event.position().toPoint()))
+ return super().enterEvent(event)
+
+ @override
+ def leaveEvent(self, event: QEvent) -> None:
+ self._set_image_hovered(hovered=False)
+ return super().leaveEvent(event)
+
+ @override
+ def mousePressEvent(self, event: QMouseEvent) -> None:
+ pos = event.position().toPoint()
+ if event.button() != Qt.MouseButton.LeftButton or not self._image_rect().contains(pos):
+ return super().mousePressEvent(event)
+
+ self._press_pos = pos
+ ctrl = QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier
+ self._select_on_release = False
+ if ctrl or not self._selected:
+ self._toggle_item_selection()
+ elif len(self._driver.selected) > 1:
+ # Wait for release so clicking a selected item can still drag the existing selection
+ self._select_on_release = True
+
+ @override
+ def mouseReleaseEvent(self, event: QMouseEvent) -> None:
+ if event.button() != Qt.MouseButton.LeftButton or self._press_pos is None:
+ return super().mouseReleaseEvent(event)
+
+ self._press_pos = None
+ if self._select_on_release and self._image_rect().contains(event.position().toPoint()):
+ self._toggle_item_selection()
+
+ @override
+ def mouseMoveEvent(self, event: QMouseEvent) -> None:
+ pos = event.position().toPoint()
+ self._set_image_hovered(self._image_rect().contains(pos))
+ if self._press_pos is None or event.buttons() != Qt.MouseButton.LeftButton:
+ return
+ if (pos - self._press_pos).manhattanLength() < QApplication.startDragDistance():
+ return
+
+ self._press_pos = None
+ self._start_drag()
+
+ @override
+ def contextMenuEvent(self, event: QContextMenuEvent) -> None:
+ if not self._image_rect().contains(event.pos()):
+ return super().contextMenuEvent(event)
+
+ view = self.layout()
+ QMenu.exec(
+ [view.open_file_action, view.open_explorer_action, view.delete_action],
+ event.globalPos(),
+ None,
+ self,
+ )
diff --git a/src/tagstudio/qt/mixed/item_thumb.py b/src/tagstudio/qt/mixed/item_thumb.py
deleted file mode 100644
index 37ff580291..0000000000
--- a/src/tagstudio/qt/mixed/item_thumb.py
+++ /dev/null
@@ -1,470 +0,0 @@
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-
-
-from pathlib import Path
-from typing import TYPE_CHECKING, NamedTuple, override
-
-import structlog
-from PySide6.QtCore import QEvent, QMimeData, QSignalBlocker, QSize, Qt, QUrl
-from PySide6.QtGui import QAction, QDrag, QEnterEvent, QGuiApplication, QMouseEvent, QPixmap
-from PySide6.QtWidgets import QBoxLayout, QCheckBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget
-
-from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
-from tagstudio.core.library.alchemy.enums import ItemType
-from tagstudio.core.library.alchemy.library import Library
-from tagstudio.core.media_types import MediaTypes
-from tagstudio.core.query_lang.file_groups import SEARCH
-from tagstudio.core.utils.decompression import get_display_ext
-from tagstudio.core.utils.str_formatting import format_duration
-from tagstudio.core.utils.types import unwrap
-from tagstudio.i18n.platform_strings import open_file_str, trash_term
-from tagstudio.i18n.translations import Translations
-from tagstudio.qt.utils.file_opener import FileOpenerHelper
-from tagstudio.qt.views.thumb_button import ThumbButton
-
-if TYPE_CHECKING:
- from tagstudio.core.library.alchemy.models import Entry
- from tagstudio.qt.qt_driver import QtDriver
-
-logger = structlog.get_logger(__name__)
-
-
-class _BadgeStyle(NamedTuple):
- name: str
- icon: str
- icon_checked: str
-
-
-_BADGE_STYLES: dict[int, _BadgeStyle] = {
- TAG_FAVORITE: _BadgeStyle("Favorite", "star_icon_empty", "star_icon_filled"),
- TAG_ARCHIVED: _BadgeStyle("Archived", "box_icon_empty", "box_icon_filled"),
-}
-
-
-# TODO: Split to use MVC guidelines.
-class ItemThumb(QWidget):
- """The thumbnail widget for a library item (Entry, Entry Group, etc.)."""
-
- small_text_style = (
- "background-color:rgba(0, 0, 0, 192);"
- "color:#FFFFFF;"
- "font-family:Oxanium;"
- "font-weight:bold;"
- "font-size:12px;"
- "border-radius:3px;"
- "padding-top: 4px;"
- "padding-right: 1px;"
- "padding-bottom: 1px;"
- "padding-left: 1px;"
- )
-
- med_text_style = (
- "background-color:rgba(0, 0, 0, 192);"
- "color:#FFFFFF;"
- "font-family:Oxanium;"
- "font-weight:bold;"
- "font-size:18px;"
- "border-radius:3px;"
- "padding-top: 4px;"
- "padding-right: 1px;"
- "padding-bottom: 1px;"
- "padding-left: 1px;"
- )
-
- filename_style = "font-size:10px;"
-
- def __init__(
- self,
- mode: ItemType | None,
- library: Library,
- driver: QtDriver,
- thumb_size: tuple[int, int],
- show_filename_label: bool = False,
- ):
- super().__init__()
- self.lib = library
- self.mode: ItemType | None = mode
- self.driver = driver
- self.item_id: int = -1
- self.item_path: Path | None = None
- self.rendered_path: Path | None = None
- self._select_on_release: bool = False
- self.thumb_size: tuple[int, int] = thumb_size
- self.show_filename_label: bool = show_filename_label
- self.label_height = 12
- self.label_spacing = 4
- self.setMinimumSize(*thumb_size)
- self.setMaximumSize(*thumb_size)
- self.setMouseTracking(True)
- check_size = 24
- self.setFixedSize(
- thumb_size[0],
- thumb_size[1]
- + ((self.label_height + self.label_spacing) if show_filename_label else 0),
- )
-
- self.thumb_container = QWidget()
- self.base_layout = QVBoxLayout(self)
- self.base_layout.setContentsMargins(0, 0, 0, 0)
- self.base_layout.setSpacing(0)
- self.setLayout(self.base_layout)
-
- # +----------+
- # | ARC FAV| Top Right: Favorite & Archived Badges
- # | |
- # | |
- # |EXT #| Lower Left: File Type or Entry Group Icon
- # +----------+ Lower Right: Entry Group Count, Video Length, or Word Count
- #
- # Filename Underneath: (Optional) Filename
-
- # Thumbnail ============================================================
-
- # +----------+
- # |*--------*|
- # || ||
- # || ||
- # |*--------*|
- # +----------+
- self.thumb_layout = QVBoxLayout(self.thumb_container)
- self.thumb_layout.setObjectName("baseLayout")
- self.thumb_layout.setContentsMargins(0, 0, 0, 0)
-
- # +----------+
- # |[~~~~~~~~]|
- # | |
- # | |
- # | |
- # +----------+
- self.top_layout = QHBoxLayout()
- self.top_layout.setObjectName("topLayout")
- self.top_layout.setContentsMargins(6, 6, 6, 6)
- self.top_container = QWidget()
- self.top_container.setLayout(self.top_layout)
- self.thumb_layout.addWidget(self.top_container)
-
- # +----------+
- # |[~~~~~~~~]|
- # | ^ |
- # | | |
- # | v |
- # +----------+
- self.thumb_layout.addStretch(2)
-
- # +----------+
- # |[~~~~~~~~]|
- # | ^ |
- # | v |
- # |[~~~~~~~~]|
- # +----------+
- self.bottom_layout = QHBoxLayout()
- self.bottom_layout.setObjectName("bottomLayout")
- self.bottom_layout.setContentsMargins(6, 6, 6, 6)
- self.bottom_container = QWidget()
- self.bottom_container.setLayout(self.bottom_layout)
- self.thumb_layout.addWidget(self.bottom_container)
-
- self.thumb_button = ThumbButton(self.thumb_container, thumb_size)
- self.thumb_button.setFlat(True)
- self.thumb_button.setLayout(self.thumb_layout)
- self.thumb_button.setContextMenuPolicy(Qt.ContextMenuPolicy.ActionsContextMenu)
-
- self.opener = FileOpenerHelper(Path())
- open_file_action = QAction(Translations["file.open_file"], self)
- open_file_action.triggered.connect(self.opener.open_file)
- open_explorer_action = QAction(open_file_str(), self)
- open_explorer_action.triggered.connect(self.opener.open_explorer)
-
- self.delete_action = QAction(
- Translations.format("trash.context.ambiguous", trash_term=trash_term()),
- self,
- )
-
- def _on_delete():
- if self.item_id != -1 and self.item_path is not None:
- self.driver.delete_files_callback(self.item_path, self.item_id)
-
- self.delete_action.triggered.connect(lambda checked=False: _on_delete())
-
- self.thumb_button.addAction(open_file_action)
- self.thumb_button.addAction(open_explorer_action)
- self.thumb_button.addAction(self.delete_action)
-
- # Static Badges ========================================================
-
- # Item Type Badge ------------------------------------------------------
- # Used for showing the Entry Group icons.
- # Mutually exclusive with the File Extension Badge.
- self.item_type_badge = QLabel()
- self.item_type_badge.setObjectName("itemBadge")
- # self.item_type_badge.setPixmap(
- # QPixmap.fromImage(
- # ImageQt.ImageQt(
- # ItemThumb.collation_icon_128.resize(
- # (check_size, check_size), Image.Resampling.BILINEAR
- # )
- # )
- # )
- # )
- self.item_type_badge.setMinimumSize(check_size, check_size)
- self.item_type_badge.setMaximumSize(check_size, check_size)
- self.bottom_layout.addWidget(self.item_type_badge)
-
- # File Extension Badge -------------------------------------------------
- # Mutually exclusive with the File Extension Badge.
- self.ext_badge = QLabel()
- self.ext_badge.setObjectName("extBadge")
- self.ext_badge.setStyleSheet(ItemThumb.small_text_style)
- self.bottom_layout.addWidget(self.ext_badge)
- self.bottom_layout.addStretch(2)
-
- # Count Badge ----------------------------------------------------------
- # Used for Entry Group counts, video length, word count, etc.
- self.count_badge = QLabel()
- self.count_badge.setObjectName("countBadge")
- self.count_badge.setText("-:--")
- self.count_badge.setStyleSheet(ItemThumb.small_text_style)
- self.bottom_layout.addWidget(self.count_badge, alignment=Qt.AlignmentFlag.AlignBottom)
- self.top_layout.addStretch(2)
-
- # Intractable Badges ===================================================
- self.cb_container = QWidget()
- self.cb_layout = QHBoxLayout()
- self.cb_layout.setDirection(QBoxLayout.Direction.RightToLeft)
- self.cb_layout.setContentsMargins(0, 0, 0, 0)
- self.cb_layout.setSpacing(6)
- self.cb_container.setLayout(self.cb_layout)
- self.top_layout.addWidget(self.cb_container)
-
- self.badges: dict[int, QCheckBox] = {}
- for tag_id in self.driver.entry_state.badge_tag_ids:
- style = _BADGE_STYLES[tag_id]
- icon = unwrap(self.driver.rm.get_path(style.icon)).as_posix()
- icon_checked = unwrap(self.driver.rm.get_path(style.icon_checked)).as_posix()
- badge = QCheckBox()
- badge.setObjectName(f"{style.name.lower()}_badge")
- badge.setToolTip(style.name)
- badge.setStyleSheet(
- f"QCheckBox::indicator{{width: {check_size}px;height: {check_size}px;}}"
- f'QCheckBox::indicator::unchecked{{image: url("{icon}")}}'
- f'QCheckBox::indicator::checked{{image: url("{icon_checked}")}}'
- )
- badge.setMinimumSize(check_size, check_size)
- badge.setMaximumSize(check_size, check_size)
- badge.setHidden(True)
-
- badge.stateChanged.connect(lambda x, t=tag_id: self.on_badge_check(t))
-
- self.badges[tag_id] = badge
- self.cb_layout.addWidget(badge)
-
- # Filename Label =======================================================
- self.file_label = QLabel(Translations["generic.filename"])
- self.file_label.setStyleSheet(ItemThumb.filename_style)
- self.file_label.setMaximumHeight(self.label_height)
- if not show_filename_label:
- self.file_label.setHidden(True)
-
- self.base_layout.addWidget(self.thumb_container)
- self.base_layout.addWidget(self.file_label)
- # NOTE: self.item_id seems to act as a reference here and does not need to be updated inside
- # QtDriver.update_thumbs() while item_thumb.delete_action does.
- # If this behavior ever changes, move this method back to QtDriver.update_thumbs().
- self.thumb_button.pressed.connect(self._on_thumb_pressed)
- self.thumb_button.clicked.connect(self._on_thumb_clicked)
- self.set_mode(mode)
-
- def toggle_item_selection(self):
- self.driver.toggle_item_selection(
- self.item_id,
- append=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier),
- bridge=(QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ShiftModifier),
- )
-
- def _on_thumb_pressed(self) -> None:
- ctrl = QGuiApplication.keyboardModifiers() == Qt.KeyboardModifier.ControlModifier
- self._select_on_release = False
- if ctrl or not self.thumb_button.selected:
- self.toggle_item_selection()
- elif len(self.driver.selected) > 1:
- # Wait for release so clicking a selected item can still drag the existing selection
- self._select_on_release = True
-
- def _on_thumb_clicked(self) -> None:
- if self._select_on_release:
- self.toggle_item_selection()
-
- def set_mode(self, mode: ItemType | None) -> None:
- if mode is None:
- self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=True)
- self.thumb_button.unsetCursor()
- self.thumb_button.setHidden(True)
- elif mode == ItemType.ENTRY:
- self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
- self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor)
- self.thumb_button.setHidden(False)
- self.cb_container.setHidden(False)
- # Count Badge depends on file extension (video length, word count)
- self.item_type_badge.setHidden(True)
- self.count_badge.setStyleSheet(ItemThumb.small_text_style)
- self.count_badge.setHidden(True)
- self.ext_badge.setHidden(True)
- elif mode == ItemType.ENTRY_GROUP:
- self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, on=False)
- self.thumb_button.setCursor(Qt.CursorShape.PointingHandCursor)
- self.thumb_button.setHidden(False)
- self.cb_container.setHidden(True)
- self.ext_badge.setHidden(True)
- self.count_badge.setStyleSheet(ItemThumb.med_text_style)
- self.count_badge.setHidden(False)
- self.item_type_badge.setHidden(False)
- self.mode = mode
-
- def set_extension(self, filename: Path) -> None:
- show_ext_badge = False
- show_count_badge = False
-
- ext = get_display_ext(filename)
- if ext and ext.startswith(".") is False:
- ext = "." + ext
- if (
- not MediaTypes.contains("image.raster", ext, SEARCH)
- or MediaTypes.contains("image.raster.raw", ext, SEARCH)
- or MediaTypes.contains("image.vector", ext, SEARCH)
- or MediaTypes.contains("adobe.photoshop", ext, SEARCH)
- or ext
- in [
- ".apng",
- ".avif",
- ".exr",
- ".gif",
- ".jxl",
- ".webp",
- ]
- ):
- if ext or filename.stem:
- self.ext_badge.setText(ext.upper()[1:] or filename.stem.upper())
- show_ext_badge = True
- if MediaTypes.contains("video", ext, SEARCH) or MediaTypes.contains(
- "audio", ext, SEARCH
- ):
- show_count_badge = True
-
- self.ext_badge.setHidden(not show_ext_badge)
- self.count_badge.setHidden(not show_count_badge)
-
- def set_count(self, count: str) -> None:
- if count:
- self.count_badge.setHidden(False)
- self.count_badge.setText(count)
- else:
- if self.mode == ItemType.ENTRY:
- self.ext_badge.setHidden(True)
- self.count_badge.setHidden(True)
-
- def set_duration(self, duration: float | None) -> None:
- """Show a media duration on the count badge, or "-:--" while it's unknown."""
- if duration is None:
- self.count_badge.setText("-:--")
- else:
- self.count_badge.setText(format_duration(duration))
- self.count_badge.setHidden(False)
-
- def set_filename_text(self, filename: Path):
- self.file_label.setText(str(filename.name))
-
- def set_filename_visibility(self, set_visible: bool):
- """Toggle the visibility of the filename label.
-
- Args:
- set_visible (bool): Show the filename, true or false.
- """
- if set_visible:
- if self.file_label.isHidden():
- self.file_label.setHidden(False)
- self.setFixedHeight(self.thumb_size[1] + self.label_height + self.label_spacing)
- else:
- self.file_label.setHidden(True)
- self.setFixedHeight(self.thumb_size[1])
- self.show_filename_label = set_visible
-
- def update_thumb(self, image: QPixmap | None = None, file_path: Path | None = None):
- """Update attributes of a thumbnail element."""
- self.thumb_button.setIcon(image if image else QPixmap())
- self.rendered_path = file_path
-
- def update_size(self, size: QSize):
- """Updates attributes of a thumbnail element.
-
- Args:
- size (QSize): The new thumbnail size to set.
- """
- self.thumb_size = size.width(), size.height()
- self.thumb_button.setIconSize(size)
- self.thumb_button.setMinimumSize(size)
- self.thumb_button.setMaximumSize(size)
-
- def set_item(self, entry: Entry):
- self.set_item_id(entry.id)
- path = unwrap(self.lib.library_dir) / entry.path
- self.set_item_path(path)
-
- def set_item_id(self, item_id: int):
- self.item_id = item_id
-
- def set_item_path(self, path: Path):
- """Set the absolute filepath for the item. Used for locating on disk."""
- self.item_path = path
- self.opener.set_filepath(path)
-
- def assign_badge(self, tag_id: int, value: bool) -> None:
- badge = self.badges[tag_id]
- if badge.isChecked() != value:
- with QSignalBlocker(badge):
- badge.setChecked(value)
- badge.setHidden(not value)
-
- def show_check_badges(self, show: bool):
- if self.mode != ItemType.TAG_GROUP:
- for badge in self.badges.values():
- badge.setHidden(not (show or badge.isChecked()))
-
- @override
- def enterEvent(self, event: QEnterEvent) -> None:
- self.show_check_badges(show=True)
- return super().enterEvent(event)
-
- @override
- def leaveEvent(self, event: QEvent) -> None:
- self.show_check_badges(show=False)
- return super().leaveEvent(event)
-
- def on_badge_check(self, tag_id: int):
- selected = self.driver.selected
- entry_ids = selected if self.item_id in selected else [self.item_id]
- self.driver.entry_state.set_tag(entry_ids, tag_id, self.badges[tag_id].isChecked())
-
- @override
- def mouseMoveEvent(self, event: QMouseEvent) -> None:
- if event.buttons() is not Qt.MouseButton.LeftButton:
- return
-
- drag = QDrag(self.driver)
- paths: list[QUrl] = []
- mimedata = QMimeData()
-
- selected_ids = self.driver.selected
-
- for entry_id in selected_ids:
- entry = self.lib.get_entry(entry_id)
- if not entry:
- continue
-
- url = QUrl.fromLocalFile(Path(unwrap(self.lib.library_dir)) / entry.path)
- paths.append(url)
-
- mimedata.setUrls(paths)
- drag.setMimeData(mimedata)
- drag.exec(Qt.DropAction.CopyAction)
- logger.info("[ItemThumb] Dragging Files:", entry_ids=selected_ids)
diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py
index cec20a4aa7..ac41c29622 100644
--- a/src/tagstudio/qt/qt_driver.py
+++ b/src/tagstudio/qt/qt_driver.py
@@ -1267,14 +1267,8 @@ def sorting_mode_callback(self):
def thumb_size_callback(self, size: int):
"""Perform actions needed when the thumbnail size selection is changed."""
self.update_thumbs()
- blank_icon: QIcon = QIcon()
for it in self.main_window.thumb_layout._item_thumbs:
- it.thumb_button.setIcon(blank_icon)
- it.resize(self.main_window.thumb_size, self.main_window.thumb_size)
- it.thumb_size = (self.main_window.thumb_size, self.main_window.thumb_size)
- it.setFixedSize(self.main_window.thumb_size, self.main_window.thumb_size)
- it.thumb_button.thumb_size = (self.main_window.thumb_size, self.main_window.thumb_size)
- it.set_filename_visibility(it.show_filename_label)
+ it.set_thumb_size(self.main_window.thumb_size)
def show_hidden_entries_callback(self):
logger.info("Show Hidden Entries Changed", exclude=self.main_window.show_hidden_entries)
diff --git a/src/tagstudio/qt/views/item_thumb_view.py b/src/tagstudio/qt/views/item_thumb_view.py
new file mode 100644
index 0000000000..a18103b858
--- /dev/null
+++ b/src/tagstudio/qt/views/item_thumb_view.py
@@ -0,0 +1,166 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+from collections.abc import Iterable
+from typing import NamedTuple
+
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QAction
+from PySide6.QtWidgets import QBoxLayout, QCheckBox, QHBoxLayout, QLabel, QVBoxLayout, QWidget
+
+from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
+from tagstudio.core.utils.types import unwrap
+from tagstudio.i18n.platform_strings import open_file_str, trash_term
+from tagstudio.i18n.translations import Translations
+from tagstudio.qt.resource_manager import ResourceManager
+from tagstudio.qt.views.styles.stylesheets import (
+ item_thumb_check_badge_style,
+ item_thumb_filename_style,
+ item_thumb_label_style,
+)
+
+
+class _BadgeStyle(NamedTuple):
+ name: str
+ icon: str
+ icon_checked: str
+
+
+_BADGE_STYLES: dict[int, _BadgeStyle] = {
+ TAG_FAVORITE: _BadgeStyle("Favorite", "star_icon_empty", "star_icon_filled"),
+ TAG_ARCHIVED: _BadgeStyle("Archived", "box_icon_empty", "box_icon_filled"),
+}
+
+_ICON_SIZE = 24
+
+
+class ItemThumbView(QVBoxLayout):
+ FILE_LABEL_HEIGHT = 12
+ FILE_LABEL_SPACING = 4
+
+ def __init__(self, badge_tag_ids: Iterable[int]) -> None:
+ super().__init__()
+ self.setContentsMargins(0, 0, 0, 0)
+ self.setSpacing(self.FILE_LABEL_SPACING)
+
+ # +----------+
+ # | ARC FAV| Top Right: Favorite & Archived Badges
+ # | |
+ # | |
+ # |EXT #| Lower Left: File Type or Entry Group Icon
+ # +----------+ Lower Right: Entry Group Count, Video Length, or Word Count
+ #
+ # Filename Underneath: (Optional) Filename
+
+ # Thumbnail ============================================================
+
+ # +----------+
+ # |*--------*|
+ # || ||
+ # || ||
+ # |*--------*|
+ # +----------+
+ thumb_layout = QVBoxLayout()
+ thumb_layout.setObjectName("baseLayout")
+ thumb_layout.setContentsMargins(0, 0, 0, 0)
+
+ # +----------+
+ # |[~~~~~~~~]|
+ # | |
+ # | |
+ # | |
+ # +----------+
+ top_layout = QHBoxLayout()
+ top_layout.setObjectName("topLayout")
+ top_layout.setContentsMargins(6, 6, 6, 6)
+ thumb_layout.addLayout(top_layout)
+
+ # +----------+
+ # |[~~~~~~~~]|
+ # | ^ |
+ # | | |
+ # | v |
+ # +----------+
+ thumb_layout.addStretch(2)
+
+ # +----------+
+ # |[~~~~~~~~]|
+ # | ^ |
+ # | v |
+ # |[~~~~~~~~]|
+ # +----------+
+ bottom_layout = QHBoxLayout()
+ bottom_layout.setObjectName("bottomLayout")
+ bottom_layout.setContentsMargins(6, 6, 6, 6)
+ thumb_layout.addLayout(bottom_layout)
+
+ self.open_file_action = QAction(Translations["file.open_file"], self)
+ self.open_explorer_action = QAction(open_file_str(), self)
+ self.delete_action = QAction(
+ Translations.format("trash.context.ambiguous", trash_term=trash_term()), self
+ )
+
+ # Static Labels ========================================================
+
+ # Item Type Icon -------------------------------------------------------
+ # Used for showing the Entry Group icons.
+ # Mutually exclusive with the File Extension Label.
+ self.item_type_icon = QLabel()
+ self.item_type_icon.setObjectName("itemTypeIcon")
+ self.item_type_icon.setMinimumSize(_ICON_SIZE, _ICON_SIZE)
+ self.item_type_icon.setMaximumSize(_ICON_SIZE, _ICON_SIZE)
+ bottom_layout.addWidget(self.item_type_icon)
+
+ # File Extension Label -------------------------------------------------
+ # Mutually exclusive with the Item Type Icon.
+ self.ext_label = QLabel()
+ self.ext_label.setObjectName("extLabel")
+ self.ext_label.setStyleSheet(item_thumb_label_style())
+ bottom_layout.addWidget(self.ext_label)
+ bottom_layout.addStretch(2)
+
+ # Count Label ----------------------------------------------------------
+ # Used for Entry Group counts, video length, word count, etc.
+ self.count_label = QLabel()
+ self.count_label.setObjectName("countLabel")
+ self.count_label.setText("-:--")
+ self.count_label.setStyleSheet(item_thumb_label_style())
+ bottom_layout.addWidget(self.count_label, alignment=Qt.AlignmentFlag.AlignBottom)
+ top_layout.addStretch(2)
+
+ # Tag Badge Checkboxes =================================================
+ self.badge_container = QWidget()
+ badge_layout = QHBoxLayout()
+ badge_layout.setDirection(QBoxLayout.Direction.RightToLeft)
+ badge_layout.setContentsMargins(0, 0, 0, 0)
+ badge_layout.setSpacing(6)
+ self.badge_container.setLayout(badge_layout)
+ top_layout.addWidget(self.badge_container)
+
+ rm = ResourceManager()
+ self.badges: dict[int, QCheckBox] = {}
+ for tag_id in badge_tag_ids:
+ style = _BADGE_STYLES[tag_id]
+ badge = QCheckBox()
+ badge.setObjectName(f"{style.name.lower()}_badge")
+ badge.setToolTip(style.name)
+ badge.setStyleSheet(
+ item_thumb_check_badge_style(
+ _ICON_SIZE,
+ unwrap(rm.get_path(style.icon)),
+ unwrap(rm.get_path(style.icon_checked)),
+ )
+ )
+ badge.setMinimumSize(_ICON_SIZE, _ICON_SIZE)
+ badge.setMaximumSize(_ICON_SIZE, _ICON_SIZE)
+ badge.setHidden(True)
+ self.badges[tag_id] = badge
+ badge_layout.addWidget(badge)
+
+ # Filename Label =======================================================
+ self.file_label = QLabel(Translations["generic.filename"])
+ self.file_label.setStyleSheet(item_thumb_filename_style())
+
+ self.addLayout(thumb_layout)
+ self.addWidget(self.file_label, alignment=Qt.AlignmentFlag.AlignLeft)
diff --git a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
index 7bb4b53511..c9787d4069 100644
--- a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
+++ b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
@@ -16,7 +16,7 @@
from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
from tagstudio.previews.media_probe import can_have_duration, get_duration
-from tagstudio.qt.mixed.item_thumb import ItemThumb
+from tagstudio.qt.controllers.item_thumb import ItemThumb
from tagstudio.qt.qt_file_renderer import QtFileRenderer
if TYPE_CHECKING:
@@ -100,7 +100,7 @@ def set_entries(self, entry_ids: list[int]):
def update_selected(self):
for item_thumb in self._item_thumbs:
value = item_thumb.item_id in self.driver._selected
- item_thumb.thumb_button.set_selected(value)
+ item_thumb.set_selected(value)
def _assign_badges(self, item_thumb: ItemThumb, entry_id: int) -> None:
for tag_id in self.driver.entry_state.badge_tag_ids:
@@ -142,8 +142,7 @@ def _update_thumb(self, entry_id: int, image: QPixmap, size: QSize, file_path: P
if index is None:
return
item_thumb = self._item_thumbs[index]
- item_thumb.update_thumb(image, file_path)
- item_thumb.update_size(size)
+ item_thumb.set_image(image, size, file_path)
item_thumb.set_filename_text(file_path)
item_thumb.set_extension(file_path)
item_thumb.set_duration(self._durations.get(file_path))
@@ -360,7 +359,7 @@ def setGeometry(self, arg__1: QRect) -> None:
entry_id = self._entry_ids[i]
item_index = self._entry_items[entry_id]
item_thumb = self._item_thumbs[item_index]
- item_thumb.thumb_button.set_selected(entry_id in self.driver._selected)
+ item_thumb.set_selected(entry_id in self.driver._selected)
self._assign_badges(item_thumb, entry_id)
@override
diff --git a/src/tagstudio/qt/views/styles/palette.py b/src/tagstudio/qt/views/styles/palette.py
index 51aeef0bcd..6527ba26d6 100644
--- a/src/tagstudio/qt/views/styles/palette.py
+++ b/src/tagstudio/qt/views/styles/palette.py
@@ -8,7 +8,8 @@
from typing import Any
import structlog
-from PySide6.QtGui import QColor, QPalette
+from PySide6.QtCore import Qt
+from PySide6.QtGui import QColor, QGuiApplication, QPalette
from tagstudio.core.library.alchemy.enums import TagColorEnum
from tagstudio.core.utils.singleton import Singleton
@@ -45,6 +46,15 @@ def accent() -> QColor:
return QPalette.color(Palette._palette, role)
+ @staticmethod
+ def accent_alt() -> QColor:
+ """An alternate accent color variant that's lighter in dark mode."""
+ return QColor(
+ Palette.accent().lighter(110)
+ if QGuiApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
+ else Palette.accent()
+ )
+
class ColorType(IntEnum):
PRIMARY = 0
diff --git a/src/tagstudio/qt/views/styles/stylesheets.py b/src/tagstudio/qt/views/styles/stylesheets.py
index fffad44bf5..74bbe3a716 100644
--- a/src/tagstudio/qt/views/styles/stylesheets.py
+++ b/src/tagstudio/qt/views/styles/stylesheets.py
@@ -3,6 +3,7 @@
import platform
+from pathlib import Path
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QGuiApplication
@@ -608,7 +609,7 @@ def _is_dark_theme() -> bool:
def get_contrast_text_color(background_color: QColor) -> QColor:
"""Return plain black or white, whichever reads better against `background_color`."""
- return QColor(0, 0, 0) if background_color.lightness() > 120 else QColor(255, 255, 255)
+ return QColor(0, 0, 0) if background_color.lightness() > 140 else QColor(255, 255, 255)
def _banner_button_hover_style(object_name: str) -> str:
@@ -666,7 +667,7 @@ def banner_action_button_style() -> str:
def banner_notice_bg_color() -> QColor:
"""Fill color for the banner card in "notice" mode."""
- color = QColor(Palette.accent())
+ color = Palette.accent_alt()
color.setAlpha(235)
return color
@@ -744,11 +745,68 @@ def pagination_style() -> str:
"""
+def item_thumb_label_style(is_large: bool = False) -> str:
+ """Style used for text labels on item thumbnails, like file extensions and durations."""
+ font_size = 18 if is_large else 12
+ return (
+ "background-color:rgba(0, 0, 0, 192);"
+ "color:#FFFFFF;"
+ "font-family:Oxanium;"
+ "font-weight:bold;"
+ f"font-size:{font_size}px;"
+ "border-radius:3px;"
+ "padding-top: 4px;"
+ "padding-right: 1px;"
+ "padding-bottom: 1px;"
+ "padding-left: 1px;"
+ )
+
+
+def item_thumb_check_badge_style(size: int, icon: Path, checked_icon: Path) -> str:
+ """Style used for checkable tag badges on item thumbnails, like Favorite and Archived."""
+ return (
+ f"QCheckBox::indicator{{width: {size}px;height: {size}px;}}"
+ f'QCheckBox::indicator::unchecked{{image: url("{icon.as_posix()}")}}'
+ f'QCheckBox::indicator::checked{{image: url("{checked_icon.as_posix()}")}}'
+ )
+
+
+def item_thumb_filename_style(is_selected: bool = False) -> str:
+ """Style used for filename labels under item thumbnails, highlighted when selected."""
+ if is_selected:
+ return (
+ "background-color: palette(highlight);"
+ "color: palette(highlighted-text);"
+ "font-size: 10px;"
+ "padding-bottom: 1px;"
+ "padding-left: 1px;"
+ f"border-radius: {HALF_PAD}px;"
+ )
+ return "font-size: 10px; padding-left: 1px; padding-bottom: 1px;"
+
+
+def item_thumb_faded_select_color() -> QColor:
+ """Semi-transparent accent color used to fill selected item thumbnails."""
+ color = Palette.accent()
+ color.setHsl(color.hslHue(), color.hslSaturation(), max(color.lightness(), 127), 127)
+ return color
+
+
+def item_thumb_hover_color() -> QColor:
+ """Lightened accent color used to outline hovered item thumbnails."""
+ color = Palette.accent()
+ color.setHsl(
+ color.hslHue(),
+ min(255, round(color.hslSaturation() * 1.25)),
+ min(color.lightness() + 80, 255),
+ color.alpha(),
+ )
+ return color
+
+
def banner_progress_chunk_color() -> QColor:
"""Fill color for the banner's custom-painted progress bar chunk."""
- is_dark = _is_dark_theme()
- chunk = QColor(Palette.accent().lighter(130) if is_dark else Palette.accent().darker(115))
- chunk.setAlpha(235)
+ chunk = Palette.accent_alt()
return chunk
diff --git a/src/tagstudio/qt/views/thumb_button.py b/src/tagstudio/qt/views/thumb_button.py
deleted file mode 100644
index b71bc3ead9..0000000000
--- a/src/tagstudio/qt/views/thumb_button.py
+++ /dev/null
@@ -1,96 +0,0 @@
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-
-
-from typing import override
-
-from PySide6 import QtCore
-from PySide6.QtCore import QEvent
-from PySide6.QtGui import QColor, QEnterEvent, QPainter, QPainterPath, QPaintEvent, QPen
-from PySide6.QtWidgets import QPushButton, QWidget
-
-from tagstudio.qt.views.styles.palette import Palette
-from tagstudio.qt.views.styles.stylesheets import RADIUS
-
-
-# TODO: Use newer MVC style guidelines
-class ThumbButton(QPushButton):
- def __init__(self, parent: QWidget, thumb_size: tuple[int, int]) -> None:
- super().__init__(parent)
- self.thumb_size: tuple[int, int] = thumb_size
- self.hovered = False
- self.selected = False
- self.select_color = Palette.accent()
-
- self.select_color_faded = Palette.accent()
- self.select_color_faded.setHsl(
- self.select_color_faded.hslHue(),
- self.select_color_faded.hslSaturation(),
- max(self.select_color_faded.lightness(), 127),
- 127,
- )
-
- self.hover_color = Palette.accent()
- self.hover_color.setHsl(
- self.hover_color.hslHue(),
- self.hover_color.hslSaturation(),
- min(self.hover_color.lightness() + 80, 255),
- self.hover_color.alpha(),
- )
-
- @override
- def paintEvent(self, arg__1: QPaintEvent) -> None:
- super().paintEvent(arg__1)
- if self.hovered or self.selected:
- painter = QPainter()
- painter.begin(self)
- painter.setRenderHint(QPainter.RenderHint.Antialiasing)
- path = QPainterPath()
- width = 3
- path.addRoundedRect(
- QtCore.QRectF(
- width / 2,
- width / 2,
- self.thumb_size[0] - width,
- self.thumb_size[1] - width,
- ),
- RADIUS,
- RADIUS,
- )
-
- if self.selected:
- painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_HardLight)
- pen = QPen(self.select_color_faded, width)
- painter.setPen(pen)
- painter.fillPath(path, self.select_color_faded)
- painter.drawPath(path)
-
- painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
- color: QColor = self.select_color if not self.hovered else self.hover_color
- pen = QPen(color, width)
- painter.setPen(pen)
- painter.drawPath(path)
- elif self.hovered:
- painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
- pen = QPen(self.hover_color, width)
- painter.setPen(pen)
- painter.drawPath(path)
-
- painter.end()
-
- @override
- def enterEvent(self, event: QEnterEvent) -> None:
- self.hovered = True
- self.repaint()
- return super().enterEvent(event)
-
- @override
- def leaveEvent(self, event: QEvent) -> None:
- self.hovered = False
- self.repaint()
- return super().leaveEvent(event)
-
- def set_selected(self, value: bool) -> None:
- if value != self.selected:
- self.selected = value
- self.repaint()
diff --git a/tests/qt/test_entry_state.py b/tests/qt/test_entry_state.py
index fb47e81ac7..7528f5f6f6 100644
--- a/tests/qt/test_entry_state.py
+++ b/tests/qt/test_entry_state.py
@@ -7,8 +7,8 @@
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
from tagstudio.core.library.alchemy.enums import ItemType
from tagstudio.core.library.alchemy.library import Library
+from tagstudio.qt.controllers.item_thumb import ItemThumb
from tagstudio.qt.entry_state import EntryState
-from tagstudio.qt.mixed.item_thumb import ItemThumb
from tagstudio.qt.qt_driver import QtDriver
@@ -56,7 +56,7 @@ def test_badge_click_updates_whole_selection(qt_driver: QtDriver):
qt_driver.toggle_item_selection(1, append=False, bridge=False)
qt_driver.toggle_item_selection(2, append=True, bridge=False)
- thumbs[0].badges[TAG_FAVORITE].setChecked(True)
+ thumbs[0].layout().badges[TAG_FAVORITE].setChecked(True)
- assert all(thumb.badges[TAG_FAVORITE].isChecked() for thumb in thumbs)
+ assert all(thumb.layout().badges[TAG_FAVORITE].isChecked() for thumb in thumbs)
assert qt_driver.lib.get_tag_entries([TAG_FAVORITE], [1, 2])[TAG_FAVORITE] == {1, 2}
diff --git a/tests/qt/test_item_thumb.py b/tests/qt/test_item_thumb.py
index c179c104d5..876e98ba33 100644
--- a/tests/qt/test_item_thumb.py
+++ b/tests/qt/test_item_thumb.py
@@ -6,7 +6,7 @@
from tagstudio.core.constants import TAG_FAVORITE
from tagstudio.core.library.alchemy.enums import ItemType
-from tagstudio.qt.mixed.item_thumb import ItemThumb
+from tagstudio.qt.controllers.item_thumb import ItemThumb
from tagstudio.qt.qt_driver import QtDriver
@@ -19,7 +19,5 @@ def test_badge_visual_state(qt_driver: QtDriver, entry_min: int, new_value: bool
qt_driver.frame_content = [entry_min]
qt_driver.toggle_item_selection(0, append=False, bridge=False)
- thumb.badges[TAG_FAVORITE].setChecked(new_value)
- assert thumb.badges[TAG_FAVORITE].isChecked() == new_value
- # TODO
- # assert thumb.favorite_badge.isHidden() == initial_state
+ thumb.layout().badges[TAG_FAVORITE].setChecked(new_value)
+ assert thumb.layout().badges[TAG_FAVORITE].isChecked() == new_value
From 71323b086683d92d61c075b766e7d1ff8324ce70 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sun, 11 Oct 2026 00:38:26 -0700
Subject: [PATCH 04/10] refactor: MVC split ThumbGridLayout
---
src/tagstudio/core/library/alchemy/library.py | 2 +-
src/tagstudio/qt/controllers/item_thumb.py | 21 +-
src/tagstudio/qt/controllers/main_window.py | 17 +-
src/tagstudio/qt/controllers/thumb_grid.py | 348 +++++++++++++
src/tagstudio/qt/entry_state.py | 13 +-
src/tagstudio/qt/qt_driver.py | 26 +-
.../qt/views/layouts/thumb_grid_layout.py | 469 +++++-------------
tests/conftest.py | 9 +-
tests/qt/test_entry_state.py | 15 +-
tests/qt/test_qt_driver.py | 4 +-
10 files changed, 535 insertions(+), 389 deletions(-)
create mode 100644 src/tagstudio/qt/controllers/thumb_grid.py
diff --git a/src/tagstudio/core/library/alchemy/library.py b/src/tagstudio/core/library/alchemy/library.py
index 0f365c9e15..068ee99a6c 100644
--- a/src/tagstudio/core/library/alchemy/library.py
+++ b/src/tagstudio/core/library/alchemy/library.py
@@ -600,7 +600,7 @@ def get_entries(self, entry_ids: Iterable[int]) -> list[Entry]:
with Session(self.engine) as session:
statement = select(Entry).where(Entry.id.in_(entry_ids))
entries = dict((e.id, e) for e in session.scalars(statement))
- return [entries[id] for id in entry_ids]
+ return [entries[id] for id in entry_ids if id in entries]
def get_entries_full(self, entry_ids: list[int] | set[int]) -> Iterator[Entry]:
"""Load entry and join with all joins and all tags."""
diff --git a/src/tagstudio/qt/controllers/item_thumb.py b/src/tagstudio/qt/controllers/item_thumb.py
index db6f08149c..2fb5a76eac 100644
--- a/src/tagstudio/qt/controllers/item_thumb.py
+++ b/src/tagstudio/qt/controllers/item_thumb.py
@@ -6,7 +6,7 @@
from typing import TYPE_CHECKING, override
import structlog
-from PySide6.QtCore import QEvent, QMimeData, QPoint, QRect, QRectF, QSignalBlocker, QSize, Qt, QUrl
+from PySide6.QtCore import QEvent, QMimeData, QPoint, QRect, QRectF, QSignalBlocker, Qt, QUrl
from PySide6.QtGui import (
QColor,
QContextMenuEvent,
@@ -178,6 +178,7 @@ def set_extension(self, filename: Path) -> None:
self.layout().ext_label.setHidden(not show_ext_label)
self.layout().count_label.setHidden(not show_count_label)
+ self._apply_label_sizes()
def set_count(self, count: str) -> None:
view = self.layout()
@@ -188,6 +189,7 @@ def set_count(self, count: str) -> None:
if self._mode == ItemType.ENTRY:
view.ext_label.setHidden(True)
view.count_label.setHidden(True)
+ self._apply_label_sizes()
def set_duration(self, duration: float | None) -> None:
"""Show a media duration on the count label, or "-:--" while it's unknown."""
@@ -197,9 +199,14 @@ def set_duration(self, duration: float | None) -> None:
else:
count_label.setText(format_duration(duration))
count_label.setHidden(False)
+ self._apply_label_sizes()
def set_filename_text(self, filename: Path):
self.layout().file_label.setText(str(filename.name))
+ self._apply_label_sizes()
+
+ def _apply_label_sizes(self) -> None:
+ self.layout().activate()
def set_filename_visibility(self, set_visible: bool):
"""Toggle the visibility of the filename label.
@@ -229,17 +236,20 @@ def set_selected(self, value: bool) -> None:
def set_thumb_size(self, size: int) -> None:
self._thumb_size = (size, size)
- self._image = None
self.setFixedSize(size, size)
self.set_filename_visibility(self._show_filename_label)
self.update()
- def set_image(self, image: QPixmap, size: QSize, file_path: Path) -> None:
+ def set_image(self, image: QPixmap, file_path: Path) -> None:
self._image = image
- self._thumb_size = size.width(), size.height()
self.rendered_path = file_path
self.update()
+ def clear_image(self) -> None:
+ self._image = None
+ self.rendered_path = None
+ self.update()
+
def set_item(self, entry: Entry):
self.set_item_id(entry.id)
self._item_path = unwrap(self._lib.library_dir) / entry.path
@@ -247,6 +257,7 @@ def set_item(self, entry: Entry):
def set_item_id(self, item_id: int):
self.item_id = item_id
+ self._item_path = None
def assign_badge(self, tag_id: int, value: bool) -> None:
badge = self.layout().badges[tag_id]
@@ -372,7 +383,7 @@ def mouseMoveEvent(self, event: QMouseEvent) -> None:
@override
def contextMenuEvent(self, event: QContextMenuEvent) -> None:
- if not self._image_rect().contains(event.pos()):
+ if self._item_path is None or not self._image_rect().contains(event.pos()):
return super().contextMenuEvent(event)
view = self.layout()
diff --git a/src/tagstudio/qt/controllers/main_window.py b/src/tagstudio/qt/controllers/main_window.py
index ef13a769ff..acee695663 100644
--- a/src/tagstudio/qt/controllers/main_window.py
+++ b/src/tagstudio/qt/controllers/main_window.py
@@ -42,10 +42,10 @@
from tagstudio.qt.controllers.banner_stack import BannerStack
from tagstudio.qt.controllers.inspector import Inspector
from tagstudio.qt.controllers.pagination import Pagination
+from tagstudio.qt.controllers.thumb_grid import ThumbGrid
from tagstudio.qt.helpers.mnemonics import assign_mnemonics
from tagstudio.qt.mixed.landing import LandingWidget
from tagstudio.qt.resource_manager import ResourceManager
-from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout
from tagstudio.qt.views.styles.color_overlay import auto_theme_overlay
from tagstudio.qt.views.styles.stylesheets import HALF_PAD, PAD, WIN_PAD, checkbox_style
@@ -494,8 +494,7 @@ def __init__(self, driver: QtDriver, parent: QWidget | None = None) -> None:
self.entry_list_layout: QVBoxLayout
self.banners: BannerStack
self.entry_scroll_area: QScrollArea
- self.thumb_grid: QWidget
- self.thumb_layout: ThumbGridLayout
+ self.thumb_grid: ThumbGrid
self.landing_widget: LandingWidget
self.pagination: Pagination
@@ -700,18 +699,10 @@ def setup_entry_list(self, driver: QtDriver):
self.entry_scroll_area.setFrameShadow(QFrame.Shadow.Plain)
self.entry_scroll_area.setWidgetResizable(True)
self.entry_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
- self.entry_scroll_area.verticalScrollBar().valueChanged.connect(
- lambda value: self.thumb_layout.update()
- )
- self.thumb_grid = QWidget()
- self.thumb_grid.setObjectName("thumb_grid")
+ self.thumb_grid = ThumbGrid(driver, self.entry_scroll_area)
# Padding so floating pagination bar doesn't block bottom of scroll contents
- self.thumb_layout = ThumbGridLayout(
- driver, self.entry_scroll_area, bottom_padding=Pagination.HEIGHT
- )
- self.thumb_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.thumb_grid.setLayout(self.thumb_layout)
+ self.thumb_grid.setContentsMargins(0, 0, 0, Pagination.HEIGHT)
self.entry_scroll_area.setWidget(self.thumb_grid)
self.banners = BannerStack()
diff --git a/src/tagstudio/qt/controllers/thumb_grid.py b/src/tagstudio/qt/controllers/thumb_grid.py
new file mode 100644
index 0000000000..c8cfb7322f
--- /dev/null
+++ b/src/tagstudio/qt/controllers/thumb_grid.py
@@ -0,0 +1,348 @@
+# SPDX-FileCopyrightText: (c) TagStudio Contributors
+# SPDX-License-Identifier: GPL-3.0-only
+
+
+import time
+from pathlib import Path
+from typing import TYPE_CHECKING, override
+
+import structlog
+from PySide6.QtCore import QSize, QThreadPool, QTimer, Signal
+from PySide6.QtGui import QPixmap
+from PySide6.QtWidgets import QScrollArea, QWidget
+
+from tagstudio.core.library.alchemy.enums import ItemType
+from tagstudio.core.library.alchemy.models import Entry
+from tagstudio.core.utils.types import unwrap
+from tagstudio.previews.media_probe import can_have_duration, get_duration
+from tagstudio.qt.controllers.item_thumb import ItemThumb
+from tagstudio.qt.qt_file_renderer import QtFileRenderer
+from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout
+
+if TYPE_CHECKING:
+ from tagstudio.qt.qt_driver import QtDriver
+
+logger = structlog.get_logger(__name__)
+
+
+class ThumbGrid(QWidget):
+ """A scrollable grid of item thumbnails."""
+
+ RESIZE_RENDER_DELAY_MS = 200 # Allows for more responsive rapid resizing
+
+ visible_changed = Signal(int) # ID of the first visible entry
+ _entries_fetched = Signal(int, object, object)
+ _duration_loaded = Signal(Path, object)
+
+ def __init__(self, driver: QtDriver, scroll_area: QScrollArea) -> None:
+ super().__init__()
+ self.setObjectName("thumb_grid")
+ self._driver = driver
+ self._renderer = QtFileRenderer(driver.lib, driver.settings)
+
+ self._durations: dict[Path, float | None] = {}
+ self._entries: dict[int, Entry] = {}
+ self._entry_ids: list[int] = []
+ self._entry_indices: dict[int, int] = {}
+ self._entry_paths: dict[Path, int] = {}
+ self._fetch_pool: QThreadPool | None = None
+ self._fetching: set[int] = set()
+ self._changed_while_fetching: set[int] = set()
+ self._generation = 0
+ self._loading_image: QPixmap | None = None
+ self._pending_durations: set[Path] = set()
+ self._previous_size_renders: dict[Path, QPixmap] = {}
+ self._render_cutoff = 0.0
+ self._renders: dict[Path, QPixmap | None] = {}
+ self._resize_render_timer = QTimer(self)
+ self._resize_render_timer.setInterval(self.RESIZE_RENDER_DELAY_MS)
+ self._resize_render_timer.setSingleShot(True)
+ self._scroll_to: int | None = None
+
+ self.setLayout(ThumbGridLayout(scroll_area, self._create_thumb))
+ self._connect_callbacks(scroll_area)
+
+ @override
+ def layout(self) -> ThumbGridLayout[ItemThumb]:
+ return super().layout() # pyright: ignore[reportReturnType]
+
+ def _connect_callbacks(self, scroll_area: QScrollArea) -> None:
+ scroll_area.verticalScrollBar().valueChanged.connect(lambda _: self.layout().update())
+ self.layout().items_bound.connect(self._on_items_bound)
+ self.layout().first_visible_changed.connect(
+ lambda index: self.visible_changed.emit(self._entry_ids[index])
+ )
+ self._renderer.updated.connect(self._on_rendered)
+ self._resize_render_timer.timeout.connect(self._request_renders)
+ self._entries_fetched.connect(self._on_entries_fetched)
+ self._duration_loaded.connect(self._on_duration_loaded)
+ self._driver.entry_state.tags_changed.connect(self._on_tags_changed)
+
+ def set_entries(self, entry_ids: list[int]) -> None:
+ self._generation += 1
+ self._resize_render_timer.stop()
+ self._entry_ids = entry_ids
+ self._entry_indices = {entry_id: index for index, entry_id in enumerate(entry_ids)}
+ self._entries.clear()
+ self._entry_paths.clear()
+ self._fetching.clear()
+ self._changed_while_fetching.clear()
+ self._driver.entry_state.unload_badges()
+ self._previous_size_renders.clear()
+ self._reset_renders()
+ self.layout().set_item_count(len(entry_ids))
+
+ if self._scroll_to is not None:
+ index = self._entry_indices.get(self._scroll_to)
+ if index is not None:
+ self.layout().scroll_to_index(index)
+ self._scroll_to = None
+
+ def scroll_to(self, entry_id: int) -> None:
+ """Scroll to this entry once the next entries are set."""
+ self._scroll_to = entry_id
+
+ def set_thumb_size(self, size: int) -> None:
+ self._previous_size_renders.update(
+ (path, image) for path, image in self._renders.items() if image is not None
+ )
+ self._reset_renders()
+ self._resize_render_timer.start()
+ first_visible = self.layout().first_visible_index()
+ for thumb in self.layout().widgets():
+ thumb.set_thumb_size(size)
+ if first_visible is not None:
+ self.layout().scroll_to_index(first_visible)
+ self.layout().activate()
+
+ def set_filenames_visible(self, visible: bool) -> None:
+ for thumb in self.layout().widgets():
+ thumb.set_filename_visibility(visible)
+ self.layout().activate()
+
+ def update_selected(self) -> None:
+ for _, thumb in self.layout().bound_items():
+ thumb.set_selected(thumb.item_id in self._driver._selected) # pyright: ignore[reportPrivateUsage]
+
+ def _create_thumb(self) -> ItemThumb:
+ size = self._driver.main_window.thumb_size
+ return ItemThumb(
+ ItemType.ENTRY,
+ self._driver.lib,
+ self._driver,
+ (size, size),
+ show_filename_label=self._driver.settings.show_filenames_in_grid,
+ )
+
+ def _path(self, entry: Entry) -> Path:
+ return unwrap(self._driver.lib.library_dir) / entry.path
+
+ def _thumb_for(self, entry_id: int) -> ItemThumb | None:
+ index = self._entry_indices.get(entry_id)
+ return None if index is None else self.layout().widget_at(index)
+
+ def _on_items_bound(self, items: list[tuple[int, ItemThumb]]) -> None:
+ missing: list[int] = []
+ for index, thumb in items:
+ entry_id = self._entry_ids[index]
+ thumb.set_item_id(entry_id)
+ thumb.set_selected(entry_id in self._driver._selected) # pyright: ignore[reportPrivateUsage]
+ entry = self._entries.get(entry_id)
+ if entry is not None:
+ self._bind_entry(thumb, entry)
+ continue
+
+ thumb.set_filename_text(Path())
+ thumb.set_extension(Path())
+ for tag_id in self._driver.entry_state.badge_tag_ids:
+ thumb.assign_badge(tag_id, value=False)
+ self._show_loading_image(thumb)
+ if entry_id not in self._fetching:
+ missing.append(entry_id)
+
+ if missing:
+ self._fetch_entries(missing)
+ self._trim_render_queue()
+ self._request_renders()
+
+ def _bind_entry(self, thumb: ItemThumb, entry: Entry) -> None:
+ path = self._path(entry)
+ thumb.set_item(entry)
+ thumb.set_filename_text(path)
+ thumb.set_extension(path)
+ thumb.set_duration(self._durations.get(path))
+ self._assign_badges(thumb, entry.id)
+
+ image = self._renders.get(path) or self._previous_size_renders.get(path)
+ if image is not None:
+ thumb.set_image(image, path)
+ elif thumb.rendered_path != path:
+ self._show_loading_image(thumb)
+
+ def _show_loading_image(self, thumb: ItemThumb) -> None:
+ if self._loading_image is None:
+ thumb.clear_image()
+ else:
+ thumb.set_image(self._loading_image, Path())
+
+ def _assign_badges(self, thumb: ItemThumb, entry_id: int) -> None:
+ for tag_id in self._driver.entry_state.badge_tag_ids:
+ thumb.assign_badge(tag_id, self._driver.entry_state.has_badge(entry_id, tag_id))
+
+ def _on_tags_changed(self, entry_ids: set[int]) -> None:
+ self._changed_while_fetching.update(self._fetching.intersection(entry_ids))
+ for _, thumb in self.layout().bound_items():
+ if thumb.item_id in entry_ids and thumb.item_id in self._entries:
+ self._assign_badges(thumb, thumb.item_id)
+
+ def _fetch_entries(self, entry_ids: list[int]) -> None:
+ """Read entries and their badge tags on a worker thread."""
+ self._fetching.update(entry_ids)
+ lib = self._driver.lib
+ generation = self._generation
+ badge_tag_ids = self._driver.entry_state.badge_tag_ids
+
+ def fetch() -> None:
+ try:
+ entries = lib.get_entries(entry_ids)
+ tag_entries = lib.get_tag_entries(badge_tag_ids, entry_ids)
+ except Exception as e:
+ logger.error("[ThumbGrid] Error fetching entries", error=e)
+ return
+ self._entries_fetched.emit(generation, entries, tag_entries)
+
+ if self._fetch_pool is None:
+ self._fetch_pool = QThreadPool(self)
+ self._fetch_pool.setMaxThreadCount(1)
+ self._fetch_pool.start(fetch)
+
+ def _on_entries_fetched(
+ self, generation: int, entries: list[Entry], tag_entries: dict[int, set[int]]
+ ) -> None:
+ if generation != self._generation:
+ return
+
+ entry_ids = [entry.id for entry in entries]
+ self._fetching.difference_update(entry_ids)
+ fresh = [e for e in entry_ids if e not in self._changed_while_fetching]
+ self._changed_while_fetching.difference_update(entry_ids)
+ self._driver.entry_state.cache_badges(fresh, tag_entries)
+ for entry in entries:
+ self._entries[entry.id] = entry
+ self._entry_paths[self._path(entry)] = entry.id
+ if (thumb := self._thumb_for(entry.id)) is not None:
+ self._bind_entry(thumb, entry)
+ self._request_renders()
+
+ def _reset_renders(self) -> None:
+ self._render_cutoff = time.time()
+ self._renders.clear()
+ self._loading_image = None
+ self._driver.thumb_job_queue.queue.clear()
+ self._pending_durations.clear()
+ self._request_loading_image()
+
+ def _request_loading_image(self) -> None:
+ size = self._driver.main_window.thumb_size
+ self._driver.thumb_job_queue.put(
+ (
+ self._renderer.render,
+ (
+ self._driver.cache_manager,
+ self._render_cutoff,
+ Path(),
+ (size, size),
+ self._driver.main_window.devicePixelRatio(),
+ True,
+ True,
+ ),
+ )
+ )
+
+ def _request_renders(self) -> None:
+ """Queue renders for thumbnails, starting at the first visible one."""
+ if self._resize_render_timer.isActive():
+ return
+
+ first_visible = self.layout().first_visible_index() or 0
+ bound = sorted(self.layout().bound_items(), key=lambda item: item[0] < first_visible)
+ entries = [entry for _, thumb in bound if (entry := self._entries.get(thumb.item_id))]
+ size = self._driver.main_window.thumb_size
+ ratio = self._driver.main_window.devicePixelRatio()
+ timestamp = time.time()
+ for entry in entries:
+ path = self._path(entry)
+ if path in self._renders:
+ continue
+ self._renders[path] = None
+ self._driver.thumb_job_queue.put(
+ (
+ self._renderer.render,
+ (
+ self._driver.cache_manager,
+ timestamp,
+ path,
+ (size, size),
+ ratio,
+ False,
+ True,
+ entry.date_modified,
+ ),
+ )
+ )
+
+ # Queued after thumbnails so they don't hold up rendering
+ for entry in entries:
+ self._queue_duration(self._path(entry))
+
+ def _trim_render_queue(self) -> None:
+ """Trim queued renders once they get too backed up."""
+ queue = self._driver.thumb_job_queue.queue
+ if len(queue) <= 2 * len(self.layout().bound_items()):
+ return
+
+ queue.clear()
+ self._pending_durations.clear()
+ self._renders = {path: image for path, image in self._renders.items() if image is not None}
+ if self._loading_image is None:
+ self._request_loading_image()
+
+ def _on_rendered(self, timestamp: float, image: QPixmap, _size: QSize, file_path: Path) -> None:
+ if timestamp < self._render_cutoff:
+ return
+
+ if file_path == Path():
+ self._loading_image = image
+ for _, thumb in self.layout().bound_items():
+ entry = self._entries.get(thumb.item_id)
+ if entry is None or thumb.rendered_path != self._path(entry):
+ thumb.set_image(image, Path())
+ return
+
+ self._renders[file_path] = image
+ self._previous_size_renders.pop(file_path, None)
+ entry_id = self._entry_paths.get(file_path)
+ thumb = None if entry_id is None else self._thumb_for(entry_id)
+ if thumb is not None:
+ thumb.set_image(image, file_path)
+
+ def _queue_duration(self, file_path: Path) -> None:
+ if (
+ not can_have_duration(file_path)
+ or file_path in self._durations
+ or file_path in self._pending_durations
+ ):
+ return
+ self._pending_durations.add(file_path)
+ self._driver.thumb_job_queue.put((self._load_duration, (file_path,)))
+
+ def _load_duration(self, file_path: Path) -> None:
+ self._duration_loaded.emit(file_path, get_duration(file_path))
+
+ def _on_duration_loaded(self, file_path: Path, duration: float | None) -> None:
+ self._pending_durations.discard(file_path)
+ self._durations[file_path] = duration
+ entry_id = self._entry_paths.get(file_path)
+ thumb = None if entry_id is None else self._thumb_for(entry_id)
+ if thumb is not None:
+ thumb.set_duration(duration)
diff --git a/src/tagstudio/qt/entry_state.py b/src/tagstudio/qt/entry_state.py
index 9cfdf0c404..7ad0485599 100644
--- a/src/tagstudio/qt/entry_state.py
+++ b/src/tagstudio/qt/entry_state.py
@@ -40,15 +40,18 @@ def badge_tag_ids(self) -> tuple[int, ...]:
return self._badge_tag_ids
def load_badges(self, entry_ids: Iterable[int]) -> None:
- """Read which entries have the tags used for badges."""
+ """Read which entries have tags used for badges."""
entry_ids = set(entry_ids)
- if not entry_ids:
- return
+ if entry_ids:
+ tag_entries = self._lib.get_tag_entries(self._badge_tag_ids, list(entry_ids))
+ self.cache_badges(entry_ids, tag_entries)
- tag_entries = self._lib.get_tag_entries(self._badge_tag_ids, list(entry_ids))
+ def cache_badges(self, entry_ids: Iterable[int], tag_entries: dict[int, set[int]]) -> None:
+ """Store which entries have tags used for badges, as given by `tag_entries`."""
+ entry_ids = set(entry_ids)
for tag_id, entries in self._entries_with_badge.items():
entries.difference_update(entry_ids)
- entries.update(tag_entries[tag_id])
+ entries.update(entry_ids.intersection(tag_entries[tag_id]))
self._badges_loaded.update(entry_ids)
def unload_badges(self) -> None:
diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py
index ac41c29622..d51139d18d 100644
--- a/src/tagstudio/qt/qt_driver.py
+++ b/src/tagstudio/qt/qt_driver.py
@@ -606,7 +606,7 @@ def on_visible_changed(entry_id: int | None):
else:
current.page_positions[page_index] = entry_id
- self.main_window.thumb_layout.visible_changed.connect(on_visible_changed)
+ self.main_window.thumb_grid.visible_changed.connect(on_visible_changed)
QFontDatabase.addApplicationFont(
str(Path(__file__).parents[1] / "resources/qt/fonts/Oxanium-Bold.ttf")
@@ -746,8 +746,7 @@ def init_ignore_modal(self):
self.main_window.menu_bar.ignore_modal_action.triggered.connect(self.ignore_modal.show)
def show_grid_filenames(self, value: bool):
- for thumb in self.main_window.thumb_layout._item_thumbs:
- thumb.set_filename_visibility(value)
+ self.main_window.thumb_grid.set_filenames_visible(value)
def call_if_library_open(self, func):
"""Check if loaded library has valid path before executing the button function."""
@@ -818,7 +817,7 @@ def close_library(self, is_shutdown: bool = False):
if hasattr(self, "library_info_window"):
self.library_info_window.close()
- self.main_window.thumb_layout.set_entries([])
+ self.main_window.thumb_grid.set_entries([])
self.main_window.inspector.set_selection(self.selected)
self.main_window.toggle_landing_page(enabled=True)
self.main_window.landing_widget.animate_logo_in()
@@ -1266,9 +1265,7 @@ def sorting_mode_callback(self):
def thumb_size_callback(self, size: int):
"""Perform actions needed when the thumbnail size selection is changed."""
- self.update_thumbs()
- for it in self.main_window.thumb_layout._item_thumbs:
- it.set_thumb_size(self.main_window.thumb_size)
+ self.main_window.thumb_grid.set_thumb_size(self.main_window.thumb_size)
def show_hidden_entries_callback(self):
logger.info("Show Hidden Entries Changed", exclude=self.main_window.show_hidden_entries)
@@ -1470,8 +1467,7 @@ def update_thumbs(self):
start = page * page_size
end = min(start + page_size, len(self.frame_content))
- self.main_window.thumb_layout.set_entries(self.frame_content[start:end])
- self.main_window.thumb_layout.update()
+ self.main_window.thumb_grid.set_entries(self.frame_content[start:end])
self.main_window.update()
def update_browsing_state(self, state: BrowsingState | None = None) -> None:
@@ -1512,7 +1508,7 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None:
else:
entry_id = self.last_selected
if entry_id is not None:
- self.main_window.thumb_layout.scroll_to(entry_id)
+ self.main_window.thumb_grid.scroll_to(entry_id)
self.update_thumbs()
# update pagination
@@ -1789,7 +1785,7 @@ def drag_move_event(self, event: QDragMoveEvent):
def select_all(self):
self._selected = OrderedDict.fromkeys(self.frame_content)
- self.main_window.thumb_layout.update_selected()
+ self.main_window.thumb_grid.update_selected()
def select_inverse(self):
selected = OrderedDict()
@@ -1798,14 +1794,14 @@ def select_inverse(self):
selected[id] = None
self._selected = selected
- self.main_window.thumb_layout.update_selected()
+ self.main_window.thumb_grid.update_selected()
def select_entry(self, entry_id: int):
if entry_id in self._selected:
self._selected.pop(entry_id)
else:
self._selected[entry_id] = None
- self.main_window.thumb_layout.update_selected()
+ self.main_window.thumb_grid.update_selected()
def select_to_entry(self, entry_id: int):
if len(self._selected) == 0:
@@ -1823,11 +1819,11 @@ def select_to_entry(self, entry_id: int):
for i in range(start, end):
entry_id = self.frame_content[i]
self._selected[entry_id] = None
- self.main_window.thumb_layout.update_selected()
+ self.main_window.thumb_grid.update_selected()
def clear_selected(self):
self._selected.clear()
- self.main_window.thumb_layout.update_selected()
+ self.main_window.thumb_grid.update_selected()
def log_optional_modules(self) -> None:
"""Logs the status of optional modules."""
diff --git a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
index c9787d4069..14f42f6b90 100644
--- a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
+++ b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
@@ -3,364 +3,139 @@
import math
-import time
-from collections.abc import Iterable
-from pathlib import Path
-from typing import TYPE_CHECKING, Any, override
+from collections.abc import Callable
+from typing import override
from PySide6.QtCore import QPoint, QRect, QSize, Signal
-from PySide6.QtGui import QPixmap
-from PySide6.QtWidgets import QLayout, QLayoutItem, QScrollArea
+from PySide6.QtWidgets import QLayout, QLayoutItem, QScrollArea, QWidget
-from tagstudio.core.library.alchemy.enums import ItemType
-from tagstudio.core.library.alchemy.models import Entry
from tagstudio.core.utils.types import unwrap
-from tagstudio.previews.media_probe import can_have_duration, get_duration
-from tagstudio.qt.controllers.item_thumb import ItemThumb
-from tagstudio.qt.qt_file_renderer import QtFileRenderer
+from tagstudio.qt.views.styles.stylesheets import PAD
-if TYPE_CHECKING:
- from tagstudio.qt.qt_driver import QtDriver
+class ThumbGridLayout[W: QWidget](QLayout):
+ """A recycling scrollable grid for widgets of the a uniform size."""
-class ThumbGridLayout(QLayout):
- SPACING = 9
+ _BUFFER_ROWS = 3
- _duration_loaded = Signal(Path, object)
- visible_changed = Signal(int) # ID of first visible entry
+ first_visible_changed = Signal(int) # Index of the first visible item
+ items_bound = Signal(object) # list[tuple[int, W]] of newly bound (index, widget) pairs
- def __init__(self, driver: QtDriver, scroll_area: QScrollArea, bottom_padding: int = 0) -> None:
+ def __init__(self, scroll_area: QScrollArea, create_widget: Callable[[], W]) -> None:
super().__init__(None)
self.setContentsMargins(0, 0, 0, 0)
- self.setSpacing(self.SPACING)
- self.driver: QtDriver = driver
- self.scroll_area: QScrollArea = scroll_area
- self._bottom_padding: int = bottom_padding
+ self.setSpacing(PAD)
- self._item_thumbs: list[ItemThumb] = []
+ self._bound: dict[int, W] = {}
+ self._create_widget = create_widget
+ self._first_visible: int | None = None
+ self._free: list[W] = []
+ self._item_count = 0
self._items: list[QLayoutItem] = []
-
- self._entry_ids: list[int] = []
- self._entries: dict[int, Entry] = {}
- self._entry_paths: dict[Path, int] = {}
- # Entry.id -> _items[index]
- self._entry_items: dict[int, int] = {}
-
- self._render_results: dict[Path, Any] = {}
- self._renderer: QtFileRenderer = QtFileRenderer(self.driver.lib, self.driver.settings)
- self._renderer.updated.connect(self._on_rendered)
- self._render_cutoff: float = 0.0
- self.driver.entry_state.tags_changed.connect(self._on_tags_changed)
-
- self._durations: dict[Path, float | None] = {}
- self._pending_durations: set[Path] = set()
- self._duration_loaded.connect(self._on_duration_loaded)
-
- # _entry_ids[StartIndex:EndIndex], per_row
- self._last_page_update: tuple[int, int, int] | None = None
-
+ # [start, end, per_row, item size]
+ self._last_layout: tuple[int, int, int, QSize] | None = None
+ self._scroll_area = scroll_area
self._scroll_to: int | None = None
- def scroll_to(self, entry_id: int):
- self._scroll_to = entry_id
-
- def set_entries(self, entry_ids: list[int]):
- self._entry_ids = entry_ids
- self._entries.clear()
- self.driver.entry_state.unload_badges()
- self._entry_paths.clear()
-
- self._entry_items.clear()
- self._render_results.clear()
- self.driver.thumb_job_queue.queue.clear()
- self._pending_durations.clear()
- self._render_cutoff = time.time()
-
- base_size: tuple[int, int] = (
- self.driver.main_window.thumb_size,
- self.driver.main_window.thumb_size,
- )
- self.driver.thumb_job_queue.put(
- (
- self._renderer.render,
- (
- self.driver.cache_manager,
- self._render_cutoff,
- Path(),
- base_size,
- self.driver.main_window.devicePixelRatio(),
- True,
- True,
- ),
- )
- )
-
- self._last_page_update = None
-
- def update_selected(self):
- for item_thumb in self._item_thumbs:
- value = item_thumb.item_id in self.driver._selected
- item_thumb.set_selected(value)
-
- def _assign_badges(self, item_thumb: ItemThumb, entry_id: int) -> None:
- for tag_id in self.driver.entry_state.badge_tag_ids:
- item_thumb.assign_badge(tag_id, self.driver.entry_state.has_badge(entry_id, tag_id))
-
- def _on_tags_changed(self, entry_ids: set[int]) -> None:
- for item_thumb in self._item_thumbs:
- if item_thumb.item_id in entry_ids:
- self._assign_badges(item_thumb, item_thumb.item_id)
-
- def _fetch_entries(self, ids: Iterable[int]):
- ids = [id for id in ids if id not in self._entries]
- entries = self.driver.lib.get_entries(ids)
- for entry in entries:
- self._entry_paths[unwrap(self.driver.lib.library_dir) / entry.path] = entry.id
- self._entries[entry.id] = entry
-
- self.driver.entry_state.load_badges(ids)
-
- def _on_rendered(self, timestamp: float, image: QPixmap, size: QSize, file_path: Path):
- if timestamp < self._render_cutoff:
- return
- self._render_results[file_path] = (timestamp, image, size, file_path)
-
- # If this is the loading image update all item_thumbs with pending thumbnails
- if file_path == Path():
- for path, entry_id in self._entry_paths.items():
- if self._render_results.get(path, None) is None:
- self._update_thumb(entry_id, image, size, file_path)
- return
-
- if file_path not in self._entry_paths:
- return
- entry_id = self._entry_paths[file_path]
- self._update_thumb(entry_id, image, size, file_path)
-
- def _update_thumb(self, entry_id: int, image: QPixmap, size: QSize, file_path: Path):
- index = self._entry_items.get(entry_id)
- if index is None:
- return
- item_thumb = self._item_thumbs[index]
- item_thumb.set_image(image, size, file_path)
- item_thumb.set_filename_text(file_path)
- item_thumb.set_extension(file_path)
- item_thumb.set_duration(self._durations.get(file_path))
-
- def _queue_duration(self, file_path: Path) -> None:
- if (
- not can_have_duration(file_path)
- or file_path in self._durations
- or file_path in self._pending_durations
- ):
- return
- self._pending_durations.add(file_path)
- self.driver.thumb_job_queue.put((self._load_duration, (file_path,)))
-
- def _load_duration(self, file_path: Path) -> None:
- self._duration_loaded.emit(file_path, get_duration(file_path))
-
- def _on_duration_loaded(self, file_path: Path, duration: float | None) -> None:
- self._pending_durations.discard(file_path)
- self._durations[file_path] = duration
-
- entry_id = self._entry_paths.get(file_path)
- index = self._entry_items.get(entry_id) if entry_id is not None else None
- if index is None:
- return
- item_thumb = self._item_thumbs[index]
- if item_thumb.rendered_path == file_path:
- item_thumb.set_duration(duration)
-
- def _item_thumb(self, index: int) -> ItemThumb:
- if w := getattr(self.driver, "main_window", None):
- base_size = (w.thumb_size, w.thumb_size)
- else:
- base_size = (128, 128)
- while index >= len(self._item_thumbs):
- show_filename = self.driver.settings.show_filenames_in_grid
- item = ItemThumb(
- ItemType.ENTRY,
- self.driver.lib,
- self.driver,
- base_size,
- show_filename_label=show_filename,
- )
- self._item_thumbs.append(item)
- self.addWidget(item)
- return self._item_thumbs[index]
-
- def _size(self, width: int) -> tuple[int, int, int]:
- if len(self._entry_ids) == 0:
- return 0, 0, 0
- spacing = self.spacing()
-
- _item_thumb = self._item_thumb(0)
- item = self._items[0]
- item_size = item.sizeHint()
- item_width = item_size.width()
- item_height = item_size.height()
-
- width_offset = item_width + spacing
- height_offset = item_height + spacing
-
- if width_offset == 0:
- return 0, 0, height_offset
- per_row = int(width / width_offset)
-
- return per_row, width_offset, height_offset
-
- @override
- def heightForWidth(self, arg__1: int) -> int:
- width = arg__1
- per_row, _, height_offset = self._size(width)
- if per_row == 0:
- return height_offset + self._bottom_padding
- return math.ceil(len(self._entry_ids) / per_row) * height_offset + self._bottom_padding
+ def set_item_count(self, count: int) -> None:
+ """Replace all items with `count` new ones, unbinding every widget."""
+ self._item_count = count
+ self._free.extend(self._bound.values())
+ self._bound.clear()
+ self._first_visible = None
+ self._last_layout = None
+ self.update()
+
+ def scroll_to_index(self, index: int) -> None:
+ self._scroll_to = index
+ self.update()
+
+ def first_visible_index(self) -> int | None:
+ return self._first_visible
+
+ def widget_at(self, index: int) -> W | None:
+ return self._bound.get(index)
+
+ def bound_items(self) -> list[tuple[int, W]]:
+ return sorted(self._bound.items(), key=lambda item: item[0])
+
+ def widgets(self) -> list[W]:
+ return [*self._bound.values(), *self._free]
+
+ def _new_widget(self) -> W:
+ widget = self._create_widget()
+ self.addWidget(widget)
+ return widget
+
+ def _item_size(self) -> QSize:
+ """The first widget's size hint within its size limits, even before it's been shown."""
+ if not self._items:
+ self._free.append(self._new_widget())
+ widget = unwrap(self._items[0].widget())
+ return widget.sizeHint().expandedTo(widget.minimumSize()).boundedTo(widget.maximumSize())
+
+ def _grid_metrics(self, width: int) -> tuple[int, QSize]:
+ """Return how many items fit in a row, and the size of each item's cell."""
+ size = self._item_size()
+ cell = QSize(size.width() + self.spacing(), size.height() + self.spacing())
+ return max(1, int(width / cell.width())), cell
@override
def setGeometry(self, arg__1: QRect) -> None:
super().setGeometry(arg__1)
- rect = arg__1
- if len(self._entry_ids) == 0:
- for item in self._item_thumbs:
- item.setGeometry(32_000, 32_000, 0, 0)
+ if self._item_count == 0:
+ self._free.extend(self._bound.values())
+ self._bound.clear()
+ for widget in self._free:
+ widget.setGeometry(32_000, 32_000, 0, 0)
return
- per_row, width_offset, height_offset = self._size(rect.right())
- view_height = self.parentWidget().parentWidget().height() # pyright: ignore[reportOptionalMemberAccess]
- offset = self.scroll_area.verticalScrollBar().value()
+ per_row, cell = self._grid_metrics(arg__1.width())
+ scroll_bar = self._scroll_area.verticalScrollBar()
if self._scroll_to is not None:
- try:
- index = self._entry_ids.index(self._scroll_to)
- value = (index // per_row) * height_offset
- self.scroll_area.verticalScrollBar().setMaximum(value)
- self.scroll_area.verticalScrollBar().setSliderPosition(value)
- offset = value
- except ValueError:
- pass
+ viewport_height = self._scroll_area.viewport().height()
+ maximum = max(0, self.totalHeightForWidth(arg__1.width()) - viewport_height)
+ scroll_bar.setMaximum(maximum)
+ scroll_bar.setValue(min((self._scroll_to // per_row) * cell.height(), maximum))
self._scroll_to = None
- row_offset = offset
- visible_rows = math.ceil((view_height + (row_offset % height_offset)) / height_offset)
- row_offset = int(row_offset / height_offset)
- start = row_offset * per_row
- end = start + (visible_rows * per_row)
-
- first_visible = self._entry_ids[start] if 0 <= start < len(self._entry_ids) else None
- self.visible_changed.emit(first_visible)
+ offset = scroll_bar.value()
+ last_row = (self._item_count - 1) // per_row
+ first_row = min(offset // cell.height(), last_row)
+ visible_rows = math.ceil(
+ (self._scroll_area.viewport().height() + offset % cell.height()) / cell.height()
+ )
- # Load closest off screen rows
- start -= per_row * 3
- end += per_row * 3
+ first_visible = first_row * per_row
+ if first_visible != self._first_visible:
+ self._first_visible = first_visible
+ self.first_visible_changed.emit(first_visible)
- start = max(0, start)
- end = min(len(self._entry_ids), end)
- if (start, end, per_row) == self._last_page_update:
+ start = max(0, (first_row - self._BUFFER_ROWS) * per_row)
+ end = min(self._item_count, (first_row + visible_rows + self._BUFFER_ROWS) * per_row)
+ item_size = self._item_size()
+ if (start, end, per_row, item_size) == self._last_layout:
return
- self._last_page_update = (start, end, per_row)
-
- # Clear render queue if len > 2 pages
- if len(self.driver.thumb_job_queue.queue) > (per_row * visible_rows * 2):
- self.driver.thumb_job_queue.queue.clear()
- self._pending_durations.clear()
- pending = []
- for k, v in self._render_results.items():
- if v is None and k != Path():
- pending.append(k)
- for k in pending:
- self._render_results.pop(k)
-
- # Reorder items so previously rendered rows will reuse same item_thumbs
- # When scrolling down top row gets moved to end of list
- _ = self._item_thumb(end - start - 1)
- for item_index, i in enumerate(range(start, end)):
- if i >= len(self._entry_ids):
- continue
- entry_id = self._entry_ids[i]
- if entry_id not in self._entry_items:
- continue
- prev_item_index = self._entry_items[entry_id]
- if item_index == prev_item_index:
- break
- diff = prev_item_index - item_index
- self._items = self._items[diff:] + self._items[:diff]
- self._item_thumbs = self._item_thumbs[diff:] + self._item_thumbs[:diff]
- break
- self._entry_items.clear()
-
- # Move unused item_thumbs off screen
- count = end - start
- for item in self._item_thumbs[count:]:
- item.setGeometry(32_000, 32_000, 0, 0)
-
- ratio = self.driver.main_window.devicePixelRatio()
- base_size: tuple[int, int] = (
- self.driver.main_window.thumb_size,
- self.driver.main_window.thumb_size,
- )
- timestamp = time.time()
- for item_index, i in enumerate(range(start, end)):
- entry_id = self._entry_ids[i]
- if entry_id not in self._entries:
- ids = self._entry_ids[start:end]
- self._fetch_entries(ids)
-
- entry = self._entries[entry_id]
- row = int(i / per_row)
- self._entry_items[entry_id] = item_index
- item_thumb = self._item_thumb(item_index)
- item = self._items[item_index]
- col = i % per_row
- item_x = width_offset * col
- item_y = height_offset * row
- item_thumb.setGeometry(QRect(QPoint(item_x, item_y), item.sizeHint()))
- file_path = unwrap(self.driver.lib.library_dir) / entry.path
- item_thumb.set_item(entry)
-
- if result := self._render_results.get(file_path):
- _t, im, s, p = result
- if item_thumb.rendered_path == p:
- continue
- self._update_thumb(entry_id, im, s, p)
- else:
- if Path() in self._render_results:
- _t, im, s, p = self._render_results[Path()]
- self._update_thumb(entry_id, im, s, p)
-
- if file_path not in self._render_results:
- self._render_results[file_path] = None
- self.driver.thumb_job_queue.put(
- (
- self._renderer.render,
- (
- self.driver.cache_manager,
- timestamp,
- file_path,
- base_size,
- ratio,
- False,
- True,
- entry.date_modified,
- ),
- )
- )
-
- # Queued after thumbnails so they don't hold up rendering
- for i in range(start, end):
- entry = self._entries[self._entry_ids[i]]
- self._queue_duration(unwrap(self.driver.lib.library_dir) / entry.path)
-
- # set_selected causes stutters making thumbs after selected not show for a frame
- # setting it after positioning thumbs fixes this
- for i in range(start, end):
- if i >= len(self._entry_ids):
- continue
- entry_id = self._entry_ids[i]
- item_index = self._entry_items[entry_id]
- item_thumb = self._item_thumbs[item_index]
- item_thumb.set_selected(entry_id in self.driver._selected)
- self._assign_badges(item_thumb, entry_id)
+ self._last_layout = (start, end, per_row, item_size)
+
+ for index in [index for index in self._bound if not start <= index < end]:
+ self._free.append(self._bound.pop(index))
+
+ newly_bound: list[tuple[int, W]] = []
+ for index in range(start, end):
+ widget = self._bound.get(index)
+ if widget is None:
+ widget = self._free.pop() if self._free else self._new_widget()
+ self._bound[index] = widget
+ newly_bound.append((index, widget))
+ row, column = divmod(index, per_row)
+ widget.setGeometry(QRect(QPoint(column * cell.width(), row * cell.height()), item_size))
+
+ for widget in self._free:
+ widget.setGeometry(32_000, 32_000, 0, 0)
+
+ if newly_bound:
+ self.items_bound.emit(newly_bound)
@override
def addItem(self, arg__1: QLayoutItem) -> None:
@@ -368,19 +143,37 @@ def addItem(self, arg__1: QLayoutItem) -> None:
@override
def count(self) -> int:
- return len(self._entries)
+ return len(self._items)
+
+ @override
+ def itemAt(self, index: int) -> QLayoutItem:
+ if not 0 <= index < len(self._items):
+ return None # pyright: ignore[reportReturnType]
+ return self._items[index]
+
+ @override
+ def takeAt(self, index: int) -> QLayoutItem:
+ if not 0 <= index < len(self._items):
+ return None # pyright: ignore[reportReturnType]
+ item = self._items.pop(index)
+ widget = item.widget()
+ self._free = [w for w in self._free if w is not widget]
+ self._bound = {i: w for i, w in self._bound.items() if w is not widget}
+ return item
@override
def hasHeightForWidth(self) -> bool:
return True
@override
- def itemAt(self, index: int) -> QLayoutItem:
- if index >= len(self._items):
- return None # pyright: ignore[reportReturnType]
- return self._items[index]
+ def heightForWidth(self, arg__1: int) -> int:
+ if self._item_count == 0:
+ return 0
+ per_row, cell = self._grid_metrics(arg__1)
+ return math.ceil(self._item_count / per_row) * cell.height()
@override
def sizeHint(self) -> QSize:
- self._item_thumb(0)
- return self._items[0].minimumSize()
+ if not self._items:
+ return QSize()
+ return self._item_size()
diff --git a/tests/conftest.py b/tests/conftest.py
index e463281303..5650cfeed7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -27,9 +27,9 @@
from tagstudio.core.library.alchemy.library import Library
from tagstudio.core.library.alchemy.models import Entry, Tag
from tagstudio.core.library.ignore import GLOBAL_IGNORE, Ignore, IgnoreMatcher
+from tagstudio.qt.controllers.thumb_grid import ThumbGrid
from tagstudio.qt.entry_state import EntryState
from tagstudio.qt.qt_driver import QtDriver
-from tagstudio.qt.views.layouts.thumb_grid_layout import ThumbGridLayout
@pytest.fixture
@@ -197,11 +197,16 @@ class Args:
open = library_dir
ci = True
+ # In-memory libraries are per-thread, so grid entry fetches run on the test thread
+ grid_fetch_pool = Mock()
+ grid_fetch_pool.return_value.start.side_effect = lambda task: task()
+
with (
patch("tagstudio.qt.qt_driver.Consumer"),
patch("tagstudio.qt.qt_driver.CustomRunnable"),
# NOTE: Patching the QThreadPool stops Python's garbage collector from causing segfaults
patch("tagstudio.qt.qt_driver.QThreadPool"),
+ patch("tagstudio.qt.controllers.thumb_grid.QThreadPool", grid_fetch_pool),
):
driver = QtDriver(Args()) # pyright: ignore[reportArgumentType]
driver.lib = library
@@ -210,7 +215,7 @@ class Args:
driver.app = Mock()
driver.main_window = Mock()
driver.main_window.thumb_size = 128
- driver.main_window.thumb_layout = ThumbGridLayout(driver, QScrollArea())
+ driver.main_window.thumb_grid = ThumbGrid(driver, QScrollArea())
driver.main_window.menu_bar.autofill_action = Mock()
driver.copy_buffer = {"fields": [], "tags": []}
diff --git a/tests/qt/test_entry_state.py b/tests/qt/test_entry_state.py
index 7528f5f6f6..82cec48f98 100644
--- a/tests/qt/test_entry_state.py
+++ b/tests/qt/test_entry_state.py
@@ -4,10 +4,11 @@
# pyright: reportPrivateUsage=false
+from PySide6.QtCore import QRect
+
from tagstudio.core.constants import TAG_ARCHIVED, TAG_FAVORITE
-from tagstudio.core.library.alchemy.enums import ItemType
from tagstudio.core.library.alchemy.library import Library
-from tagstudio.qt.controllers.item_thumb import ItemThumb
+from tagstudio.core.utils.types import unwrap
from tagstudio.qt.entry_state import EntryState
from tagstudio.qt.qt_driver import QtDriver
@@ -47,12 +48,10 @@ def test_unload_badges_rereads_from_library(library: Library):
def test_badge_click_updates_whole_selection(qt_driver: QtDriver):
- thumbs: list[ItemThumb] = []
- for entry_id in (1, 2):
- thumb = ItemThumb(ItemType.ENTRY, qt_driver.lib, qt_driver, (100, 100))
- thumb.set_item_id(entry_id)
- thumbs.append(thumb)
- qt_driver.main_window.thumb_layout._item_thumbs.extend(thumbs)
+ grid = qt_driver.main_window.thumb_grid
+ grid.set_entries([1, 2])
+ grid.layout().setGeometry(QRect(0, 0, 1000, 1000))
+ thumbs = [unwrap(grid.layout().widget_at(index)) for index in (0, 1)]
qt_driver.toggle_item_selection(1, append=False, bridge=False)
qt_driver.toggle_item_selection(2, append=True, bridge=False)
diff --git a/tests/qt/test_qt_driver.py b/tests/qt/test_qt_driver.py
index 2317bd30fb..adfd755511 100644
--- a/tests/qt/test_qt_driver.py
+++ b/tests/qt/test_qt_driver.py
@@ -12,7 +12,7 @@ def test_browsing_state_update(qt_driver: QtDriver):
entries = qt_driver.lib.all_entries(with_joins=True)
ids = [e.id for e in entries]
qt_driver.frame_content = ids
- qt_driver.main_window.thumb_layout.set_entries(ids)
+ qt_driver.main_window.thumb_grid.set_entries(ids)
# no filter, both items are returned
qt_driver.update_browsing_state()
@@ -47,7 +47,7 @@ def test_close_library(qt_driver: QtDriver):
assert qt_driver.lib.library_dir is None
assert not qt_driver.frame_content
assert not qt_driver.selected
- assert len(qt_driver.main_window.thumb_layout._entry_ids) == 0
+ assert len(qt_driver.main_window.thumb_grid._entry_ids) == 0
# close library again to see there's no error
qt_driver.close_library()
From e7d004e16f0c4d57664ecc7f218f3616301d43d1 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sun, 11 Oct 2026 01:27:05 -0700
Subject: [PATCH 05/10] fix(ui): make browsing history track pages and scroll
positions
---
src/tagstudio/core/library/alchemy/enums.py | 27 +++++-----
src/tagstudio/qt/controllers/thumb_grid.py | 8 ++-
src/tagstudio/qt/qt_driver.py | 56 ++++++++++-----------
tests/qt/test_qt_driver.py | 20 +++++++-
4 files changed, 64 insertions(+), 47 deletions(-)
diff --git a/src/tagstudio/core/library/alchemy/enums.py b/src/tagstudio/core/library/alchemy/enums.py
index 6cf1264be5..0fb06d4856 100644
--- a/src/tagstudio/core/library/alchemy/enums.py
+++ b/src/tagstudio/core/library/alchemy/enums.py
@@ -6,6 +6,7 @@
import random
from dataclasses import dataclass, field, replace
from pathlib import Path
+from typing import Any
import structlog
@@ -82,17 +83,15 @@ class SortingModeEnum(enum.Enum):
@dataclass
class BrowsingState:
- """Represent a state of the Library grid view."""
+ """Represent a browsing history state for a library."""
- page_index: int = 0
- page_positions: dict[int, int] = field(default_factory=dict)
- sorting_mode: SortingModeEnum = SortingModeEnum.DATE_ADDED
ascending: bool = False
+ first_visible_entry_id: int | None = field(default=None, compare=False)
+ page_index: int = 0
+ query: str | None = None
random_seed: float = 0
-
show_hidden_entries: bool = False
-
- query: str | None = None
+ sorting_mode: SortingModeEnum = SortingModeEnum.DATE_ADDED
# Abstract Syntax Tree Of the current Search Query
@property
@@ -141,19 +140,23 @@ def from_tag_name(cls, tag_name: str) -> BrowsingState:
return cls(query=f'tag:"{tag_name}"')
def with_page_index(self, index: int) -> BrowsingState:
- return replace(self, page_index=index)
+ return replace(self, page_index=index, first_visible_entry_id=None)
def with_sorting_mode(self, mode: SortingModeEnum) -> BrowsingState:
seed = self.random_seed
if mode == SortingModeEnum.RANDOM:
seed = random.random()
- return replace(self, sorting_mode=mode, random_seed=seed)
+ return self._with_new_results(sorting_mode=mode, random_seed=seed)
def with_sorting_direction(self, ascending: bool) -> BrowsingState:
- return replace(self, ascending=ascending)
+ return self._with_new_results(ascending=ascending)
def with_search_query(self, search_query: str) -> BrowsingState:
- return replace(self, query=search_query)
+ return self._with_new_results(query=search_query)
def with_show_hidden_entries(self, show_hidden_entries: bool) -> BrowsingState:
- return replace(self, show_hidden_entries=show_hidden_entries)
+ return self._with_new_results(show_hidden_entries=show_hidden_entries)
+
+ def _with_new_results(self, **changes: Any) -> BrowsingState:
+ """Copy this state with changes to its results, starting back on the first page."""
+ return replace(self, page_index=0, first_visible_entry_id=None, **changes)
diff --git a/src/tagstudio/qt/controllers/thumb_grid.py b/src/tagstudio/qt/controllers/thumb_grid.py
index c8cfb7322f..45e2f1d299 100644
--- a/src/tagstudio/qt/controllers/thumb_grid.py
+++ b/src/tagstudio/qt/controllers/thumb_grid.py
@@ -92,11 +92,9 @@ def set_entries(self, entry_ids: list[int]) -> None:
self._reset_renders()
self.layout().set_item_count(len(entry_ids))
- if self._scroll_to is not None:
- index = self._entry_indices.get(self._scroll_to)
- if index is not None:
- self.layout().scroll_to_index(index)
- self._scroll_to = None
+ index = None if self._scroll_to is None else self._entry_indices.get(self._scroll_to)
+ self._scroll_to = None
+ self.layout().scroll_to_index(index or 0)
def scroll_to(self, entry_id: int) -> None:
"""Scroll to this entry once the next entries are set."""
diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py
index d51139d18d..38b5f6fbaa 100644
--- a/src/tagstudio/qt/qt_driver.py
+++ b/src/tagstudio/qt/qt_driver.py
@@ -142,12 +142,19 @@ def erase_future(self) -> None:
self.__history = self.__history[: self.__index + 1]
def push(self, value: T) -> None:
+ """Make `value` current and drop any forward history, unless it equals the current one."""
+ if value == self.current:
+ return
self.erase_future()
self.__history.append(value)
self.__index = len(self.__history) - 1
- def move(self, delta: int):
- self.__index = clamp(self.__index + delta, 0, len(self.__history) - 1)
+ def move(self, delta: int) -> bool:
+ """Move through the history, returning whether the current value changed."""
+ index = clamp(self.__index + delta, 0, len(self.__history) - 1)
+ moved = index != self.__index
+ self.__index = index
+ return moved
@property
def current(self) -> T:
@@ -598,13 +605,8 @@ def create_about_modal():
self.main_window.search_field.textChanged.connect(self.update_completions_list)
- def on_visible_changed(entry_id: int | None):
- current = self.browsing_history.current
- page_index = current.page_index
- if entry_id is None:
- current.page_positions.pop(page_index)
- else:
- current.page_positions[page_index] = entry_id
+ def on_visible_changed(entry_id: int):
+ self.browsing_history.current.first_visible_entry_id = entry_id
self.main_window.thumb_grid.visible_changed.connect(on_visible_changed)
@@ -1286,16 +1288,9 @@ def page_move(self, value: int, absolute=False) -> None:
logger.info("page_move", value=value, absolute=absolute)
current = self.browsing_history.current
- if not absolute:
- current.page_index += value
- else:
- current.page_index = value
- current.page_index = clamp(current.page_index, 0, self.pages_count - 1)
-
- # TODO: The back mouse button will no longer move to the previous page and
- # instead goto the previous query passing a new state to update_browsing_state
- # will get this behaviour back but would mess with persisting page scroll positions
- self.update_browsing_state()
+ index = clamp(value if absolute else current.page_index + value, 0, self.pages_count - 1)
+ if index != current.page_index:
+ self.update_browsing_state(current.with_page_index(index))
def navigation_callback(self, delta: int) -> None:
"""Callback for the Forwads and Backwards Navigation Buttons next to the search bar."""
@@ -1304,9 +1299,8 @@ def navigation_callback(self, delta: int) -> None:
delta=delta,
)
- self.browsing_history.move(delta)
-
- self.update_browsing_state()
+ if self.browsing_history.move(delta):
+ self.update_browsing_state()
def copy_fields_action_callback(self):
if len(self.selected) > 0:
@@ -1476,15 +1470,17 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None:
logger.info("Library not loaded")
return
+ previous = self.browsing_history.current
if state:
self.browsing_history.push(state)
+ current = self.browsing_history.current
- self.main_window.search_field.setText(self.browsing_history.current.query or "")
+ self.main_window.search_field.setText(current.query or "")
# search the library
start_time = time.time()
Ignore.get_patterns(self.lib.library_dir, include_global=True)
- results = self.lib.search_library(self.browsing_history.current, page_size=0)
+ results = self.lib.search_library(current, page_size=0)
logger.info("items to render", count=len(results))
end_time = time.time()
@@ -1494,7 +1490,7 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None:
self.main_window.results_label.setText(
Translations.format(
# Show "X Results" on query, "X Items" on none
- "status.results_found" if state and state.query else "status.items",
+ "status.results_found" if current.query else "status.items",
count=count,
time_span=time_span,
)
@@ -1502,11 +1498,13 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None:
# update page content
self.frame_content = results.ids
- page_index = self.browsing_history.current.page_index
- if state is None:
- entry_id = self.browsing_history.current.page_positions.get(page_index)
- else:
+ if current is previous:
+ entry_id = current.first_visible_entry_id
+ elif current.query == previous.query and current.page_index == previous.page_index:
+ # Keep the selection in view when the same results are only resorted or filtered
entry_id = self.last_selected
+ else:
+ entry_id = None
if entry_id is not None:
self.main_window.thumb_grid.scroll_to(entry_id)
self.update_thumbs()
diff --git a/tests/qt/test_qt_driver.py b/tests/qt/test_qt_driver.py
index adfd755511..2d331233d2 100644
--- a/tests/qt/test_qt_driver.py
+++ b/tests/qt/test_qt_driver.py
@@ -4,7 +4,7 @@
from tagstudio.core.library.alchemy.enums import BrowsingState
from tagstudio.core.utils.types import unwrap
-from tagstudio.qt.qt_driver import QtDriver
+from tagstudio.qt.qt_driver import History, QtDriver
def test_browsing_state_update(qt_driver: QtDriver):
@@ -52,3 +52,21 @@ def test_close_library(qt_driver: QtDriver):
# close library again to see there's no error
qt_driver.close_library()
qt_driver.close_library(is_shutdown=True)
+
+
+def test_new_results_start_on_first_page():
+ state = BrowsingState(page_index=3, first_visible_entry_id=42)
+ new_state = state.with_search_query("tag_id:1000")
+ assert new_state.page_index == 0
+ assert new_state.first_visible_entry_id is None
+
+
+def test_history_skips_repeated_states():
+ history = History(BrowsingState.from_search_query("foo"))
+ history.push(BrowsingState(query="foo", first_visible_entry_id=1))
+ history.push(BrowsingState.from_search_query("bar"))
+
+ assert not history.move(1)
+ assert history.move(-1)
+ assert history.current.query == "foo"
+ assert not history.move(-1)
From e5a09067bc3984d9539fcf1a02ce831fc6f7170a Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sun, 11 Oct 2026 02:18:21 -0700
Subject: [PATCH 06/10] perf(ui): optimize waveform thumbs, drop pydub
---
nix/package/default.nix | 1 -
pyproject.toml | 2 -
src/tagstudio/previews/renderers/audio.py | 18 +-
.../previews/vendored/pydub/audio_segment.py | 1442 -----------------
.../previews/vendored/pydub/utils.py | 92 --
5 files changed, 12 insertions(+), 1543 deletions(-)
delete mode 100644 src/tagstudio/previews/vendored/pydub/audio_segment.py
delete mode 100644 src/tagstudio/previews/vendored/pydub/utils.py
diff --git a/nix/package/default.nix b/nix/package/default.nix
index fc987eca7f..f3f65b815b 100644
--- a/nix/package/default.nix
+++ b/nix/package/default.nix
@@ -54,7 +54,6 @@ python3Packages.buildPythonApplication {
pillow-heif
py7zr
pydantic
- pydub
pygments
pyside6
rarfile
diff --git a/pyproject.toml b/pyproject.toml
index 7e340db4fa..07a09e33c3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,7 +26,6 @@ dependencies = [
"Pillow>=10.2,<12",
"py7zr~=1.1.3",
"pydantic~=2.10",
- "pydub~=0.25",
"Pygments~=2.20",
"PySide6==6.11.2",
"rarfile==4.2",
@@ -101,7 +100,6 @@ filterwarnings = [
ignore = [
".venv/**",
"src/tagstudio/core/library/json/",
- "src/tagstudio/previews/vendored/pydub/",
]
include = ["src/tagstudio", "tests"]
# Reference for the settings here: https://github.com/microsoft/pyright/blob/main/docs/configuration.md
diff --git a/src/tagstudio/previews/renderers/audio.py b/src/tagstudio/previews/renderers/audio.py
index 6391351f47..a84a45eca0 100644
--- a/src/tagstudio/previews/renderers/audio.py
+++ b/src/tagstudio/previews/renderers/audio.py
@@ -2,6 +2,7 @@
# SPDX-License-Identifier: MIT
+import subprocess
from io import BytesIO
from pathlib import Path
from typing import override
@@ -17,15 +18,17 @@
from tagstudio.core.enums import Theme
from tagstudio.core.media_types import MediaTypes
+from tagstudio.core.utils.ffmpeg_status import FfmpegStatus
+from tagstudio.core.utils.silent_subprocess import silent_run
+from tagstudio.core.utils.types import unwrap
from tagstudio.previews.base_preview import RENDER, BasePreview
from tagstudio.previews.effects import apply_overlay_color
-from tagstudio.previews.vendored.pydub.audio_segment import (
- _AudioSegment as AudioSegment, # pyright: ignore[reportPrivateUsage]
-)
from tagstudio.qt.views.styles.palette import UiColor
logger = structlog.get_logger(__name__)
+_WAVEFORM_SAMPLE_RATE = 8000 # Good enough for the waveforms while still being small bits of data
+
class AudioPreview(BasePreview):
media_type_name = "audio"
@@ -120,8 +123,11 @@ def audio_waveform_thumb(filepath: Path, theme: Theme, size: tuple[int, int]) ->
try:
bar_count: int = 32
- audio = AudioSegment.from_file(filepath, filepath.suffix.lower()[1:]) # pyright: ignore[reportUnknownVariableType]
- data = np.frombuffer(buffer=audio._data, dtype=f"
current_x = current_x + line_width + bar_margin
- im.resize(size, Resampling.BILINEAR)
+ im = im.resize(size, Resampling.BILINEAR)
im = apply_overlay_color(im, UiColor.GREEN, theme)
except Exception as e:
diff --git a/src/tagstudio/previews/vendored/pydub/audio_segment.py b/src/tagstudio/previews/vendored/pydub/audio_segment.py
deleted file mode 100644
index 90aa005a82..0000000000
--- a/src/tagstudio/previews/vendored/pydub/audio_segment.py
+++ /dev/null
@@ -1,1442 +0,0 @@
-# SPDX-FileCopyrightText: (c) 2022 James Robert (jiaaro), http://jiaaro.com
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-# Vendored from pydub
-
-
-import array
-import base64
-import os
-import struct
-import subprocess
-import sys
-import wave
-from collections import namedtuple
-from io import BytesIO, StringIO
-from tempfile import NamedTemporaryFile
-
-from pydub.logging_utils import log_conversion, log_subprocess_output
-from pydub.utils import fsdecode
-
-from tagstudio.core.utils.ffmpeg_status import FfmpegStatus
-
-try:
- from itertools import izip
-except Exception:
- izip = zip
-
-from pydub.exceptions import (
- CouldntDecodeError,
- CouldntEncodeError,
- InvalidDuration,
- InvalidID3TagVersion,
- InvalidTag,
- MissingAudioParameter,
- TooManyMissingFrames,
-)
-from pydub.utils import (
- _fd_or_path_or_tempfile,
- audioop,
- db_to_float,
- get_array_type,
- ratio_to_db,
-)
-
-from tagstudio.core.utils.silent_subprocess import silent_popen
-from tagstudio.previews.vendored.pydub.utils import _mediainfo_json
-
-basestring = str
-xrange = range
-StringIO = BytesIO # noqa: F811
-
-
-class ClassPropertyDescriptor:
- def __init__(self, fget, fset=None):
- self.fget = fget
- self.fset = fset
-
- def __get__(self, obj, klass=None):
- if klass is None:
- klass = type(obj)
- return self.fget.__get__(obj, klass)()
-
- def __set__(self, obj, value):
- if not self.fset:
- raise AttributeError("can't set attribute")
- type_ = type(obj)
- return self.fset.__get__(obj, type_)(value)
-
- def setter(self, func):
- if not isinstance(func, (classmethod, staticmethod)):
- func = classmethod(func)
- self.fset = func
- return self
-
-
-def classproperty(func):
- if not isinstance(func, (classmethod, staticmethod)):
- func = classmethod(func)
-
- return ClassPropertyDescriptor(func)
-
-
-AUDIO_FILE_EXT_ALIASES = {
- "m4a": "mp4",
- "m4r": "mp4",
- "wave": "wav",
- "aif": "aiff",
- "aifc": "aiff",
-}
-
-WavSubChunk = namedtuple("WavSubChunk", ["id", "position", "size"])
-WavData = namedtuple(
- "WavData", ["audio_format", "channels", "sample_rate", "bits_per_sample", "raw_data"]
-)
-
-
-def extract_wav_headers(data):
- # def search_subchunk(data, subchunk_id):
- pos = 12 # The size of the RIFF chunk descriptor
- subchunks = []
- while pos + 8 <= len(data) and len(subchunks) < 10:
- subchunk_id = data[pos : pos + 4]
- subchunk_size = struct.unpack_from(" 2**32:
- raise CouldntDecodeError("Unable to process >4GB files")
-
- # Set the file size in the RIFF chunk descriptor
- data[4:8] = struct.pack(" b"\x7f"[0]])
- old_bytes = struct.pack(pack_fmt, b0, b1, b2)
- byte_buffer.write(old_bytes)
-
- self._data = byte_buffer.getvalue()
- self.sample_width = 4
- self.frame_width = self.channels * self.sample_width
-
- super().__init__(*args, **kwargs)
-
- @property
- def raw_data(self):
- """Public access to the raw audio data as a bytestring."""
- return self._data
-
- def get_array_of_samples(self, array_type_override=None):
- """Return the raw_data as an array of samples."""
- if array_type_override is None:
- array_type_override = self.array_type
- return array.array(array_type_override, self._data)
-
- @property
- def array_type(self):
- return get_array_type(self.sample_width * 8)
-
- def __len__(self):
- """Return the length of this audio segment in milliseconds."""
- return round(1000 * (self.frame_count() / self.frame_rate))
-
- def __eq__(self, other):
- try:
- return self._data == other._data
- except Exception:
- return False
-
- def __hash__(self):
- return hash(_AudioSegment) ^ hash(
- (self.channels, self.frame_rate, self.sample_width, self._data)
- )
-
- def __ne__(self, other):
- return not (self == other)
-
- def __iter__(self):
- return (self[i] for i in xrange(len(self)))
-
- def __getitem__(self, millisecond):
- if isinstance(millisecond, slice):
- if millisecond.step:
- return (
- self[i : i + millisecond.step] for i in xrange(*millisecond.indices(len(self)))
- )
-
- start = millisecond.start if millisecond.start is not None else 0
- end = millisecond.stop if millisecond.stop is not None else len(self)
-
- start = min(start, len(self))
- end = min(end, len(self))
- else:
- start = millisecond
- end = millisecond + 1
-
- start = self._parse_position(start) * self.frame_width
- end = self._parse_position(end) * self.frame_width
- data = self._data[start:end]
-
- # ensure the output is as long as the requester is expecting
- expected_length = end - start
- missing_frames = (expected_length - len(data)) // self.frame_width
- if missing_frames:
- if missing_frames > self.frame_count(ms=2):
- raise TooManyMissingFrames(
- "You should never be filling in " # noqa: UP031
- " more than 2 ms with silence here, "
- "missing frames: %s" % missing_frames
- )
- silence = audioop.mul(data[: self.frame_width], self.sample_width, 0)
- data += silence * missing_frames
-
- return self._spawn(data)
-
- def get_sample_slice(self, start_sample=None, end_sample=None):
- """Get a section of the audio segment by sample index.
-
- NOTE: Negative indices do *not* address samples backword
- from the end of the audio segment like a python list.
- This is intentional.
- """
- max_val = int(self.frame_count())
-
- def bounded(val, default):
- if val is None:
- return default
- if val < 0:
- return 0
- if val > max_val:
- return max_val
- return val
-
- start_i = bounded(start_sample, 0) * self.frame_width
- end_i = bounded(end_sample, max_val) * self.frame_width
-
- data = self._data[start_i:end_i]
- return self._spawn(data)
-
- def __add__(self, arg):
- if isinstance(arg, _AudioSegment):
- return self.append(arg, crossfade=0)
- else:
- return self.apply_gain(arg)
-
- def __radd__(self, rarg):
- """Permit use of sum() builtin with an iterable of AudioSegments."""
- if rarg == 0:
- return self
- raise TypeError("Gains must be the second addend after the AudioSegment")
-
- def __sub__(self, arg):
- if isinstance(arg, _AudioSegment):
- raise TypeError("AudioSegment objects can't be subtracted from each other")
- else:
- return self.apply_gain(-arg)
-
- def __mul__(self, arg):
- """If the argument is an AudioSegment, overlay the multiplied audio segment.
-
- If it's a number, just use the string multiply operation to repeat the
- audio.
-
- The following would return an AudioSegment that contains the
- audio of audio_seg eight times
-
- `audio_seg * 8`
- """
- if isinstance(arg, _AudioSegment):
- return self.overlay(arg, position=0, loop=True)
- else:
- return self._spawn(data=self._data * arg)
-
- def _spawn(self, data, overrides={}): # noqa: B006
- """Create a new audio segment using the metadata from the current one & the data passed in.
-
- Should be used whenever an AudioSegment is being returned by an operation that would alters
- the current one, since AudioSegment objects are immutable.
- """
- # accept lists of data chunks
- if isinstance(data, list):
- data = b"".join(data)
-
- if isinstance(data, array.array):
- try:
- data = data.tobytes()
- except Exception:
- data = data.tostring()
-
- # accept file-like objects
- if hasattr(data, "read"):
- if hasattr(data, "seek"):
- data.seek(0)
- data = data.read()
-
- metadata = {
- "sample_width": self.sample_width,
- "frame_rate": self.frame_rate,
- "frame_width": self.frame_width,
- "channels": self.channels,
- }
- metadata.update(overrides)
- return self.__class__(data=data, metadata=metadata)
-
- @classmethod
- def _sync(cls, *segs):
- channels = max(seg.channels for seg in segs)
- frame_rate = max(seg.frame_rate for seg in segs)
- sample_width = max(seg.sample_width for seg in segs)
-
- return tuple(
- seg.set_channels(channels).set_frame_rate(frame_rate).set_sample_width(sample_width)
- for seg in segs
- )
-
- def _parse_position(self, val):
- if val < 0:
- val = len(self) - abs(val)
- val = self.frame_count(ms=len(self)) if val == float("inf") else self.frame_count(ms=val)
- return int(val)
-
- @classmethod
- def empty(cls):
- return cls(
- b"", metadata={"channels": 1, "sample_width": 1, "frame_rate": 1, "frame_width": 1}
- )
-
- @classmethod
- def silent(cls, duration=1000, frame_rate=11025):
- """Generate a silent audio segment.
-
- Duration specified in milliseconds (default duration: 1000ms, default frame_rate: 11025).
- """
- frames = int(frame_rate * (duration / 1000.0))
- data = b"\0\0" * frames
- return cls(
- data,
- metadata={"channels": 1, "sample_width": 2, "frame_rate": frame_rate, "frame_width": 2},
- )
-
- @classmethod
- def from_mono_audiosegments(cls, *mono_segments):
- if not len(mono_segments):
- raise ValueError("At least one AudioSegment instance is required")
-
- segs = cls._sync(*mono_segments)
-
- if segs[0].channels != 1:
- raise ValueError(
- "AudioSegment.from_mono_audiosegments requires all "
- "arguments are mono AudioSegment instances"
- )
-
- channels = len(segs)
- sample_width = segs[0].sample_width
- frame_rate = segs[0].frame_rate
-
- frame_count = max(int(seg.frame_count()) for seg in segs)
- data = array.array(segs[0].array_type, b"\0" * (frame_count * sample_width * channels))
-
- for i, seg in enumerate(segs):
- data[i::channels] = seg.get_array_of_samples()
-
- return cls(
- data,
- channels=channels,
- sample_width=sample_width,
- frame_rate=frame_rate,
- )
-
- @classmethod
- def from_file_using_temporary_files(
- cls,
- file,
- format=None,
- codec=None,
- parameters=None,
- start_second=None,
- duration=None,
- **kwargs,
- ):
- orig_file = file
- file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False)
-
- if format:
- format = format.lower()
- format = AUDIO_FILE_EXT_ALIASES.get(format, format)
-
- def is_format(f):
- f = f.lower()
- if format == f:
- return True
- if isinstance(orig_file, basestring):
- return orig_file.lower().endswith(f".{f}")
- if isinstance(orig_file, bytes):
- return orig_file.lower().endswith((f".{f}").encode())
- return False
-
- if is_format("wav"):
- try:
- obj = cls._from_safe_wav(file)
- if close_file:
- file.close()
- if start_second is None and duration is None:
- return obj
- elif start_second is not None and duration is None:
- return obj[start_second * 1000 :]
- elif start_second is None and duration is not None:
- return obj[: duration * 1000]
- else:
- return obj[start_second * 1000 : (start_second + duration) * 1000]
- except Exception:
- file.seek(0)
- elif is_format("raw") or is_format("pcm"):
- sample_width = kwargs["sample_width"]
- frame_rate = kwargs["frame_rate"]
- channels = kwargs["channels"]
- metadata = {
- "sample_width": sample_width,
- "frame_rate": frame_rate,
- "channels": channels,
- "frame_width": channels * sample_width,
- }
- obj = cls(data=file.read(), metadata=metadata)
- if close_file:
- file.close()
- if start_second is None and duration is None:
- return obj
- elif start_second is not None and duration is None:
- return obj[start_second * 1000 :]
- elif start_second is None and duration is not None:
- return obj[: duration * 1000]
- else:
- return obj[start_second * 1000 : (start_second + duration) * 1000]
-
- input_file = NamedTemporaryFile(mode="wb", delete=False)
- try:
- input_file.write(file.read())
- except OSError:
- input_file.flush()
- input_file.close()
- input_file = NamedTemporaryFile(mode="wb", delete=False, buffering=2**31 - 1)
- if close_file:
- file.close()
- close_file = True
- file = open(orig_file, buffering=2**13 - 1, mode="rb")
- reader = file.read(2**31 - 1)
- while reader:
- input_file.write(reader)
- reader = file.read(2**31 - 1)
- input_file.flush()
- if close_file:
- file.close()
-
- output = NamedTemporaryFile(mode="rb", delete=False)
-
- conversion_command = [
- cls.converter,
- "-y", # always overwrite existing files
- ]
-
- # If format is not defined
- # ffmpeg/avconv will detect it automatically
- if format:
- conversion_command += ["-f", format]
-
- if codec:
- # force audio decoder
- conversion_command += ["-acodec", codec]
-
- conversion_command += [
- "-i",
- input_file.name, # input_file options (filename last)
- "-vn", # Drop any video streams if there are any
- "-f",
- "wav", # output options (filename last)
- ]
-
- if start_second is not None:
- conversion_command += ["-ss", str(start_second)]
-
- if duration is not None:
- conversion_command += ["-t", str(duration)]
-
- conversion_command += [output.name]
-
- if parameters is not None:
- # extend arguments with arbitrary set
- conversion_command.extend(parameters)
-
- log_conversion(conversion_command)
-
- with open(os.devnull, "rb") as devnull:
- # PATCHED
- p = silent_popen(
- conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE
- )
- p_out, p_err = p.communicate()
-
- log_subprocess_output(p_out)
- log_subprocess_output(p_err)
-
- try:
- if p.returncode != 0:
- raise CouldntDecodeError(
- "Decoding failed. ffmpeg returned error code: " # noqa: UP030
- "{0}\n\nOutput from ffmpeg/avlib:\n\n{1}".format(
- p.returncode, p_err.decode(errors="ignore")
- )
- )
- obj = cls._from_safe_wav(output)
- finally:
- input_file.close()
- output.close()
- os.unlink(input_file.name)
- os.unlink(output.name)
-
- if start_second is None and duration is None:
- return obj
- elif start_second is not None and duration is None:
- return obj[0:]
- elif start_second is None and duration is not None:
- return obj[: duration * 1000]
- else:
- return obj[0 : duration * 1000]
-
- @classmethod
- def from_file(
- cls,
- file,
- format=None,
- codec=None,
- parameters=None,
- start_second=None,
- duration=None,
- **kwargs,
- ):
- orig_file = file
- try:
- filename = fsdecode(file)
- except TypeError:
- filename = None
- file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False)
-
- if format:
- format = format.lower()
- format = AUDIO_FILE_EXT_ALIASES.get(format, format)
-
- def is_format(f):
- f = f.lower()
- if format == f:
- return True
-
- if filename:
- return filename.lower().endswith(f".{f}")
-
- return False
-
- if is_format("wav"):
- try:
- if start_second is None and duration is None:
- return cls._from_safe_wav(file)
- elif start_second is not None and duration is None:
- return cls._from_safe_wav(file)[start_second * 1000 :]
- elif start_second is None and duration is not None:
- return cls._from_safe_wav(file)[: duration * 1000]
- else:
- return cls._from_safe_wav(file)[
- start_second * 1000 : (start_second + duration) * 1000
- ]
- except Exception:
- file.seek(0)
- elif is_format("raw") or is_format("pcm"):
- sample_width = kwargs["sample_width"]
- frame_rate = kwargs["frame_rate"]
- channels = kwargs["channels"]
- metadata = {
- "sample_width": sample_width,
- "frame_rate": frame_rate,
- "channels": channels,
- "frame_width": channels * sample_width,
- }
- if start_second is None and duration is None:
- return cls(data=file.read(), metadata=metadata)
- elif start_second is not None and duration is None:
- return cls(data=file.read(), metadata=metadata)[start_second * 1000 :]
- elif start_second is None and duration is not None:
- return cls(data=file.read(), metadata=metadata)[: duration * 1000]
- else:
- return cls(data=file.read(), metadata=metadata)[
- start_second * 1000 : (start_second + duration) * 1000
- ]
-
- conversion_command = [
- cls.converter,
- "-y", # always overwrite existing files
- ]
-
- # If format is not defined
- # ffmpeg/avconv will detect it automatically
- if format:
- conversion_command += ["-f", format]
-
- if codec:
- # force audio decoder
- conversion_command += ["-acodec", codec]
-
- read_ahead_limit = kwargs.get("read_ahead_limit", -1)
- if filename:
- conversion_command += ["-i", filename]
- stdin_parameter = None
- stdin_data = None
- else:
- if cls.converter == FfmpegStatus.which():
- conversion_command += [
- "-read_ahead_limit",
- str(read_ahead_limit),
- "-i",
- "cache:pipe:0",
- ]
- else:
- conversion_command += ["-i", "-"]
- stdin_parameter = subprocess.PIPE
- stdin_data = file.read()
-
- # PATCHED
- info = None if codec else _mediainfo_json(orig_file, read_ahead_limit=read_ahead_limit)
- if info:
- audio_streams = [x for x in info["streams"] if x["codec_type"] == "audio"]
- # This is a workaround for some ffprobe versions that always say
- # that mp3/mp4/aac/webm/ogg files contain fltp samples
- audio_codec = audio_streams[0].get("codec_name")
- if audio_streams[0].get("sample_fmt") == "fltp" and audio_codec in [
- "mp3",
- "mp4",
- "aac",
- "webm",
- "ogg",
- ]:
- bits_per_sample = 16
- else:
- bits_per_sample = audio_streams[0]["bits_per_sample"]
- acodec = "pcm_u8" if bits_per_sample == 8 else "pcm_s%dle" % bits_per_sample
-
- conversion_command += ["-acodec", acodec]
-
- conversion_command += [
- "-vn", # Drop any video streams if there are any
- "-f",
- "wav", # output options (filename last)
- ]
-
- if start_second is not None:
- conversion_command += ["-ss", str(start_second)]
-
- if duration is not None:
- conversion_command += ["-t", str(duration)]
-
- conversion_command += ["-"]
-
- if parameters is not None:
- # extend arguments with arbitrary set
- conversion_command.extend(parameters)
-
- log_conversion(conversion_command)
-
- # PATCHED
- p = silent_popen(
- conversion_command,
- stdin=stdin_parameter,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- )
- p_out, p_err = p.communicate(input=stdin_data)
-
- if p.returncode != 0 or len(p_out) == 0:
- if close_file:
- file.close()
- raise CouldntDecodeError(
- "Decoding failed. ffmpeg returned error code: " # noqa: UP030
- "{0}\n\nOutput from ffmpeg/avlib:\n\n{1}".format(
- p.returncode, p_err.decode(errors="ignore")
- )
- )
-
- p_out = bytearray(p_out)
- fix_wav_headers(p_out)
- p_out = bytes(p_out)
- obj = cls(p_out)
-
- if close_file:
- file.close()
-
- if start_second is None and duration is None:
- return obj
- elif start_second is not None and duration is None:
- return obj[0:]
- elif start_second is None and duration is not None:
- return obj[: duration * 1000]
- else:
- return obj[0 : duration * 1000]
-
- @classmethod
- def from_mp3(cls, file, parameters=None):
- return cls.from_file(file, "mp3", parameters=parameters)
-
- @classmethod
- def from_flv(cls, file, parameters=None):
- return cls.from_file(file, "flv", parameters=parameters)
-
- @classmethod
- def from_ogg(cls, file, parameters=None):
- return cls.from_file(file, "ogg", parameters=parameters)
-
- @classmethod
- def from_wav(cls, file, parameters=None):
- return cls.from_file(file, "wav", parameters=parameters)
-
- @classmethod
- def from_raw(cls, file, **kwargs):
- return cls.from_file(
- file,
- "raw",
- sample_width=kwargs["sample_width"],
- frame_rate=kwargs["frame_rate"],
- channels=kwargs["channels"],
- )
-
- @classmethod
- def _from_safe_wav(cls, file):
- file, close_file = _fd_or_path_or_tempfile(file, "rb", tempfile=False)
- file.seek(0)
- obj = cls(data=file)
- if close_file:
- file.close()
- return obj
-
- def export(
- self,
- out_f=None,
- format="mp3",
- codec=None,
- bitrate=None,
- parameters=None,
- tags=None,
- id3v2_version="4",
- cover=None,
- ):
- """Export an AudioSegment to a file with the given options.
-
- out_f (string):
- Path to destination audio file. Also accepts os.PathLike objects on
- python >= 3.6
-
- format (string)
- Format for destination audio file.
- ('mp3', 'wav', 'raw', 'ogg' or other ffmpeg/avconv supported files)
-
- codec (string)
- Codec used to encode the destination file.
-
- bitrate (string)
- Bitrate used when encoding destination file. (64, 92, 128, 256, 312k...)
- Each codec accepts different bitrate arguments so take a look at the
- ffmpeg documentation for details (bitrate usually shown as -b, -ba or
- -a:b).
-
- parameters (list of strings)
- Aditional ffmpeg/avconv parameters
-
- tags (dict)
- Set metadata information to destination files
- usually used as tags. ({title='Song Title', artist='Song Artist'})
-
- id3v2_version (string)
- Set ID3v2 version for tags. (default: '4')
-
- cover (file)
- Set cover for audio file from image file. (png or jpg)
- """
- id3v2_allowed_versions = ["3", "4"]
-
- if format == "raw" and (codec is not None or parameters is not None):
- raise AttributeError(
- 'Can not invoke ffmpeg when export format is "raw"; '
- 'specify an ffmpeg raw format like format="s16le" instead '
- 'or call export(format="raw") with no codec or parameters'
- )
-
- out_f, _ = _fd_or_path_or_tempfile(out_f, "wb+")
- out_f.seek(0)
-
- if format == "raw":
- out_f.write(self._data)
- out_f.seek(0)
- return out_f
-
- # wav with no ffmpeg parameters can just be written directly to out_f
- easy_wav = format == "wav" and codec is None and parameters is None
-
- data = out_f if easy_wav else NamedTemporaryFile(mode="wb", delete=False)
-
- pcm_for_wav = self._data
- if self.sample_width == 1:
- # convert to unsigned integers for wav
- pcm_for_wav = audioop.bias(self._data, 1, 128)
-
- wave_data = wave.open(data, "wb")
- wave_data.setnchannels(self.channels)
- wave_data.setsampwidth(self.sample_width)
- wave_data.setframerate(self.frame_rate)
- # For some reason packing the wave header struct with
- # a float in python 2 doesn't throw an exception
- wave_data.setnframes(int(self.frame_count()))
- wave_data.writeframesraw(pcm_for_wav)
- wave_data.close()
-
- # for easy wav files, we're done (wav data is written directly to out_f)
- if easy_wav:
- out_f.seek(0)
- return out_f
-
- output = NamedTemporaryFile(mode="w+b", delete=False)
-
- # build converter command to export
- conversion_command = [
- self.converter,
- "-y", # always overwrite existing files
- "-f",
- "wav",
- "-i",
- data.name, # input options (filename last)
- ]
-
- if codec is None:
- codec = self.DEFAULT_CODECS.get(format, None)
-
- if cover is not None:
- if (
- cover.lower().endswith((".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"))
- and format == "mp3"
- ):
- conversion_command.extend(["-i", cover, "-map", "0", "-map", "1", "-c:v", "mjpeg"])
- else:
- raise AttributeError(
- "Currently cover images are only supported by MP3 files. The allowed image "
- "formats are: .tif, .jpg, .bmp, .jpeg and .png."
- )
-
- if codec is not None:
- # force audio encoder
- conversion_command.extend(["-acodec", codec])
-
- if bitrate is not None:
- conversion_command.extend(["-b:a", bitrate])
-
- if parameters is not None:
- # extend arguments with arbitrary set
- conversion_command.extend(parameters)
-
- if tags is not None:
- if not isinstance(tags, dict):
- raise InvalidTag("Tags must be a dictionary.")
- else:
- # Extend converter command with tags
- # print(tags)
- for key, value in tags.items():
- conversion_command.extend(["-metadata", f"{key}={value}"])
-
- if format == "mp3":
- # set id3v2 tag version
- if id3v2_version not in id3v2_allowed_versions:
- raise InvalidID3TagVersion(
- "id3v2_version not allowed, allowed versions: %s" # noqa: UP031
- % id3v2_allowed_versions
- )
- conversion_command.extend(["-id3v2_version", id3v2_version])
-
- if sys.platform == "darwin" and codec == "mp3":
- conversion_command.extend(["-write_xing", "0"])
-
- conversion_command.extend(
- [
- "-f",
- format,
- output.name, # output options (filename last)
- ]
- )
-
- log_conversion(conversion_command)
-
- # read stdin / write stdout
- with open(os.devnull, "rb") as devnull:
- # PATCHED
- p = silent_popen(
- conversion_command, stdin=devnull, stdout=subprocess.PIPE, stderr=subprocess.PIPE
- )
- p_out, p_err = p.communicate()
-
- log_subprocess_output(p_out)
- log_subprocess_output(p_err)
-
- try:
- if p.returncode != 0:
- raise CouldntEncodeError(
- "Encoding failed. ffmpeg/avlib returned error code: " # noqa: UP030
- "{0}\n\nCommand:{1}\n\nOutput from ffmpeg/avlib:\n\n{2}".format(
- p.returncode, conversion_command, p_err.decode(errors="ignore")
- )
- )
-
- output.seek(0)
- out_f.write(output.read())
-
- finally:
- data.close()
- output.close()
- os.unlink(data.name)
- os.unlink(output.name)
-
- out_f.seek(0)
- return out_f
-
- def get_frame(self, index):
- frame_start = index * self.frame_width
- frame_end = frame_start + self.frame_width
- return self._data[frame_start:frame_end]
-
- def frame_count(self, ms=None):
- """Return the number of frames for the given number of milliseconds.
-
- If not specified, return the number of frames in the whole AudioSegment
- """
- if ms is not None:
- return ms * (self.frame_rate / 1000.0)
- else:
- return float(len(self._data) // self.frame_width)
-
- def set_sample_width(self, sample_width):
- if sample_width == self.sample_width:
- return self
-
- frame_width = self.channels * sample_width
-
- return self._spawn(
- audioop.lin2lin(self._data, self.sample_width, sample_width),
- overrides={"sample_width": sample_width, "frame_width": frame_width},
- )
-
- def set_frame_rate(self, frame_rate):
- if frame_rate == self.frame_rate:
- return self
-
- if self._data:
- converted, _ = audioop.ratecv(
- self._data, self.sample_width, self.channels, self.frame_rate, frame_rate, None
- )
- else:
- converted = self._data
-
- return self._spawn(data=converted, overrides={"frame_rate": frame_rate})
-
- def set_channels(self, channels):
- if channels == self.channels:
- return self
-
- if channels == 2 and self.channels == 1:
- fn = audioop.tostereo
- frame_width = self.frame_width * 2
- fac = 1
- converted = fn(self._data, self.sample_width, fac, fac)
- elif channels == 1 and self.channels == 2:
- fn = audioop.tomono
- frame_width = self.frame_width // 2
- fac = 0.5
- converted = fn(self._data, self.sample_width, fac, fac)
- elif channels == 1:
- channels_data = [seg.get_array_of_samples() for seg in self.split_to_mono()]
- frame_count = int(self.frame_count())
- converted = array.array(
- channels_data[0].typecode, b"\0" * (frame_count * self.sample_width)
- )
- for raw_channel_data in channels_data:
- for i in range(frame_count):
- converted[i] += raw_channel_data[i] // self.channels
- frame_width = self.frame_width // self.channels
- elif self.channels == 1:
- dup_channels = [self for iChannel in range(channels)]
- return _AudioSegment.from_mono_audiosegments(*dup_channels)
- else:
- raise ValueError(
- "AudioSegment.set_channels only supports mono-to-multi channel "
- "and multi-to-mono channel conversion"
- )
-
- return self._spawn(
- data=converted, overrides={"channels": channels, "frame_width": frame_width}
- )
-
- def split_to_mono(self):
- if self.channels == 1:
- return [self]
-
- samples = self.get_array_of_samples()
-
- mono_channels = []
- for i in range(self.channels):
- samples_for_current_channel = samples[i :: self.channels]
-
- try:
- mono_data = samples_for_current_channel.tobytes()
- except AttributeError:
- mono_data = samples_for_current_channel.tostring()
-
- mono_channels.append(
- self._spawn(mono_data, overrides={"channels": 1, "frame_width": self.sample_width})
- )
-
- return mono_channels
-
- @property
- def rms(self):
- return audioop.rms(self._data, self.sample_width)
-
- @property
- def dBFS(self): # noqa: N802
- rms = self.rms
- if not rms:
- return -float("infinity")
- return ratio_to_db(self.rms / self.max_possible_amplitude)
-
- @property
- def max(self):
- return audioop.max(self._data, self.sample_width)
-
- @property
- def max_possible_amplitude(self):
- bits = self.sample_width * 8
- max_possible_val = 2**bits
-
- # since half is above 0 and half is below the max amplitude is divided
- return max_possible_val / 2
-
- @property
- def max_dBFS(self):
- return ratio_to_db(self.max, self.max_possible_amplitude)
-
- @property
- def duration_seconds(self):
- return self.frame_rate and self.frame_count() / self.frame_rate or 0.0
-
- def get_dc_offset(self, channel=1):
- """Return a value between -1.0 and 1.0 representing the DC offset of a channel.
-
- 1 for left, 2 for right.
- """
- if not 1 <= channel <= 2:
- raise ValueError("channel value must be 1 (left) or 2 (right)")
-
- if self.channels == 1:
- data = self._data
- elif channel == 1:
- data = audioop.tomono(self._data, self.sample_width, 1, 0)
- else:
- data = audioop.tomono(self._data, self.sample_width, 0, 1)
-
- return float(audioop.avg(data, self.sample_width)) / self.max_possible_amplitude
-
- def remove_dc_offset(self, channel=None, offset=None):
- """Remove DC offset of given channel. Calculates offset if it's not given.
-
- Offset values must be in range -1.0 to 1.0. If channel is None, removes
- DC offset from all available channels.
- """
- if channel and not 1 <= channel <= 2:
- raise ValueError("channel value must be None, 1 (left) or 2 (right)")
-
- if offset and not -1.0 <= offset <= 1.0:
- raise ValueError("offset value must be in range -1.0 to 1.0")
-
- if offset:
- offset = int(round(offset * self.max_possible_amplitude))
-
- def remove_data_dc(data, off):
- if not off:
- off = audioop.avg(data, self.sample_width)
- return audioop.bias(data, self.sample_width, -off)
-
- if self.channels == 1:
- return self._spawn(data=remove_data_dc(self._data, offset))
-
- left_channel = audioop.tomono(self._data, self.sample_width, 1, 0)
- right_channel = audioop.tomono(self._data, self.sample_width, 0, 1)
-
- if not channel or channel == 1:
- left_channel = remove_data_dc(left_channel, offset)
-
- if not channel or channel == 2:
- right_channel = remove_data_dc(right_channel, offset)
-
- left_channel = audioop.tostereo(left_channel, self.sample_width, 1, 0)
- right_channel = audioop.tostereo(right_channel, self.sample_width, 0, 1)
-
- return self._spawn(data=audioop.add(left_channel, right_channel, self.sample_width))
-
- def apply_gain(self, volume_change):
- return self._spawn(
- data=audioop.mul(self._data, self.sample_width, db_to_float(float(volume_change)))
- )
-
- def overlay(self, seg, position=0, loop=False, times=None, gain_during_overlay=None):
- """Overlay the provided segment on to this segment.
-
- Starts at the specified position and uses the specified looping behavior.
-
- seg (AudioSegment):
- The audio segment to overlay on to this one.
-
- position (optional int):
- The position to start overlaying the provided segment in to this
- one.
-
- loop (optional bool):
- Loop seg as many times as necessary to match this segment's length.
- Overrides loops param.
-
- times (optional int):
- Loop seg the specified number of times or until it matches this
- segment's length. 1 means once, 2 means twice, ... 0 would make the
- call a no-op
- gain_during_overlay (optional int):
- Changes this segment's volume by the specified amount during the
- duration of time that seg is overlaid on top of it. When negative,
- this has the effect of 'ducking' the audio under the overlay.
- """
- if loop:
- # match loop=True's behavior with new times (count) mechanism.
- times = -1
- elif times is None:
- # no times specified, just once through
- times = 1
- elif times == 0:
- # it's a no-op, make a copy since we never mutate
- return self._spawn(self._data)
-
- output = StringIO()
-
- seg1, seg2 = _AudioSegment._sync(self, seg)
- sample_width = seg1.sample_width
- spawn = seg1._spawn
-
- output.write(seg1[:position]._data)
-
- # drop down to the raw data
- seg1 = seg1[position:]._data
- seg2 = seg2._data
- pos = 0
- seg1_len = len(seg1)
- seg2_len = len(seg2)
- while times:
- remaining = max(0, seg1_len - pos)
- if seg2_len >= remaining:
- seg2 = seg2[:remaining]
- seg2_len = remaining
- # we've hit the end, we're done looping (if we were) and this
- # is our last go-around
- times = 1
-
- if gain_during_overlay:
- seg1_overlaid = seg1[pos : pos + seg2_len]
- seg1_adjusted_gain = audioop.mul(
- seg1_overlaid, self.sample_width, db_to_float(float(gain_during_overlay))
- )
- output.write(audioop.add(seg1_adjusted_gain, seg2, sample_width))
- else:
- output.write(audioop.add(seg1[pos : pos + seg2_len], seg2, sample_width))
- pos += seg2_len
-
- # dec times to break our while loop (eventually)
- times -= 1
-
- output.write(seg1[pos:])
-
- return spawn(data=output)
-
- def append(self, seg, crossfade=100):
- seg1, seg2 = _AudioSegment._sync(self, seg)
-
- if not crossfade:
- return seg1._spawn(seg1._data + seg2._data)
- elif crossfade > len(self):
- raise ValueError(
- f"Crossfade is longer than the original AudioSegment ({crossfade}ms > {len(self)}ms)"
- )
- elif crossfade > len(seg):
- raise ValueError(
- f"Crossfade is longer than the appended AudioSegment ({crossfade}ms > {len(seg)}ms)"
- )
-
- xf = seg1[-crossfade:].fade(to_gain=-120, start=0, end=float("inf"))
- xf *= seg2[:crossfade].fade(from_gain=-120, start=0, end=float("inf"))
-
- output = BytesIO()
-
- output.write(seg1[:-crossfade]._data)
- output.write(xf._data)
- output.write(seg2[crossfade:]._data)
-
- output.seek(0)
- obj = seg1._spawn(data=output)
- output.close()
- return obj
-
- def fade(self, to_gain=0, from_gain=0, start=None, end=None, duration=None):
- """Fade the volume of this audio segment.
-
- to_gain (float):
- resulting volume_change in db
-
- start (int):
- default = beginning of the segment
- when in this segment to start fading in milliseconds
-
- end (int):
- default = end of the segment
- when in this segment to start fading in milliseconds
-
- duration (int):
- default = until the end of the audio segment
- the duration of the fade
- """
- if None not in [duration, end, start]:
- raise TypeError(
- 'Only two of the three arguments, "start", "end", and "duration" may be specified'
- )
-
- # no fade == the same audio
- if to_gain == 0 and from_gain == 0:
- return self
-
- start = min(len(self), start) if start is not None else None
- end = min(len(self), end) if end is not None else None
-
- if start is not None and start < 0:
- start += len(self)
- if end is not None and end < 0:
- end += len(self)
-
- if duration is not None and duration < 0:
- raise InvalidDuration("duration must be a positive integer")
-
- if duration:
- if start is not None:
- end = start + duration
- elif end is not None:
- start = end - duration
- else:
- duration = end - start
-
- from_power = db_to_float(from_gain)
-
- output = []
-
- # original data - up until the crossfade portion, as is
- before_fade = self[:start]._data
- if from_gain != 0:
- before_fade = audioop.mul(before_fade, self.sample_width, from_power)
- output.append(before_fade)
-
- gain_delta = db_to_float(to_gain) - from_power
-
- # fades longer than 100ms can use coarse fading (one gain step per ms),
- # shorter fades will have audible clicks so they use precise fading
- # (one gain step per sample)
- if duration > 100:
- scale_step = gain_delta / duration
-
- for i in range(duration):
- volume_change = from_power + (scale_step * i)
- chunk = self[start + i]
- chunk = audioop.mul(chunk._data, self.sample_width, volume_change)
-
- output.append(chunk)
- else:
- start_frame = self.frame_count(ms=start)
- end_frame = self.frame_count(ms=end)
- fade_frames = end_frame - start_frame
- scale_step = gain_delta / fade_frames
-
- for i in range(int(fade_frames)):
- volume_change = from_power + (scale_step * i)
- sample = self.get_frame(int(start_frame + i))
- sample = audioop.mul(sample, self.sample_width, volume_change)
-
- output.append(sample)
-
- # original data after the crossfade portion, at the new volume
- after_fade = self[end:]._data
- if to_gain != 0:
- after_fade = audioop.mul(after_fade, self.sample_width, db_to_float(to_gain))
- output.append(after_fade)
-
- return self._spawn(data=output)
-
- def fade_out(self, duration):
- return self.fade(to_gain=-120, duration=duration, end=float("inf"))
-
- def fade_in(self, duration):
- return self.fade(from_gain=-120, duration=duration, start=0)
-
- def reverse(self):
- return self._spawn(data=audioop.reverse(self._data, self.sample_width))
-
- def _repr_html_(self):
- src = """
-
- """
- fh = self.export()
- data = base64.b64encode(fh.read()).decode("ascii")
- return src.format(base64=data)
diff --git a/src/tagstudio/previews/vendored/pydub/utils.py b/src/tagstudio/previews/vendored/pydub/utils.py
deleted file mode 100644
index e346cd0ae7..0000000000
--- a/src/tagstudio/previews/vendored/pydub/utils.py
+++ /dev/null
@@ -1,92 +0,0 @@
-# SPDX-FileCopyrightText: (c) 2011 James Robert (jiaaro), http://jiaaro.com
-# SPDX-FileCopyrightText: (c) TagStudio Contributors
-# SPDX-License-Identifier: GPL-3.0-only
-# Vendored from pydub
-
-
-import json
-import re
-import subprocess
-
-from pydub.utils import _fd_or_path_or_tempfile, fsdecode, get_extra_info
-
-from tagstudio.core.utils.ffmpeg_status import FfprobeStatus
-from tagstudio.core.utils.silent_subprocess import silent_popen
-
-
-def _mediainfo_json(filepath, read_ahead_limit=-1):
- """Return json dictionary with media info(codec, duration, size, bitrate...) from filepath."""
- prober = FfprobeStatus.which()
- command_args = [
- "-v",
- "info",
- "-show_format",
- "-show_streams",
- ]
- try:
- command_args += [fsdecode(filepath)]
- stdin_parameter = None
- stdin_data = None
- except TypeError:
- if prober == "ffprobe":
- command_args += ["-read_ahead_limit", str(read_ahead_limit), "cache:pipe:0"]
- else:
- command_args += ["-"]
- stdin_parameter = subprocess.PIPE
- file, close_file = _fd_or_path_or_tempfile(filepath, "rb", tempfile=False)
- file.seek(0)
- stdin_data = file.read()
- if close_file:
- file.close()
-
- command = [prober, "-of", "json"] + command_args
- # PATCHED
- res = silent_popen(
- command, stdin=stdin_parameter, stdout=subprocess.PIPE, stderr=subprocess.PIPE
- )
- output, stderr = res.communicate(input=stdin_data)
- output = output.decode("utf-8", "ignore")
- stderr = stderr.decode("utf-8", "ignore")
-
- try:
- info = json.loads(output)
- except json.decoder.JSONDecodeError:
- # If ffprobe didn't give any information, just return it
- # (for example, because the file doesn't exist)
- return None
- if not info:
- return info
-
- extra_info = get_extra_info(stderr)
-
- audio_streams = [x for x in info["streams"] if x["codec_type"] == "audio"]
- if len(audio_streams) == 0:
- return info
-
- # We just operate on the first audio stream in case there are more
- stream = audio_streams[0]
-
- def set_property(stream, prop, value):
- if prop not in stream or stream[prop] == 0:
- stream[prop] = value
-
- for token in extra_info[stream["index"]]:
- m = re.match(r"([su]([0-9]{1,2})p?) \(([0-9]{1,2}) bit\)$", token)
- m2 = re.match(r"([su]([0-9]{1,2})p?)( \(default\))?$", token)
- if m:
- set_property(stream, "sample_fmt", m.group(1))
- set_property(stream, "bits_per_sample", int(m.group(2)))
- set_property(stream, "bits_per_raw_sample", int(m.group(3)))
- elif m2:
- set_property(stream, "sample_fmt", m2.group(1))
- set_property(stream, "bits_per_sample", int(m2.group(2)))
- set_property(stream, "bits_per_raw_sample", int(m2.group(2)))
- elif re.match(r"(flt)p?( \(default\))?$", token):
- set_property(stream, "sample_fmt", token)
- set_property(stream, "bits_per_sample", 32)
- set_property(stream, "bits_per_raw_sample", 32)
- elif re.match(r"(dbl)p?( \(default\))?$", token):
- set_property(stream, "sample_fmt", token)
- set_property(stream, "bits_per_sample", 64)
- set_property(stream, "bits_per_raw_sample", 64)
- return info
From ee9338837af3f6ac616e88db2400704e6812aac8 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sun, 11 Oct 2026 10:24:59 -0700
Subject: [PATCH 07/10] perf(ui): fix GIL forking ffmpeg command on macOS
---
src/tagstudio/previews/renderers/audio.py | 4 +++-
src/tagstudio/previews/vendored/probe.py | 2 +-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/tagstudio/previews/renderers/audio.py b/src/tagstudio/previews/renderers/audio.py
index a84a45eca0..a243e047b1 100644
--- a/src/tagstudio/previews/renderers/audio.py
+++ b/src/tagstudio/previews/renderers/audio.py
@@ -125,7 +125,9 @@ def audio_waveform_thumb(filepath: Path, theme: Theme, size: tuple[int, int]) ->
bar_count: int = 32
command = [unwrap(FfmpegStatus.which()), "-nostdin", "-v", "error", "-i", str(filepath)]
command += ["-vn", "-ac", "1", "-ar", str(_WAVEFORM_SAMPLE_RATE), "-f", "s16le", "-"]
- ffmpeg = silent_run(command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+ ffmpeg = silent_run(
+ command, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, close_fds=False
+ )
ffmpeg.check_returncode()
data = np.frombuffer(buffer=ffmpeg.stdout, dtype="
Date: Sun, 11 Oct 2026 10:34:39 -0700
Subject: [PATCH 08/10] fix(ui): scroll to top of page when redoing same query
---
src/tagstudio/qt/qt_driver.py | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/tagstudio/qt/qt_driver.py b/src/tagstudio/qt/qt_driver.py
index 38b5f6fbaa..34b81c51f8 100644
--- a/src/tagstudio/qt/qt_driver.py
+++ b/src/tagstudio/qt/qt_driver.py
@@ -1498,9 +1498,10 @@ def update_browsing_state(self, state: BrowsingState | None = None) -> None:
# update page content
self.frame_content = results.ids
- if current is previous:
+ same_page = current.query == previous.query and current.page_index == previous.page_index
+ if state is None:
entry_id = current.first_visible_entry_id
- elif current.query == previous.query and current.page_index == previous.page_index:
+ elif same_page and current is not previous:
# Keep the selection in view when the same results are only resorted or filtered
entry_id = self.last_selected
else:
From 550da9999721f69bafb637bc549911759e62ed42 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sun, 11 Oct 2026 10:35:10 -0700
Subject: [PATCH 09/10] perf(ui): add delay to duration label processing
---
src/tagstudio/qt/controllers/thumb_grid.py | 69 ++++++++++---------
.../qt/views/layouts/thumb_grid_layout.py | 40 +++++------
2 files changed, 56 insertions(+), 53 deletions(-)
diff --git a/src/tagstudio/qt/controllers/thumb_grid.py b/src/tagstudio/qt/controllers/thumb_grid.py
index 45e2f1d299..4589c7c04d 100644
--- a/src/tagstudio/qt/controllers/thumb_grid.py
+++ b/src/tagstudio/qt/controllers/thumb_grid.py
@@ -28,6 +28,7 @@
class ThumbGrid(QWidget):
"""A scrollable grid of item thumbnails."""
+ DURATION_DELAY_MS = 200 # Allows for fast scrolling without getting lagged by duration labels
RESIZE_RENDER_DELAY_MS = 200 # Allows for more responsive rapid resizing
visible_changed = Signal(int) # ID of the first visible entry
@@ -40,6 +41,9 @@ def __init__(self, driver: QtDriver, scroll_area: QScrollArea) -> None:
self._driver = driver
self._renderer = QtFileRenderer(driver.lib, driver.settings)
+ self._duration_timer = QTimer(self)
+ self._duration_timer.setInterval(self.DURATION_DELAY_MS)
+ self._duration_timer.setSingleShot(True)
self._durations: dict[Path, float | None] = {}
self._entries: dict[int, Entry] = {}
self._entry_ids: list[int] = []
@@ -68,12 +72,13 @@ def layout(self) -> ThumbGridLayout[ItemThumb]:
def _connect_callbacks(self, scroll_area: QScrollArea) -> None:
scroll_area.verticalScrollBar().valueChanged.connect(lambda _: self.layout().update())
- self.layout().items_bound.connect(self._on_items_bound)
+ self.layout().items_assigned.connect(self._on_items_assigned)
self.layout().first_visible_changed.connect(
lambda index: self.visible_changed.emit(self._entry_ids[index])
)
self._renderer.updated.connect(self._on_rendered)
self._resize_render_timer.timeout.connect(self._request_renders)
+ self._duration_timer.timeout.connect(self._queue_durations)
self._entries_fetched.connect(self._on_entries_fetched)
self._duration_loaded.connect(self._on_duration_loaded)
self._driver.entry_state.tags_changed.connect(self._on_tags_changed)
@@ -119,7 +124,7 @@ def set_filenames_visible(self, visible: bool) -> None:
self.layout().activate()
def update_selected(self) -> None:
- for _, thumb in self.layout().bound_items():
+ for _, thumb in self.layout().assigned_items():
thumb.set_selected(thumb.item_id in self._driver._selected) # pyright: ignore[reportPrivateUsage]
def _create_thumb(self) -> ItemThumb:
@@ -135,11 +140,11 @@ def _create_thumb(self) -> ItemThumb:
def _path(self, entry: Entry) -> Path:
return unwrap(self._driver.lib.library_dir) / entry.path
- def _thumb_for(self, entry_id: int) -> ItemThumb | None:
- index = self._entry_indices.get(entry_id)
+ def _thumb_for(self, entry_id: int | None) -> ItemThumb | None:
+ index = None if entry_id is None else self._entry_indices.get(entry_id)
return None if index is None else self.layout().widget_at(index)
- def _on_items_bound(self, items: list[tuple[int, ItemThumb]]) -> None:
+ def _on_items_assigned(self, items: list[tuple[int, ItemThumb]]) -> None:
missing: list[int] = []
for index, thumb in items:
entry_id = self._entry_ids[index]
@@ -147,7 +152,7 @@ def _on_items_bound(self, items: list[tuple[int, ItemThumb]]) -> None:
thumb.set_selected(entry_id in self._driver._selected) # pyright: ignore[reportPrivateUsage]
entry = self._entries.get(entry_id)
if entry is not None:
- self._bind_entry(thumb, entry)
+ self._assign_entry(thumb, entry)
continue
thumb.set_filename_text(Path())
@@ -163,7 +168,7 @@ def _on_items_bound(self, items: list[tuple[int, ItemThumb]]) -> None:
self._trim_render_queue()
self._request_renders()
- def _bind_entry(self, thumb: ItemThumb, entry: Entry) -> None:
+ def _assign_entry(self, thumb: ItemThumb, entry: Entry) -> None:
path = self._path(entry)
thumb.set_item(entry)
thumb.set_filename_text(path)
@@ -189,7 +194,7 @@ def _assign_badges(self, thumb: ItemThumb, entry_id: int) -> None:
def _on_tags_changed(self, entry_ids: set[int]) -> None:
self._changed_while_fetching.update(self._fetching.intersection(entry_ids))
- for _, thumb in self.layout().bound_items():
+ for _, thumb in self.layout().assigned_items():
if thumb.item_id in entry_ids and thumb.item_id in self._entries:
self._assign_badges(thumb, thumb.item_id)
@@ -229,7 +234,7 @@ def _on_entries_fetched(
self._entries[entry.id] = entry
self._entry_paths[self._path(entry)] = entry.id
if (thumb := self._thumb_for(entry.id)) is not None:
- self._bind_entry(thumb, entry)
+ self._assign_entry(thumb, entry)
self._request_renders()
def _reset_renders(self) -> None:
@@ -262,13 +267,10 @@ def _request_renders(self) -> None:
if self._resize_render_timer.isActive():
return
- first_visible = self.layout().first_visible_index() or 0
- bound = sorted(self.layout().bound_items(), key=lambda item: item[0] < first_visible)
- entries = [entry for _, thumb in bound if (entry := self._entries.get(thumb.item_id))]
size = self._driver.main_window.thumb_size
ratio = self._driver.main_window.devicePixelRatio()
timestamp = time.time()
- for entry in entries:
+ for entry in self._assigned_entries():
path = self._path(entry)
if path in self._renders:
continue
@@ -288,15 +290,18 @@ def _request_renders(self) -> None:
),
)
)
+ self._duration_timer.start()
- # Queued after thumbnails so they don't hold up rendering
- for entry in entries:
- self._queue_duration(self._path(entry))
+ def _assigned_entries(self) -> list[Entry]:
+ """Entries of the assigned thumbnails, starting at the first visible one."""
+ first_visible = self.layout().first_visible_index() or 0
+ assigned = sorted(self.layout().assigned_items(), key=lambda item: item[0] < first_visible)
+ return [entry for _, thumb in assigned if (entry := self._entries.get(thumb.item_id))]
def _trim_render_queue(self) -> None:
"""Trim queued renders once they get too backed up."""
queue = self._driver.thumb_job_queue.queue
- if len(queue) <= 2 * len(self.layout().bound_items()):
+ if len(queue) <= 2 * len(self.layout().assigned_items()):
return
queue.clear()
@@ -311,7 +316,7 @@ def _on_rendered(self, timestamp: float, image: QPixmap, _size: QSize, file_path
if file_path == Path():
self._loading_image = image
- for _, thumb in self.layout().bound_items():
+ for _, thumb in self.layout().assigned_items():
entry = self._entries.get(thumb.item_id)
if entry is None or thumb.rendered_path != self._path(entry):
thumb.set_image(image, Path())
@@ -319,20 +324,20 @@ def _on_rendered(self, timestamp: float, image: QPixmap, _size: QSize, file_path
self._renders[file_path] = image
self._previous_size_renders.pop(file_path, None)
- entry_id = self._entry_paths.get(file_path)
- thumb = None if entry_id is None else self._thumb_for(entry_id)
- if thumb is not None:
+ if (thumb := self._thumb_for(self._entry_paths.get(file_path))) is not None:
thumb.set_image(image, file_path)
- def _queue_duration(self, file_path: Path) -> None:
- if (
- not can_have_duration(file_path)
- or file_path in self._durations
- or file_path in self._pending_durations
- ):
- return
- self._pending_durations.add(file_path)
- self._driver.thumb_job_queue.put((self._load_duration, (file_path,)))
+ def _queue_durations(self) -> None:
+ for entry in self._assigned_entries():
+ path = self._path(entry)
+ if (
+ not can_have_duration(path)
+ or path in self._durations
+ or path in self._pending_durations
+ ):
+ continue
+ self._pending_durations.add(path)
+ self._driver.thumb_job_queue.put((self._load_duration, (path,)))
def _load_duration(self, file_path: Path) -> None:
self._duration_loaded.emit(file_path, get_duration(file_path))
@@ -340,7 +345,5 @@ def _load_duration(self, file_path: Path) -> None:
def _on_duration_loaded(self, file_path: Path, duration: float | None) -> None:
self._pending_durations.discard(file_path)
self._durations[file_path] = duration
- entry_id = self._entry_paths.get(file_path)
- thumb = None if entry_id is None else self._thumb_for(entry_id)
- if thumb is not None:
+ if (thumb := self._thumb_for(self._entry_paths.get(file_path))) is not None:
thumb.set_duration(duration)
diff --git a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
index 14f42f6b90..076ea7e702 100644
--- a/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
+++ b/src/tagstudio/qt/views/layouts/thumb_grid_layout.py
@@ -19,14 +19,14 @@ class ThumbGridLayout[W: QWidget](QLayout):
_BUFFER_ROWS = 3
first_visible_changed = Signal(int) # Index of the first visible item
- items_bound = Signal(object) # list[tuple[int, W]] of newly bound (index, widget) pairs
+ items_assigned = Signal(object) # list[tuple[int, W]] of newly assigned (index, widget) pairs
def __init__(self, scroll_area: QScrollArea, create_widget: Callable[[], W]) -> None:
super().__init__(None)
self.setContentsMargins(0, 0, 0, 0)
self.setSpacing(PAD)
- self._bound: dict[int, W] = {}
+ self._assigned: dict[int, W] = {}
self._create_widget = create_widget
self._first_visible: int | None = None
self._free: list[W] = []
@@ -38,10 +38,10 @@ def __init__(self, scroll_area: QScrollArea, create_widget: Callable[[], W]) ->
self._scroll_to: int | None = None
def set_item_count(self, count: int) -> None:
- """Replace all items with `count` new ones, unbinding every widget."""
+ """Replace all items with `count` new ones, unassigning every widget."""
self._item_count = count
- self._free.extend(self._bound.values())
- self._bound.clear()
+ self._free.extend(self._assigned.values())
+ self._assigned.clear()
self._first_visible = None
self._last_layout = None
self.update()
@@ -54,13 +54,13 @@ def first_visible_index(self) -> int | None:
return self._first_visible
def widget_at(self, index: int) -> W | None:
- return self._bound.get(index)
+ return self._assigned.get(index)
- def bound_items(self) -> list[tuple[int, W]]:
- return sorted(self._bound.items(), key=lambda item: item[0])
+ def assigned_items(self) -> list[tuple[int, W]]:
+ return sorted(self._assigned.items(), key=lambda item: item[0])
def widgets(self) -> list[W]:
- return [*self._bound.values(), *self._free]
+ return [*self._assigned.values(), *self._free]
def _new_widget(self) -> W:
widget = self._create_widget()
@@ -84,8 +84,8 @@ def _grid_metrics(self, width: int) -> tuple[int, QSize]:
def setGeometry(self, arg__1: QRect) -> None:
super().setGeometry(arg__1)
if self._item_count == 0:
- self._free.extend(self._bound.values())
- self._bound.clear()
+ self._free.extend(self._assigned.values())
+ self._assigned.clear()
for widget in self._free:
widget.setGeometry(32_000, 32_000, 0, 0)
return
@@ -118,24 +118,24 @@ def setGeometry(self, arg__1: QRect) -> None:
return
self._last_layout = (start, end, per_row, item_size)
- for index in [index for index in self._bound if not start <= index < end]:
- self._free.append(self._bound.pop(index))
+ for index in [index for index in self._assigned if not start <= index < end]:
+ self._free.append(self._assigned.pop(index))
- newly_bound: list[tuple[int, W]] = []
+ newly_assigned: list[tuple[int, W]] = []
for index in range(start, end):
- widget = self._bound.get(index)
+ widget = self._assigned.get(index)
if widget is None:
widget = self._free.pop() if self._free else self._new_widget()
- self._bound[index] = widget
- newly_bound.append((index, widget))
+ self._assigned[index] = widget
+ newly_assigned.append((index, widget))
row, column = divmod(index, per_row)
widget.setGeometry(QRect(QPoint(column * cell.width(), row * cell.height()), item_size))
for widget in self._free:
widget.setGeometry(32_000, 32_000, 0, 0)
- if newly_bound:
- self.items_bound.emit(newly_bound)
+ if newly_assigned:
+ self.items_assigned.emit(newly_assigned)
@override
def addItem(self, arg__1: QLayoutItem) -> None:
@@ -158,7 +158,7 @@ def takeAt(self, index: int) -> QLayoutItem:
item = self._items.pop(index)
widget = item.widget()
self._free = [w for w in self._free if w is not widget]
- self._bound = {i: w for i, w in self._bound.items() if w is not widget}
+ self._assigned = {i: w for i, w in self._assigned.items() if w is not widget}
return item
@override
From c234a098e545a684e3e498d8e674d520ca30a1b9 Mon Sep 17 00:00:00 2001
From: Travis Abendshien <46939827+CyanVoxel@users.noreply.github.com>
Date: Sun, 11 Oct 2026 13:12:15 -0700
Subject: [PATCH 10/10] fix(ui): fix clicking badges causing scroll jumps
---
src/tagstudio/qt/controllers/item_thumb.py | 2 +-
src/tagstudio/qt/views/item_thumb_view.py | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/src/tagstudio/qt/controllers/item_thumb.py b/src/tagstudio/qt/controllers/item_thumb.py
index 2fb5a76eac..363db35a0a 100644
--- a/src/tagstudio/qt/controllers/item_thumb.py
+++ b/src/tagstudio/qt/controllers/item_thumb.py
@@ -80,7 +80,7 @@ def __init__(
self.rendered_path: Path | None = None
self.setMouseTracking(True)
- self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
+ self.setFocusPolicy(Qt.FocusPolicy.ClickFocus)
self.setFixedSize(*thumb_size)
self.setLayout(ItemThumbView(driver.entry_state.badge_tag_ids))
self._connect_callbacks()
diff --git a/src/tagstudio/qt/views/item_thumb_view.py b/src/tagstudio/qt/views/item_thumb_view.py
index a18103b858..3031fc6f0e 100644
--- a/src/tagstudio/qt/views/item_thumb_view.py
+++ b/src/tagstudio/qt/views/item_thumb_view.py
@@ -154,6 +154,7 @@ def __init__(self, badge_tag_ids: Iterable[int]) -> None:
)
badge.setMinimumSize(_ICON_SIZE, _ICON_SIZE)
badge.setMaximumSize(_ICON_SIZE, _ICON_SIZE)
+ badge.setFocusPolicy(Qt.FocusPolicy.NoFocus)
badge.setHidden(True)
self.badges[tag_id] = badge
badge_layout.addWidget(badge)