From 811775343ff1a60ed25d0846aec64e173ab4828f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 9 Jun 2024 10:28:20 +0300 Subject: [PATCH 01/73] Fix aliasviewer's text input mode --- src/client/aliasviewer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/client/aliasviewer.py b/src/client/aliasviewer.py index 6996511ce..bcc541d78 100644 --- a/src/client/aliasviewer.py +++ b/src/client/aliasviewer.py @@ -169,9 +169,9 @@ def search_alias(self, name): self._alias_window.view_aliases(name) self._search_window = None - def run(self): + def run(self) -> None: self._search_window = QtWidgets.QInputDialog(self._parent_widget) - self._search_window.setInputMode(QtWidgets.QInputDialog.TextInput) + self._search_window.setInputMode(QtWidgets.QInputDialog.InputMode.TextInput) self._search_window.textValueSelected.connect(self.search_alias) self._search_window.setLabelText("User name / alias:") self._search_window.setWindowTitle("Alias search") From 30f5f80ab439e2955daf75ddda6bc1c929eb5a33 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 10 Jun 2024 22:04:30 +0300 Subject: [PATCH 02/73] Set line length for isort --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 7023589a4..11d3b7462 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,6 @@ [isort] force_single_line = True +line_length = 100 [flake8] max_line_length = 100 From 55c1ae3a78cbcfdd652db83923a7c82444231583 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 11 Jun 2024 22:47:48 +0300 Subject: [PATCH 03/73] Fix restoring maximized window (again) --- src/client/_clientwindow.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index ebbd2f6f0..505bb9b4d 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -1454,6 +1454,7 @@ def load_settings(self): maximized = util.settings.value("maximized", defaultValue=False, type=bool) util.settings.endGroup() if maximized: + self.is_window_maximized = True self.setGeometry(self.screen().availableGeometry()) elif geometry: self.restoreGeometry(geometry) From 1dd7441f9bf5b4c85d3d0fa6b3b2a77933992bee Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 12 Jun 2024 21:32:32 +0300 Subject: [PATCH 04/73] Add basic player card which in many ways mocks java client's one --- res/client/client.css | 14 ++ res/player_card/playercard.ui | 101 ++++++++ res/player_card/playerleague.ui | 115 ++++++++++ res/player_card/unknown.png | Bin 0 -> 1106 bytes res/player_card/unlisted.png | Bin 0 -> 9564 bytes src/api/models/GamePlayerStats.py | 14 ++ src/api/models/Leaderboard.py | 8 + src/api/models/LeaderboardRating.py | 30 +++ src/api/models/LeaderboardRatingJournal.py | 16 ++ src/api/models/LeagueDivision.py | 20 ++ src/api/models/LeagueLeaderboard.py | 6 + src/api/models/LeagueSeason.py | 22 ++ src/api/models/LeagueSeasonScore.py | 29 +++ src/api/models/LeagueSubdivision.py | 25 ++ src/api/parsers/LeaderboardParser.py | 12 + .../parsers/LeaderboardRatingJournalParser.py | 12 + src/api/parsers/LeaderboardRatingParser.py | 12 + src/api/stats_api.py | 103 ++++++++- src/chat/chatter_menu.py | 11 +- src/client/leagueformatter.py | 81 +++++++ src/client/playerinfodialog.py | 215 ++++++++++++++++++ src/stats/_statswidget.py | 7 +- src/stats/itemviews/leaderboardtablemenu.py | 10 + src/stats/leaderboard_widget.py | 11 +- 24 files changed, 858 insertions(+), 16 deletions(-) create mode 100644 res/player_card/playercard.ui create mode 100644 res/player_card/playerleague.ui create mode 100644 res/player_card/unknown.png create mode 100644 res/player_card/unlisted.png create mode 100644 src/api/models/GamePlayerStats.py create mode 100644 src/api/models/Leaderboard.py create mode 100644 src/api/models/LeaderboardRating.py create mode 100644 src/api/models/LeaderboardRatingJournal.py create mode 100644 src/api/models/LeagueDivision.py create mode 100644 src/api/models/LeagueLeaderboard.py create mode 100644 src/api/models/LeagueSeason.py create mode 100644 src/api/models/LeagueSeasonScore.py create mode 100644 src/api/models/LeagueSubdivision.py create mode 100644 src/api/parsers/LeaderboardParser.py create mode 100644 src/api/parsers/LeaderboardRatingJournalParser.py create mode 100644 src/api/parsers/LeaderboardRatingParser.py create mode 100644 src/client/leagueformatter.py create mode 100644 src/client/playerinfodialog.py diff --git a/res/client/client.css b/res/client/client.css index 8bbc21fb4..d29b8cc3f 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -79,6 +79,20 @@ QMenuBar::item height: 13px; } +QTabWidget +{ + background-color:#383838; + color: silver; + min-width: 80px; + padding:4px; +} + +QTabWidget::pane +{ + border: none; + background-color: #2f2f2f; +} + QTabWidget#matchmakerQueues::pane { background-color: none; diff --git a/res/player_card/playercard.ui b/res/player_card/playercard.ui new file mode 100644 index 000000000..d506d474c --- /dev/null +++ b/res/player_card/playercard.ui @@ -0,0 +1,101 @@ + + + Dialog + + + + 0 + 0 + 843 + 673 + + + + Dialog + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + 0 + + + + General + + + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + + + + + + + + + Qt::Horizontal + + + + + + + + + Rating Type + + + + + + + + + + + + + + + + + + + + + + + + + + + PlotWidget + QGraphicsView +
pyqtgraph
+
+
+ + +
diff --git a/res/player_card/playerleague.ui b/res/player_card/playerleague.ui new file mode 100644 index 000000000..30c1645d9 --- /dev/null +++ b/res/player_card/playerleague.ui @@ -0,0 +1,115 @@ + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + + + + + 0 + 0 + + + + + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 16777215 + 40 + + + + + 13 + + + + + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 16777215 + 40 + + + + + 13 + + + + + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 16777215 + 20 + + + + + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + 16777215 + 20 + + + + + + + Qt::AlignmentFlag::AlignCenter + + + + + + + + + + diff --git a/res/player_card/unknown.png b/res/player_card/unknown.png new file mode 100644 index 0000000000000000000000000000000000000000..f1bf4a8245587ad3f4f2ee3f2efa1fb408dd0186 GIT binary patch literal 1106 zcmV-Y1g-mtP)hH*3So5En&o?#+f71LHXMgM_Z1{_crjCwO><$T~1g~2#ILpII@b}9_U?WVKw ziozhrZ)O&rQ5fVJz>Lkqz>EUHMIg(8QwoEe!~2m@;fJ3W-ll$v>azX|CdH4qPlqYcn;;K3H(3**djb%FF<{?Hf!B!y(s$u#XHAnl1)S63_4=Obsd){g&%)87!s=m)5Y&^v9}d3!u>x7? zaBalq=rle`opEu?YC803&h<{h`@TZL zxWA@Awl?^_xMyDn<3J}n=3LtveBa=}+um3bOjRJ;8+_mHz+2G!Eputr1@a59XMnzT z;B8i$rzsFCL}4OUf#64Bsp2s`A-K{%z=~<;vd#b>0WSkD0lOXU-JR8mxC~xxAU`$i zPz=M~!w&on(y_Q6N|I#aNsj*;b@L^Jf0zJVnIRwaM!tTH0Ros@V?Q3zhMf;wKW7{z`MX*amxK7 zV5#H7ohcoyB@nAb3Iso@s>`wpw7yFi3WRwHBh*Tr>HE&H%Jtr`D~f#^wu)8V)QJ_W z?`xb~v_ch*K`a?13um Y|ICuPL_C5${{R3007*qoM6N<$f^r1>a{vGU literal 0 HcmV?d00001 diff --git a/res/player_card/unlisted.png b/res/player_card/unlisted.png new file mode 100644 index 0000000000000000000000000000000000000000..42879767d3380d8a754af4f026654fb31f8b57d1 GIT binary patch literal 9564 zcmdUVc{tSX_xC+BX2v?SAj?=XNVdv0O@wSS$&w{fF{Kj1#E1|xsZ{o%O}3a8RHmXu z;;lV}kBLZO%14$gqmm`db5DJL&viZj{(jf+fAKHWA^iAt5Jze9dui zY}Fq9GjHLOlDs|Q7_T|F=}<`J&&kzsVUt}MRf9GDVapp1s~t#wEau=YX8*+U73tK< zz%^NHJ)5N(50ggD9Cv@Zp|JNuP?B|MyQEin&lFqv*N?HI%{`cZBPcv8p$EO<^8IwS zOiIstF&&wCJsRJ)5T_tJ{}A|!TZ(6j;@) z4$Mnb3J87nBAZlZ_=c^|8b?{LG}LR`+%1Vn3w9yd+~{~3C-VLUQ!Sy4piy9u_kf7XYMNT2nylYU?mTOdemn_jJ2P5M7xDkb)a~x88 zLqGDHW$iWpELq;VcdtO7`%y=APMdb+5^5(t-}QOS!}JQ_LjopILX|rC+>XPoQ3i+6 zJ-D~{@bY2W@33_Y9R?HIqD}J{Yew}D42B1Cem(*t=zQ$K?tuX)K5Y!!v#G|h*m$;D zYz;^N(`(mMyR~T2JUm@+8M^83&ht|?U}x82c;>+!r4~Ow-`1wBZfao#vaiL>6hAfs zDp}?hop)bm>eD=4-&z@V?tZb4Z08&F8X2;BthcPXCx@h+L@LOP3NTi?;e(}b5TNOA z)T|0gw7ARhRBgepaP{koSV2EmYI$v^8;84aNp{5Lyms+N^qK-DuOYQ2L6yd7tNBgq z_}Kk%2*bRq)jNwEAx}GVysDdMfx=u@_2PGEo`O)SRsg53s?<2kil)r`>ne$W;AHiP z$sCg2`*37xkWQ=5DFD`Dwe`s@IV9TSsYt;RLF3_#W!*1$`oeg6!lfcz8fSJOn6553 zv(5T)0h)(mSn<5$FOp~;*fFfXUFtRQdvP)d5bhEkKzW5B+hoVQV4D~%rwWHk>ewfdTX z4QQWtA4ZRHNO~_!5F0%sY{z=Zm4 zkwYp|I*h;NH%WZj1Zp4U89>6yald(DMGLrL*JXRaL;DaEP+lSD@XuYf-$i8A6Up@? znv019IiWq~hCk@>+oeF`cwHVypQtcGZNZN<6fBZxAtn7>t>l3xKY~EU3z6hkmBliA zCj+*1RnCeQ*_5RI&Bq9phEV|DQl5#_F^m(%gF;S$JD5{=Wy zQ@8X1ztZ1m(m1P4r3xS6=qTRqouXy4v4|rE`HCEE*bU{3Hdhh5m+2}RRz0sFr00K7km<`w{_4I`os6^44GtmOkoR^Gp_Aj* z4j%G2mVB!wsrsMEGq3iIL@r-#6do^fUcL8qj{x0#r@)0Z1;+7L#e%YxU!yf9dn^UZ1zYmsZ3DdDV|yeL zE_g|jCQrdR1t$O~F_I!Q-&Cfr z0Lr5DS?XC=uMJ>>^qNcsi$E9j?J1=mxdW&L7cmK}Du>E1Bb)M$e9)qt%2mn4oa_3r zim{YuL0E`p$nbO-mb_Ku)Li)>z zEh&gX*oa-j_O^H_b_eJ1LFRkUC)z-QuRI}v$d)EwUmAg~!*59=taP{ot}|0vTI^m2 z{MIY`fi?cBBR;#t7kJ&-KzsKj#vZ@*)F|r*>t<;(b%_C3-5!RZ5KOD>@{nZ$3*;AE zu>8o9;>}E}VB;iwl9l)fW~V&SzB~_WbLApt#Hp*t2OYbDeBtL~P5GzV;Ot()kmCHZ zR$DLU9F`PSQG9cH5XxO|!6vSMk-?N}@7gQa#2;gOlx_!J$1btAbDQYg+gq?kDE}q- zcEkEoth_N-y3QJ7%Bef+(yR9VYj`Zg01^)_3AUPYYqib2GRk_xmMKZ5&I@*dkn(*< zD|xyi>ORIvaOg?MDmd~0R%p(Do#7SYwKha)>4kQh5BQP zuRfF`i^ZB#PATupVum9PaGU|+Ff{od@qny zWS^2H8rz(RQCx-wd2u0DX`8=cYb4aeLYVmmG|dm2z&yUfGb_apD3teh#^ju041!Nj@NjaEKJ=9qA8d1F0z zN&1KF1!;qFwmKjM^)lNiQB^%RP>HcoXeCh0eH641&w5v4M;*NbRymzV#e4Qljg27_ z-0Fdv0{8pkWT|PR;Id*;GS?FmlS)`w{S;mN$v{`;c!xc{?4KxBEOHD#tVxi6H=M! zcAmH-R!7!3Y;*Akm4Ua|y5+=8A!kiMtYsZCP`B_ z@Shxh)Mtc%Ads7ybnRmD;$D9Hk6vz?ZJ?cCi=e)&w?O)_a0Md-Dq6l7tiu1GRxT8! z5%_@{a&U`^PI!q(W8r?mnL!P!djy6qkH(Y>HWiJ#_Ro!mBrvTp)HC6MPhuIeomn}@ zqJDNQBTE%alQG3L@fS?p4KfIPtv;&HbiF=>+LUmq>QSFbt8LugU3`hNIq-IB9m3f} z$55J-=5&Gei-sg}gl@s`gKf+#wIlhF_sh5GKur}`x#X>^x28~Kx2!DikEJuS@*ati zkL_1oXG)ybKNMs}Esca+el5#eJMD%`Oi51C|AkF-t|>5ziDDNyrerzpflQ8CQEX(> zbX~&6R}e{B8&KAG9Y_2SJHj5H<@jYOYj{|J{(Devm$Fmdb25cOz1ob*5Gzb4@UQG( zC!Hc_-fUI^?cQUG?lM{a^XsQ$#u$9!vWLI7F+ni78C=gOT)E3;lEKU}N{}1Jj-Sn8 zRobTvKJnsL8!Ur2yFw%&)98ro!XtE1Dxp;)Kz0+YTLvQ;-9G@X0JU zIw@j)xWJ6I=oYn6UvpqQ*eur>lwW#Di@$CyPvl}S$OlBFG}*N6A)@l?N6h;!#Eu(< z=K3+<0#Qas2n$&mKcj`?MNg?4a9;;$g7c8SH{@?|w-m!Xuv$1XD2FAVNH~xFw47Qk z(u)DcD2>p1*^?U7T~QxtGx zR$Q)?agUzR-~}^J5q4rmREZ_-9XSiV13xz$Wc_?xx0NXeBS!o4{6RpBH6$!#A12jn zo%0bygK`Ne*6_-gXkqIh_>$9#qGa_=)hNG07uP(nq}W_KjNk8=B4@~ zTqm5%dLI=PnZN||yTSufL)lj#(ttb!3Va=#hbv@j^(e;|#+v?(ZX5FuWd7&f)j<$O zQH`0e$k9VCw`z9few?ZmEkb#)9xjrRN+?#04P~;-`+r$g;$ZwCM9%IbOOVNwffI~bwb``~5Me8KY3@b6uX?P&La^+&_DfhqeG_YVf(%;6Zg;EhcDV%vviZsk^^u``6?JKN#t zF(_;&afKprla{iR|0A>?SPoY{Fy(|l@bqvi%Jgb}o#XnW;}sA9o-wzE;zTO%+&p;) z&hnKb+U&avR&_mrKVAhh^t@dIO7rWc+HIhGZ-%_xU6Y;5B4?HRe?As%b5&Q#ATZ1m z+mcGSyw*jxh=?jcxx<)LVx>SVc39u-zwHj{`;1@z_s5kd;b=djfs#rUqK)CD<)YU_ z_DvBFKcjjvLooBIKlH5gjJF-!fZ=&`)|-mW+X43+-29(k#VdV{DK@DFKoSOss-5@V zR|9Iua=23ey6SM$D<>nL4!2W(w;$FAWop#E_FWJ3m13?R6iL2s;9eMf-Y4sTKhWye zit;jtQ^VAu6qQhE=PXfg(+RC5G9`b9f3%?3NSWjOp*0&m#P}{DmNXD*_lC8iy6AuR>mrtMhOqWzplFS#5=_-+hreqK3y7^K$J=JSB&K-vh)Bb+hnV1O z6SRX1wnrd7JnFM}GFvK;QUgkl- zOZOWl2o;JS=#(d$k(0ZA=zmw5(<~ycX{STRP0dIWDpbsB(HvPH>+T$0rv&Q_kDTyX z2B5sOBYQLQ>`c*=Jn|gTk+-g_iLwxW8bQ-Q z%@_ZTp0tKOUK!rD=3lsQKp5$ZO2cn-S*@bh%qh}+6)~HnPk%Qk$ANytBHi@yZ>_k+ z;W-U~qaa`+NYf_>{D7Md&Kv|wr=8tqVx@$N{EEjuhbPFP=m?Ebr6d$@*Hr8By-LC> zgK|X1Y(TYOBWP?_XBA--Q5|TvjPJKEp-pJt=A6(flU4=g42p0J4t#KNZ1asa2p7y6 zBn3T}??2~8f1XU~xz|uVuj40OyZnM8k?XRLfrOowlC}C2dqqMjLE~m4T0>7!edW!?fdf{8Q-gB+a+rukGGM8qe&^#8(}B0H z3``gST6By$55rqUp(@_O9X<_XOrsJc{xW4q!$9$|Pf)200e$)19F~5G9diiU8Ta!8 zF^#fY3mAyBVW@JeVH^s9;W)Y(4l9+SRW3xuND~&y^@0GYy(t~3L$n&!*O-A%$8{IW zu0q`HrJIB!Xf8IW^IurQafVRtng_DvEK9c;mFARE47C*E@bctG)OQP0VgrL`AG56> z5)={7999WN=0agyo~6Q&XW%ckN{?^G_57{?gXrZB(2<>3zqz_!n#El;fd2jNMuWOQ ziaf0klWLmx4pjao3UPcwVDR|xDu%lVXBeTV<@`N@Fz&ngy#1;|eC?#$MPDrni@Rs1 zj4FOk4`QHvR&xEiFOXqkJZ^fa@DP6}(Zy8a0p@}N(O!gkII}~Jf&y;E{H2k-s&i+R z;GQR+K1HsvDp4)CprQL=G>4!)FyA0Tf?;!$=ege;?z?bURyO@NO2n@E? zorLni46-dFg}S4=(6+1Xn%AP3sv2?A@JVOD@tZIeM)RK^&BO(rzK3OC{^U?9P!{Dk z#;lxG-@epk-$2c+z$clpur_?^*X&!7i$|DQ$}_6GASj38PIQ)Oei3^7a$5Y%j_6fF zXUkz98JNsgmCtbP!kW_w9wwJ^-p^ly=sWJYHlXS6!l=r8QXz8pZ@cw-!*s<~96ZkQ z6XTfQ0)cgYB`^^|dMIw=FKNa~hN{quagH*pA?$%@)H+s%<#kJ!9Z0=DGjzh=sRpxW zCsdre0r0q}rZc)XW;;Y&aWb$NRWPXGQSS@5-z|Zv_}p3w)g_Fu6MGPz$gplDl(_;44CzX4MJ|!q`M5jEdZ^zkMwK2_p5eKOi^Y((ScUHX--av;F6PM=ih-Y4YMX zh^=6P=MmLyllT5`O1+|Ak*WTj7^a-w8LUmnRc%8M7d-X&L*TVAp3pw{D;K4U9?Mc2baTK5F^EEGmym?0WK(ZhbQ9fhs;J*b7?ROne?Q{$qeByHO`&$!lTOjyVswb4R7{ zi*_s%_7Kdxc9Jm~{b3S3Lq-5IOXC|V;F-_7$47XnzyBgO66a(!ni3yW6t$tCFbOxo zJ$yJck=soy<1E+ps^ZNn_q|a9dI5@C-wUkOVr7L9+_EIvE?CK!nGIpGB#F4W0Ks#( z%2kGhP2;<^HsrTx$aI(LeZb8%{6JlspY(DCs93)?iK~ax9%&o;Qwt%~bgy)|?qpIDx zf>J`6`wfRWwY6#2x<%u@@PSM-Q)gXLMYaaqRe|`-)Us(EI zZ_bGp{uh?oUcl@7J?jk-WVtGx^@@THj3WuFW27<&-Ts@8da~<@yd2MMWxh}+vY`+= zJ*BjwNa;;bjNRQ(j8}RUUa(k^n_^h{7xR$P!vMrfoA?*bVRBg4N|Mudhtb#i<2Bj~ zP2e_g?1B=zD!QWT+!X_OffGg)iN#oGCe)C0sHB%FP{}YLLigBb1`pg{;*&CQkJv|y zg+oeKn=&-r+FEN0vT7p)sUF`%wtZ)LC+>m@aj%&%dJMd9!e{>-wFS7ICag^}{vt5m z_Z%9wwLUAEB~_X%yzQ3Q;YFWq9a)zfY+MsC>0KZ(sbhv7lPss))N0CRvTW zYy^TY%gLsB~o1tE3XTpyt#<~~STIjYY4 zUeZA0?wGl!O&m0l>P)WsR?LMso67Ohfd2M>a`OL8gZY0sEfl8aUS--y5gX6eJ=t@g zz|#{hrSEY!qREStes4Kh_5pZs+;rMl{c|9meI9+6mCWuy_x)Di>Tw)JcYt@o@phqe}()Yeork>sfcmx?ztoxXx zZ}0jda0!a!Vz*4=vQ0f$js6I%P=cR#v>p~=@YD`mf@A=h`T>TI#*cTA4eSV?xmsOt z4b!(e(Vu1|a!l37OsA9d2WNUW`z>7|9+yblCGxHvx~Em27wt*2g}O8DnkDg+WC>CkA8#7bTeGsq;Y zJ9!A%Lxi8CmSHdP`)Jcg?2D+T4N18;cby8gf*alPQHXbK=hKs zMT=-#MA`4;e`t+QU%&~xtxP}*{9$gRC+keT3tAlUO3C+8n=gtk-BWu3%;Ein>;AW2 zQTadoie@XykHLWRZ%!zScFP(NiyKZ-x&z{S~Xd27(6U=wrzwV9EM_7v1aMGg=Oq_$%Q*S z&WmQ6fdovf6B1P02{1X|2aR>|9_8>-vgxq5bG9w3-CYMh71~JW1?=Ip}N>P zhd<3cKNy%vWtPs+P5tf9S*;g0IPLifKNznNsDyHyW%39pR( z@#Te&@a%f~Kdffo0G`qs3kqz;mcPA`$W;_E#|#^ZgX0mlds_ss+2#qigBP!haiMZfwK|#e{cP!><>(b(fk}CtKI@07xkP z+s(aJxsdT$5;+^Ywivc;CqhsDS`27sV1eN7eA7zUC|x&lp>EcJRZ%0|dDnV7R1elD z==kjft`cCiOzEaVjl8<%IGftKAK+N)5jCsKKTCVii?SCYtI&XF1^eQT#(&n1!*wb> zIK-7*5!$<$!YV_;t)#V~oZsH0-F`FV7ssibJAJ{<5HN;EyF~DUYs1&Z=QH zwx!y`ChGV`WJR)dl{tpTPr^7cRh>Ab$&UvGE;keC8?f}Fd@OrM3a!It|qHFBH;6uWc+v8~8ZVdO`aYFcyA8=2Lb5dc2Uq3c4Maqjz zRLFOdTh79+|D>lt{o_2>RT3b62o~`tCe}e7J!g>x?uVIsOg{j0F!dP!?&pEf@|r)P zM`SENz1EHK1R7xkQ+#Lno0srlZ$47Lfcn%igsF;BL>Ov-fc+XXLx!6%`@zNx?h&Sp zNdaV(y9_fv(hvwwD7)LPW?{^&d!xItB;YnA89T0zhFgm6-$vK5S}}Jy1`GI~F~8CE z-B}1TukKI?(4;?^+$NOt%;Vbu_61d^uw=IhlY~V~C=rYiLTpl29DgH_36Lt>!;9_3=6ZJQX_%i>A Wt(czjtMJPxKyz@lzqN^e?0*5CpE{`k literal 0 HcmV?d00001 diff --git a/src/api/models/GamePlayerStats.py b/src/api/models/GamePlayerStats.py new file mode 100644 index 000000000..263e0b933 --- /dev/null +++ b/src/api/models/GamePlayerStats.py @@ -0,0 +1,14 @@ +from api.models.ConfiguredModel import ConfiguredModel +from pydantic import Field + + +class GamePlayerStats(ConfiguredModel): + xd: str = Field(alias="id") + ai: bool + color: int + faction: int + result: str + score: int + score_time: str = Field(alias="scoreTime") + start_spot: int = Field(alias="startSpot") + team: int diff --git a/src/api/models/Leaderboard.py b/src/api/models/Leaderboard.py new file mode 100644 index 000000000..5f539e45b --- /dev/null +++ b/src/api/models/Leaderboard.py @@ -0,0 +1,8 @@ +from api.models.AbstractEntity import AbstractEntity +from pydantic import Field + + +class Leaderboard(AbstractEntity): + description: str = Field(alias="descriptionKey") + name: str = Field(alias="nameKey") + technical_name: str = Field(alias="technicalName") diff --git a/src/api/models/LeaderboardRating.py b/src/api/models/LeaderboardRating.py new file mode 100644 index 000000000..dfb2f6a98 --- /dev/null +++ b/src/api/models/LeaderboardRating.py @@ -0,0 +1,30 @@ +from api.models.AbstractEntity import AbstractEntity +from api.models.Leaderboard import Leaderboard +from api.models.Player import Player +from pydantic import Field +from pydantic import field_validator + + +class LeaderboardRating(AbstractEntity): + deviation: float + mean: float + total_games: int = Field(alias="totalGames") + rating: float + won_games: int = Field(alias="wonGames") + + leaderboard: Leaderboard | None = Field(None) + player: Player | None = Field(None) + + @classmethod + @field_validator("leaderboard", mode="before") + def validate_leaderboard(cls, value: dict) -> Leaderboard | None: + if not value: + return None + return Leaderboard(**value) + + @classmethod + @field_validator("player", mode="before") + def validate_player(cls, value: dict) -> Player | None: + if not value: + return None + return Player(**value) diff --git a/src/api/models/LeaderboardRatingJournal.py b/src/api/models/LeaderboardRatingJournal.py new file mode 100644 index 000000000..372025785 --- /dev/null +++ b/src/api/models/LeaderboardRatingJournal.py @@ -0,0 +1,16 @@ +from api.models.ConfiguredModel import ConfiguredModel +from api.models.GamePlayerStats import GamePlayerStats +from api.models.Leaderboard import Leaderboard +from pydantic import Field + + +class LeaderboardRatingJournal(ConfiguredModel): + create_time: str = Field(alias="createTime") + update_time: str = Field(alias="updateTime") + deviation_after: float = Field(alias="deviationAfter") + deviation_before: float = Field(alias="deviationBefore") + mean_after: float = Field(alias="meanAfter") + mean_before: float = Field(alias="meanBefore") + + player_stats: GamePlayerStats | None = Field(None, alias="gamePlayerStats") + leaderboard: Leaderboard | None = Field(None) diff --git a/src/api/models/LeagueDivision.py b/src/api/models/LeagueDivision.py new file mode 100644 index 000000000..eef5df809 --- /dev/null +++ b/src/api/models/LeagueDivision.py @@ -0,0 +1,20 @@ +from api.models.ConfiguredModel import ConfiguredModel +from api.models.LeagueSeason import LeagueSeason +from pydantic import Field +from pydantic import field_validator + + +class LeagueDivision(ConfiguredModel): + xd: str = Field(alias="id") + description: str = Field(alias="descriptionKey") + index: int = Field(alias="divisionIndex") + name: str = Field(alias="nameKey") + + season: LeagueSeason | None = Field(None, alias="leagueSeason") + + @classmethod + @field_validator("season", mode="before") + def validate_season(cls, value: dict) -> LeagueSeason | None: + if not value: + return None + return LeagueSeason(**value) diff --git a/src/api/models/LeagueLeaderboard.py b/src/api/models/LeagueLeaderboard.py new file mode 100644 index 000000000..57e32812a --- /dev/null +++ b/src/api/models/LeagueLeaderboard.py @@ -0,0 +1,6 @@ +from api.models.ConfiguredModel import ConfiguredModel +from pydantic import Field + + +class LeagueLeaderboard(ConfiguredModel): + technical_name: str = Field(alias="technicalName") diff --git a/src/api/models/LeagueSeason.py b/src/api/models/LeagueSeason.py new file mode 100644 index 000000000..44e000a17 --- /dev/null +++ b/src/api/models/LeagueSeason.py @@ -0,0 +1,22 @@ +from api.models.ConfiguredModel import ConfiguredModel +from api.models.LeagueLeaderboard import LeagueLeaderboard +from pydantic import Field +from pydantic import field_validator + + +class LeagueSeason(ConfiguredModel): + end_date: str = Field(alias="endDate") + name: str = Field(alias="nameKey") + placement_games: int = Field(alias="placementGames") + placement_games_rp: int = Field(alias="placementGamesReturningPlayer") + season_number: int = Field(alias="seasonNumber") + start_date: str = Field(alias="startDate") + + leaderboard: LeagueLeaderboard | None = Field(None) + + @classmethod + @field_validator("leaderboard", mode="before") + def validate_LeagueLeaderboard(cls, value: dict) -> LeagueLeaderboard | None: + if not value: + return None + return LeagueLeaderboard(**value) diff --git a/src/api/models/LeagueSeasonScore.py b/src/api/models/LeagueSeasonScore.py new file mode 100644 index 000000000..93f543c9f --- /dev/null +++ b/src/api/models/LeagueSeasonScore.py @@ -0,0 +1,29 @@ +from api.models.ConfiguredModel import ConfiguredModel +from api.models.LeagueSeason import LeagueSeason +from api.models.LeagueSubdivision import LeagueSubdivision +from pydantic import Field +from pydantic import field_validator + + +class LeagueSeasonScore(ConfiguredModel): + game_count: int = Field(alias="gameCount") + login_id: int = Field(alias="loginId") + returning_player: bool = Field(alias="returningPlayer") + score: int | None + + subdivision: LeagueSubdivision | None = Field(None, alias="leagueSeasonDivisionSubdivision") + season: LeagueSeason | None = Field(None, alias="leagueSeason") + + @field_validator("subdivision", mode="before") + @classmethod + def validate_subdivision(cls, value: dict) -> LeagueSubdivision | None: + if not value: + return None + return LeagueSubdivision(**value) + + @field_validator("season", mode="before") + @classmethod + def validate_season(cls, value: dict) -> LeagueSeason | None: + if not value: + return None + return LeagueSeason(**value) diff --git a/src/api/models/LeagueSubdivision.py b/src/api/models/LeagueSubdivision.py new file mode 100644 index 000000000..66ec793d4 --- /dev/null +++ b/src/api/models/LeagueSubdivision.py @@ -0,0 +1,25 @@ +from api.models.ConfiguredModel import ConfiguredModel +from api.models.LeagueDivision import LeagueDivision +from pydantic import Field +from pydantic import field_validator + + +class LeagueSubdivision(ConfiguredModel): + index: int = Field(alias="subdivisionIndex") + name: str = Field(alias="nameKey") + description: str = Field(alias="descriptionKey") + highest_score: int = Field(alias="highestScore") + max_rating: int = Field(alias="maxRating") + min_rating: int = Field(alias="minRating") + image_url: str = Field(alias="imageUrl") + small_image_url: str = Field(alias="smallImageUrl") + medium_image_url: str = Field(alias="mediumImageUrl") + + division: LeagueDivision | None = Field(None, alias="leagueSeasonDivision") + + @classmethod + @field_validator("division", mode="before") + def validate_division(cls, value: dict) -> LeagueDivision | None: + if not value: + return None + return LeagueDivision(**value) diff --git a/src/api/parsers/LeaderboardParser.py b/src/api/parsers/LeaderboardParser.py new file mode 100644 index 000000000..862cb9476 --- /dev/null +++ b/src/api/parsers/LeaderboardParser.py @@ -0,0 +1,12 @@ +from api.models.Leaderboard import Leaderboard + + +class LeaderboardParser: + + @staticmethod + def parse(api_result: dict) -> Leaderboard: + return Leaderboard(**api_result) + + @staticmethod + def parse_many(api_result: dict) -> list[Leaderboard]: + return [LeaderboardParser.parse(entry) for entry in api_result["data"]] diff --git a/src/api/parsers/LeaderboardRatingJournalParser.py b/src/api/parsers/LeaderboardRatingJournalParser.py new file mode 100644 index 000000000..c83037499 --- /dev/null +++ b/src/api/parsers/LeaderboardRatingJournalParser.py @@ -0,0 +1,12 @@ +from api.models.LeaderboardRatingJournal import LeaderboardRatingJournal + + +class LeaderboardRatingJournalParser: + + @staticmethod + def parse(api_result: dict) -> LeaderboardRatingJournal: + return LeaderboardRatingJournal(**api_result) + + @staticmethod + def parse_many(api_result: dict) -> list[LeaderboardRatingJournal]: + return [LeaderboardRatingJournalParser.parse(entry) for entry in api_result["data"]] diff --git a/src/api/parsers/LeaderboardRatingParser.py b/src/api/parsers/LeaderboardRatingParser.py new file mode 100644 index 000000000..03ffcaa54 --- /dev/null +++ b/src/api/parsers/LeaderboardRatingParser.py @@ -0,0 +1,12 @@ +from api.models.LeaderboardRating import LeaderboardRating + + +class LeaderboardRatingParser: + + @staticmethod + def parse(api_result: dict) -> LeaderboardRating: + return LeaderboardRating(**api_result) + + @staticmethod + def parse_many(api_result: dict) -> list[LeaderboardRating]: + return [LeaderboardRatingParser.parse(entry) for entry in api_result["data"]] diff --git a/src/api/stats_api.py b/src/api/stats_api.py index af4301ab1..11a0d3329 100644 --- a/src/api/stats_api.py +++ b/src/api/stats_api.py @@ -1,20 +1,111 @@ import logging +from PyQt6.QtCore import QDateTime +from PyQt6.QtCore import Qt +from PyQt6.QtCore import pyqtSignal + from api.ApiAccessors import DataApiAccessor +from api.models.Leaderboard import Leaderboard +from api.models.LeaderboardRatingJournal import LeaderboardRatingJournal +from api.models.LeagueSeasonScore import LeagueSeasonScore +from api.parsers.LeaderboardParser import LeaderboardParser +from api.parsers.LeaderboardRatingJournalParser import LeaderboardRatingJournalParser +from api.parsers.LeaderboardRatingParser import LeaderboardRatingParser logger = logging.getLogger(__name__) class LeaderboardRatingApiConnector(DataApiAccessor): - def __init__(self, leaderboard_name: str) -> None: + player_ratings_ready = pyqtSignal(dict) + + def __init__(self) -> None: super().__init__('/data/leaderboardRating') - self.leaderboard_name = leaderboard_name - def prepare_data(self, message: dict) -> None: - message["leaderboard"] = self.leaderboard_name - return message + def get_player_ratings(self, pid: str) -> None: + query = { + "include": "player,leaderboard", + "filter": f"player.id=={pid}", + } + self.get_by_query(query, self.handle_player_ratings) + + def handle_player_ratings(self, message: dict) -> None: + ratings = {"values": LeaderboardRatingParser.parse_many(message)} + self.player_ratings_ready.emit(ratings) class LeaderboardApiConnector(DataApiAccessor): def __init__(self) -> None: - super().__init__('/data/leaderboard') + super().__init__("/data/leaderboard") + + def prepare_data(self, message: dict) -> dict[str, list[Leaderboard]]: + return {"values": LeaderboardParser.parse_many(message)} + + +class LeaderboardRatingJournalApiConnector(DataApiAccessor): + ratings_ready = pyqtSignal(dict) + + def __init__(self) -> None: + super().__init__("/data/leaderboardRatingJournal") + self._result: list[LeaderboardRatingJournal] = [] + self.query = {} + + def handle_page(self, message: dict) -> None: + total_pages = message["meta"]["page"]["totalPages"] + current_page = message["meta"]["page"]["number"] + self._result.extend(LeaderboardRatingJournalParser.parse_many(message)) + if current_page < total_pages: + self.get_history_page(current_page + 1) + else: + self.ratings_ready.emit({"values": self._result}) + + def get_history_page(self, page: int) -> None: + self.query.update({ + "page[size]": 10000, + "page[number]": page, + "page[totals]": "", + }) + self.get_by_query(self.query, self.handle_page) + + def get_full_history(self, pid: str, leaderboard: str) -> None: + self._result.clear() + self.query.update({ + "include": "gamePlayerStats,leaderboard", + "filter": ( + f"gamePlayerStats.player.id=={pid!r};" + f"leaderboard.technicalName=={leaderboard!r};" + "gamePlayerStats.scoreTime=isnull='false'" + ), + "sort": "gamePlayerStats.scoreTime", + }) + self.get_history_page(1) + + +class LeagueSeasonScoreApiConnector(DataApiAccessor): + score_ready = pyqtSignal(LeagueSeasonScore) + + def __init__(self) -> None: + super().__init__("/data/leagueSeasonScore") + + def prepare_data(self, message: dict) -> dict[str, list[LeagueSeasonScore]]: + return {"values": [LeagueSeasonScore(**entry) for entry in message["data"]]} + + def handle_score(self, message: dict) -> None: + if message["data"]: + self.score_ready.emit(LeagueSeasonScore(**message["data"][0])) + + def get_player_score_in_leaderboard(self, player_id: str, leaderboard: str) -> None: + include = ( + "leagueSeasonDivisionSubdivision", + "leagueSeasonDivisionSubdivision.leagueSeasonDivision", + "leagueSeason", + "leagueSeason.leaderboard", + ) + utc_str = QDateTime.currentDateTime().toUTC().toString(Qt.DateFormat.ISODate) + filters = ( + f"loginId=={player_id!r}", + f"leagueSeason.leaderboard.technicalName=={leaderboard!r}", + f"leagueSeason.startDate=le={utc_str}", + f"leagueSeason.endDate=ge={utc_str}", + ) + query_params = {"include": ",".join(include), "filter": ";".join(filters)} + self.get_by_query(query_params, self.handle_score) diff --git a/src/chat/chatter_menu.py b/src/chat/chatter_menu.py index b1b304cb7..587b8bc52 100644 --- a/src/chat/chatter_menu.py +++ b/src/chat/chatter_menu.py @@ -6,6 +6,7 @@ from PyQt6.QtWidgets import QMenu from model.game import GameState +from src.client.playerinfodialog import PlayerInfoDialog logger = logging.getLogger(__name__) @@ -29,6 +30,7 @@ class ChatterMenuItems(Enum): COPY_USERNAME = "Copy username" INVITE_TO_PARTY = "Invite to party" KICK_FROM_PARTY = "Kick from party" + SHOW_USER_INFO = "Show user info" class ChatterMenu: @@ -47,7 +49,7 @@ def __init__( @classmethod def build( cls, me, power_tools, parent_widget, avatar_widget_builder, - alias_viewer, client_window, game_runner, **kwargs + alias_viewer, client_window, game_runner, **kwargs, ): return cls( me, power_tools, parent_widget, avatar_widget_builder, @@ -94,6 +96,7 @@ def player_actions(self, player, game, is_me): yield ChatterMenuItems.VIEW_LIVEREPLAY if player is not None: + yield ChatterMenuItems.SHOW_USER_INFO if player.ladder_estimate != 0: yield ChatterMenuItems.VIEW_IN_LEADERBOARDS yield ChatterMenuItems.VIEW_REPLAYS @@ -188,6 +191,8 @@ def _handle_action(self, chatter, player, game, kind): self._handle_chatterboxes(chatter, player, kind) elif kind == Items.VIEW_ALIASES: self._view_aliases(chatter) + elif kind == Items.SHOW_USER_INFO: + self._show_user_info(player) elif kind == Items.VIEW_REPLAYS: self._client_window.view_replays(player.login) elif kind == Items.VIEW_IN_LEADERBOARDS: @@ -230,3 +235,7 @@ def _handle_chatterboxes(self, chatter, player, kind): def _view_aliases(self, chatter): self._alias_viewer.view_aliases(chatter.name) + + def _show_user_info(self, player) -> None: + dialog = PlayerInfoDialog(player.login, player.id) + dialog.run() diff --git a/src/client/leagueformatter.py b/src/client/leagueformatter.py new file mode 100644 index 000000000..e5bf3f4fe --- /dev/null +++ b/src/client/leagueformatter.py @@ -0,0 +1,81 @@ +import os + +from PyQt6.QtGui import QImage +from PyQt6.QtGui import QPixmap + +import util +from api.models.LeaderboardRating import LeaderboardRating +from api.models.LeagueSeasonScore import LeagueSeasonScore +from api.stats_api import LeagueSeasonScoreApiConnector +from downloadManager import Downloader +from downloadManager import DownloadRequest + +FormClass, BaseClass = util.THEME.loadUiType("player_card/playerleague.ui") + + +class LegueFormatter(FormClass, BaseClass): + def __init__( + self, + player_id: str, + rating: LeaderboardRating, + league_score_api: LeagueSeasonScoreApiConnector, + ) -> None: + BaseClass.__init__(self) + self.setupUi(self) + self.load_stylesheet() + + self.divisionLabel.setText("Unlisted") + icon = util.THEME.pixmap("client/unlistedd.png") + self.iconLabel.setPixmap(icon.scaled(80, 80)) + + self.gamesLabel.setText(f"{rating.total_games:.0f} Games") + # chr(0xB1) = +- + rating_str = f"{rating.rating:.0f} [{rating.mean:.0f}\xb1{rating.deviation:.0f}]" + self.ratingLabel.setText(rating_str) + self.leaderboardLabel.setText(rating.leaderboard.technical_name) + self.league_score_api = league_score_api + self.league_score_api.score_ready.connect(self.on_league_score_ready) + + self.leaderboard = rating.leaderboard.technical_name + self.league_score_api.get_player_score_in_leaderboard(player_id, self.leaderboard) + + self.leaderboardLabel.setText(self.leaderboard) + + self._downloader = Downloader(os.path.join(util.CACHE_DIR, "divisions")) + self._images_dl_request = DownloadRequest() + self._images_dl_request.done.connect(self.on_image_downloaded) + + def load_stylesheet(self) -> None: + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + + def on_league_score_ready(self, score: LeagueSeasonScore) -> None: + if score.season.leaderboard.technical_name != self.leaderboard: + return + + if score.score is None: + self.divisionLabel.setText("Unlisted") + return + + subdivision = score.subdivision + league_name = f"{subdivision.division.name} {subdivision.name}" + self.divisionLabel.setText(league_name) + + image_name = os.path.basename(subdivision.image_url) + image_path = os.path.join(util.CACHE_DIR, "divisions", image_name) + if os.path.isfile(image_path): + self.set_league_icon(image_path) + else: + self.download_league_icon(subdivision.image_url) + + def set_league_icon(self, image_path: str) -> None: + image = QImage(image_path) + self.iconLabel.setPixmap(QPixmap(image).scaled(160, 80)) + + def download_league_icon(self, url: str) -> None: + name = os.path.basename(url) + self._downloader.download(name, self._images_dl_request, url) + + def on_image_downloaded(self, _: str, result: tuple[str, bool]) -> None: + image_path, download_failed = result + if not download_failed: + self.set_league_icon(image_path) diff --git a/src/client/playerinfodialog.py b/src/client/playerinfodialog.py new file mode 100644 index 000000000..9c0a6e3ec --- /dev/null +++ b/src/client/playerinfodialog.py @@ -0,0 +1,215 @@ +from bisect import bisect_left + +import pyqtgraph as pg +from PyQt6.QtCore import QDateTime +from PyQt6.QtCore import QPointF +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QColor +from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem + +import util +from api.models.Leaderboard import Leaderboard +from api.models.LeaderboardRating import LeaderboardRating +from api.models.LeaderboardRatingJournal import LeaderboardRatingJournal +from api.models.LeagueSeasonScore import LeagueSeasonScore +from api.stats_api import LeaderboardApiConnector +from api.stats_api import LeaderboardRatingApiConnector +from api.stats_api import LeaderboardRatingJournalApiConnector +from api.stats_api import LeagueSeasonScoreApiConnector +from client.leagueformatter import LegueFormatter + +FormClass, BaseClass = util.THEME.loadUiType("player_card/playercard.ui") + + +class Crosshairs: + def __init__(self, plotwidget: pg.PlotWidget, xseries: list[int], yseries: list[float]) -> None: + self.plotwidget = plotwidget + self.plotwidget.scene().sigMouseMoved.connect(self.update_lines_and_text) + + self.xseries = xseries + self.yseries = yseries + + pen = pg.mkPen("green", width=3) + self.xLine = pg.InfiniteLine(angle=90, pen=pen) + self.yLine = pg.InfiniteLine(angle=0, pen=pen) + + self.plotwidget.addItem(self.xLine, ignoreBounds=True) + self.plotwidget.addItem(self.yLine, ignoreBounds=True) + + color = QColor("black") + self.xText = pg.TextItem(color=color) + self.yText = pg.TextItem(color=color) + + self.plotwidget.scene().addItem(self.xText) + self.plotwidget.scene().addItem(self.yText) + + self.plotwidget.plotItem.getAxis("left").setWidth(40) + self._visible = True + + def set_visible(self, visible: bool) -> None: + self.xLine.setVisible(visible) + self.yLine.setVisible(visible) + self.xText.setVisible(visible) + self.yText.setVisible(visible) + self._visible = visible + + def hide(self) -> None: + self.set_visible(False) + + def show(self) -> None: + self.set_visible(True) + + def change_visibility(self) -> None: + self.set_visible(not self._visible) + + def is_visible(self) -> bool: + return self._visible + + def _closest_index(self, lst: list[float], value: float) -> int: + pos = bisect_left(lst, value) + if pos == 0: + return pos + if pos == len(lst): + return pos - 1 + + before = lst[pos - 1] + after = lst[pos] + if after - value < value - before: + return pos + else: + return pos - 1 + + def map_to_data(self, pos: QPointF) -> QPointF: + view = self.plotwidget.plotItem.getViewBox() + value_pos = view.mapSceneToView(pos) + point_index = self._closest_index(self.xseries, value_pos.x()) + return QPointF(self.xseries[point_index], self.yseries[point_index]) + + def get_xtext_pos(self, scene_point: QPointF) -> tuple[float, float]: + scene_width = self.plotwidget.sceneBoundingRect().width() + text_width = self.xText.boundingRect().width() + text_height = self.xText.boundingRect().height() + padding = 3 + + left_margin = self.plotwidget.plotItem.getAxis("left").width() - padding + right_margin = scene_width - text_width + padding + + x = max(left_margin, scene_point.x() - text_width / 2) + x = min(x, right_margin) + y = self.plotwidget.sceneBoundingRect().bottom() - text_height + padding + return x, y + + def get_ytext_pos(self, scene_point: QPointF) -> tuple[float, float]: + padding = 3 + x = self.plotwidget.sceneBoundingRect().left() - padding + y = scene_point.y() - self.yText.boundingRect().height() / 2 + return x, y + + def update_lines_and_text(self, pos: QPointF) -> None: + if not self.xseries: + return + + data_point = self.map_to_data(pos) + view = self.plotwidget.plotItem.getViewBox() + scene_point = view.mapViewToScene(data_point) + + left_margin = self.plotwidget.plotItem.getAxis("left").width() + if scene_point.x() < left_margin or scene_point.y() < 0: + return + + self.update_lines(pos, data_point) + self.update_text(scene_point, data_point) + self.show_at_pos(scene_point) + + def update_text(self, scene_point: QPointF, data_point: QPointF) -> None: + date = QDateTime.fromSecsSinceEpoch(round(data_point.x())).toString("dd-MM-yyyy hh:mm") + self.xText.setHtml(f"
{date}
") + self.xText.setPos(*self.get_xtext_pos(scene_point)) + self.yText.setHtml(f"
{data_point.y():.2f}
") + self.yText.setPos(*self.get_ytext_pos(scene_point)) + + def update_lines(self, pos: QPointF, data_point: QPointF) -> None: + if self.plotwidget.sceneBoundingRect().contains(pos): + self.xLine.setPos(data_point.x()) + self.yLine.setPos(data_point.y()) + + def show_at_pos(self, pos: QPointF) -> None: + seen = self.plotwidget.sceneBoundingRect().contains(pos) + self.set_visible(seen) + + +class PlayerInfoDialog(FormClass, BaseClass): + def __init__(self, login: str, xd: str) -> None: + BaseClass.__init__(self) + self.setupUi(self) + self.load_stylesheet() + + self.player_login = login + self.player_id = xd + + self.leaderboards_api = LeaderboardApiConnector() + self.leaderboards_api.data_ready.connect(self.populate_leaderboards) + + self.ratings_history_api = LeaderboardRatingJournalApiConnector() + self.ratings_history_api.ratings_ready.connect(self.process_rating_history) + + self.plotWidget.setBackground("#202025") + self.plotWidget.setAxisItems({"bottom": DateAxisItem()}) + + self.ratingComboBox.currentTextChanged.connect(self.get_ratings) + self.crosshairs = None + + self.leagues_api = LeagueSeasonScoreApiConnector() + self.leagues_api.data_ready.connect(self.on_leagues_ready) + + self.ratings_api = LeaderboardRatingApiConnector() + self.ratings_api.player_ratings_ready.connect(self.process_player_ratings) + self.ratings_api.get_player_ratings(self.player_id) + + def load_stylesheet(self) -> None: + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + + def on_leagues_ready(self, message: dict[str, list[LeagueSeasonScore]]) -> None: + for score in message["values"]: + if score.subdivision is None: + continue + + def run(self) -> None: + self.leaderboards_api.requestData() + self.exec() + + def populate_leaderboards(self, message: dict[str, list[Leaderboard]]) -> None: + for leaderboard in message["values"]: + self.ratingComboBox.addItem(leaderboard.technical_name) + + def clear_graphics(self) -> None: + self.plotWidget.clear() + if self.crosshairs is not None: + self.crosshairs.hide() + self.crosshairs = None + + def get_ratings(self, leaderboard_name: str) -> None: + self.ratingComboBox.setEnabled(False) + self.ratings_history_api.get_full_history(self.player_id, leaderboard_name) + + def get_chart_series(self, ratings: list[LeaderboardRatingJournal]) -> tuple[list, list]: + xvals, yvals = [], [] + for entry in ratings: + score_time = QDateTime.fromString(entry.player_stats.score_time, Qt.DateFormat.ISODate) + xvals.append(score_time.toSecsSinceEpoch()) + yvals.append(entry.mean_after - 3 * entry.deviation_after) + return xvals, yvals + + def draw_ratings(self, ratings: tuple[list, list]) -> None: + self.plotWidget.plot(*ratings, pen=pg.mkPen("orange")) + self.crosshairs = Crosshairs(self.plotWidget, *ratings) + self.plotWidget.autoRange() + + def process_rating_history(self, ratings: dict[str, list[LeaderboardRatingJournal]]) -> None: + self.clear_graphics() + self.draw_ratings(self.get_chart_series(ratings["values"])) + self.ratingComboBox.setEnabled(True) + + def process_player_ratings(self, ratings: dict[str, list[LeaderboardRating]]) -> None: + for rating in ratings["values"]: + self.leaguesLayout.addWidget(LegueFormatter(self.player_id, rating, self.leagues_api)) diff --git a/src/stats/_statswidget.py b/src/stats/_statswidget.py index a13bef632..2265197b3 100644 --- a/src/stats/_statswidget.py +++ b/src/stats/_statswidget.py @@ -5,6 +5,7 @@ from PyQt6 import QtWidgets import util +from api.models.Leaderboard import Leaderboard from api.stats_api import LeaderboardApiConnector from ui.busy_widget import BusyWidget @@ -241,10 +242,10 @@ def leaderboardsTabChanged(self, curr): if self.leaderboards.widget(curr) is not None: self.leaderboards.widget(curr).entered() - def process_leaderboards_info(self, message: dict) -> None: + def process_leaderboards_info(self, message: dict[str, list[Leaderboard]]) -> None: self.leaderboardNames.clear() - for value in message["data"]: - self.leaderboardNames.append(value["technicalName"]) + for leaderboard in message["values"]: + self.leaderboardNames.append(leaderboard.technical_name) for index, name in enumerate(self.leaderboardNames): self.leaderboards.insertTab( index, diff --git a/src/stats/itemviews/leaderboardtablemenu.py b/src/stats/itemviews/leaderboardtablemenu.py index 50c14287f..3a2a975af 100644 --- a/src/stats/itemviews/leaderboardtablemenu.py +++ b/src/stats/itemviews/leaderboardtablemenu.py @@ -3,6 +3,8 @@ from PyQt6 import QtWidgets from PyQt6.QtGui import QAction +from src.client.playerinfodialog import PlayerInfoDialog + class LeaderboardTableMenuItems(Enum): VIEW_ALIASES = "View aliases" @@ -12,6 +14,7 @@ class LeaderboardTableMenuItems(Enum): REMOVE_FRIEND = "Remove friend" REMOVE_FOE = "Remove foe" COPY_USERNAME = "Copy username" + SHOW_USER_INFO = "Show user info" class LeaderboardTableMenu: @@ -40,6 +43,7 @@ def usernameActions(self): def playerActions(self): yield LeaderboardTableMenuItems.VIEW_REPLAYS + yield LeaderboardTableMenuItems.SHOW_USER_INFO def friendActions(self, name, uid, is_me): if is_me: @@ -80,6 +84,8 @@ def handler(self, name, uid, kind): return lambda: self.viewAliases(name) elif kind == Items.VIEW_REPLAYS: return lambda: self.viewReplays(name) + elif kind == Items.SHOW_USER_INFO: + return lambda: self.show_user_info(name, uid) elif kind in [ Items.ADD_FRIEND, Items.ADD_FOE, Items.REMOVE_FRIEND, Items.REMOVE_FOE, @@ -107,3 +113,7 @@ def handleFriends(self, uid, kind): ctl.foes.add(uid) elif kind == Items.REMOVE_FOE: ctl.foes.remove(uid) + + def show_user_info(self, name: str, uid: str) -> None: + dialog = PlayerInfoDialog(name, uid) + dialog.run() diff --git a/src/stats/leaderboard_widget.py b/src/stats/leaderboard_widget.py index a10dc476d..f5c58ffe0 100644 --- a/src/stats/leaderboard_widget.py +++ b/src/stats/leaderboard_widget.py @@ -41,7 +41,7 @@ def __init__( self.client = client self.parent = parent self.leaderboardName = leaderboardName - self.apiConnector = LeaderboardRatingApiConnector(self.leaderboardName) + self.apiConnector = LeaderboardRatingApiConnector() self.apiConnector.data_ready.connect(self.process_rating_info) self.playerApiConnector = PlayerApiConnector() self.onlyActive = True @@ -166,11 +166,10 @@ def showColumns(self): self.showColumnCheckBoxes[index].blockSignals(False) def process_rating_info(self, message: dict) -> None: - if message["leaderboard"] == self.leaderboardName: - self.createLeaderboard(message) - self.processMeta(message["meta"]) - self.resetLoading() - self.timer.stop() + self.createLeaderboard(message) + self.processMeta(message["meta"]) + self.resetLoading() + self.timer.stop() def createLeaderboard(self, data): self.model = LeaderboardTableModel(data) From 1ce7c553d1b0fd3d4cba267ee332e784c66a6d01 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 13 Jun 2024 22:43:11 +0300 Subject: [PATCH 05/73] Remove duplicate field validators --- src/api/models/ConfiguredModel.py | 10 ++++++++++ src/api/models/LeaderboardRating.py | 15 --------------- src/api/models/LeagueDivision.py | 8 -------- src/api/models/LeagueSeason.py | 8 -------- src/api/models/LeagueSeasonScore.py | 15 --------------- src/api/models/LeagueSubdivision.py | 8 -------- src/api/models/Map.py | 18 +----------------- src/api/models/MapPoolAssignment.py | 18 +----------------- src/api/models/Mod.py | 18 +----------------- 9 files changed, 13 insertions(+), 105 deletions(-) diff --git a/src/api/models/ConfiguredModel.py b/src/api/models/ConfiguredModel.py index 4c4e4e1c5..d308049f4 100644 --- a/src/api/models/ConfiguredModel.py +++ b/src/api/models/ConfiguredModel.py @@ -1,6 +1,16 @@ +from typing import Any + from pydantic import BaseModel from pydantic import ConfigDict +from pydantic import field_validator class ConfiguredModel(BaseModel): model_config = ConfigDict(populate_by_name=True) + + @field_validator("*", mode="before") + @classmethod + def ensure_not_empty_dict(cls, v: Any) -> Any: + if isinstance(v, dict) and not v: + return None + return v diff --git a/src/api/models/LeaderboardRating.py b/src/api/models/LeaderboardRating.py index dfb2f6a98..952c21328 100644 --- a/src/api/models/LeaderboardRating.py +++ b/src/api/models/LeaderboardRating.py @@ -2,7 +2,6 @@ from api.models.Leaderboard import Leaderboard from api.models.Player import Player from pydantic import Field -from pydantic import field_validator class LeaderboardRating(AbstractEntity): @@ -14,17 +13,3 @@ class LeaderboardRating(AbstractEntity): leaderboard: Leaderboard | None = Field(None) player: Player | None = Field(None) - - @classmethod - @field_validator("leaderboard", mode="before") - def validate_leaderboard(cls, value: dict) -> Leaderboard | None: - if not value: - return None - return Leaderboard(**value) - - @classmethod - @field_validator("player", mode="before") - def validate_player(cls, value: dict) -> Player | None: - if not value: - return None - return Player(**value) diff --git a/src/api/models/LeagueDivision.py b/src/api/models/LeagueDivision.py index eef5df809..aa83d9699 100644 --- a/src/api/models/LeagueDivision.py +++ b/src/api/models/LeagueDivision.py @@ -1,7 +1,6 @@ from api.models.ConfiguredModel import ConfiguredModel from api.models.LeagueSeason import LeagueSeason from pydantic import Field -from pydantic import field_validator class LeagueDivision(ConfiguredModel): @@ -11,10 +10,3 @@ class LeagueDivision(ConfiguredModel): name: str = Field(alias="nameKey") season: LeagueSeason | None = Field(None, alias="leagueSeason") - - @classmethod - @field_validator("season", mode="before") - def validate_season(cls, value: dict) -> LeagueSeason | None: - if not value: - return None - return LeagueSeason(**value) diff --git a/src/api/models/LeagueSeason.py b/src/api/models/LeagueSeason.py index 44e000a17..02a3ba0de 100644 --- a/src/api/models/LeagueSeason.py +++ b/src/api/models/LeagueSeason.py @@ -1,7 +1,6 @@ from api.models.ConfiguredModel import ConfiguredModel from api.models.LeagueLeaderboard import LeagueLeaderboard from pydantic import Field -from pydantic import field_validator class LeagueSeason(ConfiguredModel): @@ -13,10 +12,3 @@ class LeagueSeason(ConfiguredModel): start_date: str = Field(alias="startDate") leaderboard: LeagueLeaderboard | None = Field(None) - - @classmethod - @field_validator("leaderboard", mode="before") - def validate_LeagueLeaderboard(cls, value: dict) -> LeagueLeaderboard | None: - if not value: - return None - return LeagueLeaderboard(**value) diff --git a/src/api/models/LeagueSeasonScore.py b/src/api/models/LeagueSeasonScore.py index 93f543c9f..10c61be1e 100644 --- a/src/api/models/LeagueSeasonScore.py +++ b/src/api/models/LeagueSeasonScore.py @@ -2,7 +2,6 @@ from api.models.LeagueSeason import LeagueSeason from api.models.LeagueSubdivision import LeagueSubdivision from pydantic import Field -from pydantic import field_validator class LeagueSeasonScore(ConfiguredModel): @@ -13,17 +12,3 @@ class LeagueSeasonScore(ConfiguredModel): subdivision: LeagueSubdivision | None = Field(None, alias="leagueSeasonDivisionSubdivision") season: LeagueSeason | None = Field(None, alias="leagueSeason") - - @field_validator("subdivision", mode="before") - @classmethod - def validate_subdivision(cls, value: dict) -> LeagueSubdivision | None: - if not value: - return None - return LeagueSubdivision(**value) - - @field_validator("season", mode="before") - @classmethod - def validate_season(cls, value: dict) -> LeagueSeason | None: - if not value: - return None - return LeagueSeason(**value) diff --git a/src/api/models/LeagueSubdivision.py b/src/api/models/LeagueSubdivision.py index 66ec793d4..c2c7847ce 100644 --- a/src/api/models/LeagueSubdivision.py +++ b/src/api/models/LeagueSubdivision.py @@ -1,7 +1,6 @@ from api.models.ConfiguredModel import ConfiguredModel from api.models.LeagueDivision import LeagueDivision from pydantic import Field -from pydantic import field_validator class LeagueSubdivision(ConfiguredModel): @@ -16,10 +15,3 @@ class LeagueSubdivision(ConfiguredModel): medium_image_url: str = Field(alias="mediumImageUrl") division: LeagueDivision | None = Field(None, alias="leagueSeasonDivision") - - @classmethod - @field_validator("division", mode="before") - def validate_division(cls, value: dict) -> LeagueDivision | None: - if not value: - return None - return LeagueDivision(**value) diff --git a/src/api/models/Map.py b/src/api/models/Map.py index 97c983011..6a168acfb 100644 --- a/src/api/models/Map.py +++ b/src/api/models/Map.py @@ -1,11 +1,9 @@ -from pydantic import Field -from pydantic import field_validator - from api.models.AbstractEntity import AbstractEntity from api.models.MapType import MapType from api.models.MapVersion import MapVersion from api.models.Player import Player from api.models.ReviewsSummary import ReviewsSummary +from pydantic import Field class Map(AbstractEntity): @@ -20,17 +18,3 @@ class Map(AbstractEntity): @property def maptype(self) -> MapType: return MapType.from_string(self.map_type) - - @field_validator("reviews_summary", mode="before") - @classmethod - def validate_reviews_summary(cls, value: dict) -> ReviewsSummary | None: - if not value: - return None - return ReviewsSummary(**value) - - @field_validator("author", mode="before") - @classmethod - def validate_author(cls, value: dict) -> Player | None: - if not value: - return None - return Player(**value) diff --git a/src/api/models/MapPoolAssignment.py b/src/api/models/MapPoolAssignment.py index fa38d754c..a110d0b89 100644 --- a/src/api/models/MapPoolAssignment.py +++ b/src/api/models/MapPoolAssignment.py @@ -1,28 +1,12 @@ from __future__ import annotations -from pydantic import Field -from pydantic import field_validator - from api.models.AbstractEntity import AbstractEntity from api.models.GeneratedMapParams import GeneratedMapParams from api.models.MapVersion import MapVersion +from pydantic import Field class MapPoolAssignment(AbstractEntity): map_params: GeneratedMapParams | None = Field(None, alias="mapParams") map_version: MapVersion | None = Field(None, alias="mapVersion") weight: int - - @field_validator("map_params", mode="before") - @classmethod - def validate_map_params(cls, value: dict) -> GeneratedMapParams | None: - if not value: - return None - return GeneratedMapParams(**value) - - @field_validator("map_version", mode="before") - @classmethod - def validate_map_version(cls, value: dict) -> MapVersion | None: - if not value: - return None - return MapVersion(**value) diff --git a/src/api/models/Mod.py b/src/api/models/Mod.py index c2d113174..5dd9ed545 100644 --- a/src/api/models/Mod.py +++ b/src/api/models/Mod.py @@ -1,10 +1,8 @@ -from pydantic import Field -from pydantic import field_validator - from api.models.AbstractEntity import AbstractEntity from api.models.ModVersion import ModVersion from api.models.Player import Player from api.models.ReviewsSummary import ReviewsSummary +from pydantic import Field class Mod(AbstractEntity): @@ -14,17 +12,3 @@ class Mod(AbstractEntity): reviews_summary: ReviewsSummary | None = Field(None, alias="reviewsSummary") uploader: Player | None = Field(None) version: ModVersion = Field(alias="latestVersion") - - @field_validator("reviews_summary", mode="before") - @classmethod - def validate_reviews_summary(cls, value: dict) -> ReviewsSummary | None: - if not value: - return None - return ReviewsSummary(**value) - - @field_validator("uploader", mode="before") - @classmethod - def validate_uploader(cls, value: dict) -> Player | None: - if not value: - return None - return Player(**value) From 94272d67e0fd8782bf9e9ad27edd037d21262948 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 14 Jun 2024 02:48:19 +0300 Subject: [PATCH 06/73] Move playercard into its own folder --- src/chat/chatter_menu.py | 2 +- src/{client => playercard}/leagueformatter.py | 2 +- src/{client => playercard}/playerinfodialog.py | 2 +- src/stats/itemviews/leaderboardtablemenu.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename src/{client => playercard}/leagueformatter.py (98%) rename src/{client => playercard}/playerinfodialog.py (99%) diff --git a/src/chat/chatter_menu.py b/src/chat/chatter_menu.py index 587b8bc52..c42b02545 100644 --- a/src/chat/chatter_menu.py +++ b/src/chat/chatter_menu.py @@ -6,7 +6,7 @@ from PyQt6.QtWidgets import QMenu from model.game import GameState -from src.client.playerinfodialog import PlayerInfoDialog +from playercard.playerinfodialog import PlayerInfoDialog logger = logging.getLogger(__name__) diff --git a/src/client/leagueformatter.py b/src/playercard/leagueformatter.py similarity index 98% rename from src/client/leagueformatter.py rename to src/playercard/leagueformatter.py index e5bf3f4fe..a548301be 100644 --- a/src/client/leagueformatter.py +++ b/src/playercard/leagueformatter.py @@ -25,7 +25,7 @@ def __init__( self.load_stylesheet() self.divisionLabel.setText("Unlisted") - icon = util.THEME.pixmap("client/unlistedd.png") + icon = util.THEME.pixmap("player_card/unlisted.png") self.iconLabel.setPixmap(icon.scaled(80, 80)) self.gamesLabel.setText(f"{rating.total_games:.0f} Games") diff --git a/src/client/playerinfodialog.py b/src/playercard/playerinfodialog.py similarity index 99% rename from src/client/playerinfodialog.py rename to src/playercard/playerinfodialog.py index 9c0a6e3ec..ea090e478 100644 --- a/src/client/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -16,7 +16,7 @@ from api.stats_api import LeaderboardRatingApiConnector from api.stats_api import LeaderboardRatingJournalApiConnector from api.stats_api import LeagueSeasonScoreApiConnector -from client.leagueformatter import LegueFormatter +from src.playercard.leagueformatter import LegueFormatter FormClass, BaseClass = util.THEME.loadUiType("player_card/playercard.ui") diff --git a/src/stats/itemviews/leaderboardtablemenu.py b/src/stats/itemviews/leaderboardtablemenu.py index 3a2a975af..16d1d3094 100644 --- a/src/stats/itemviews/leaderboardtablemenu.py +++ b/src/stats/itemviews/leaderboardtablemenu.py @@ -3,7 +3,7 @@ from PyQt6 import QtWidgets from PyQt6.QtGui import QAction -from src.client.playerinfodialog import PlayerInfoDialog +from playercard.playerinfodialog import PlayerInfoDialog class LeaderboardTableMenuItems(Enum): From bba53a423e2c897d5ce9268d56ec7d0b80c25806 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 15 Jun 2024 15:57:01 +0300 Subject: [PATCH 07/73] Add nick, id, create and update times to player card --- res/client/client.css | 7 ++ res/player_card/playercard.ui | 77 +++++++++++++++++++-- src/api/player_api.py | 9 +++ src/chat/chatter_menu.py | 5 +- src/playercard/playerinfodialog.py | 20 +++++- src/stats/itemviews/leaderboardtablemenu.py | 6 +- 6 files changed, 110 insertions(+), 14 deletions(-) diff --git a/res/client/client.css b/res/client/client.css index d29b8cc3f..30880b6fb 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -93,6 +93,13 @@ QTabWidget::pane background-color: #2f2f2f; } +QTabWidget > QTabBar::tab +{ + background-color:#383838; + color: silver; + min-width: 80px; +} + QTabWidget#matchmakerQueues::pane { background-color: none; diff --git a/res/player_card/playercard.ui b/res/player_card/playercard.ui index d506d474c..e707e7c7a 100644 --- a/res/player_card/playercard.ui +++ b/res/player_card/playercard.ui @@ -41,16 +41,13 @@ - QFrame::Shape::StyledPanel - - - QFrame::Shadow::Raised + QFrame::NoFrame - + - + @@ -78,6 +75,74 @@ + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + Name: + + + + + + + + + + + + + + Id: + + + + + + + + + + + + + + Last login: + + + + + + + + + + + + + + Registered: + + + + + + + + + + + + + diff --git a/src/api/player_api.py b/src/api/player_api.py index 5253c8854..00fc9e8ce 100644 --- a/src/api/player_api.py +++ b/src/api/player_api.py @@ -3,12 +3,14 @@ from PyQt6.QtCore import pyqtSignal from api.ApiAccessors import DataApiAccessor +from api.models.Player import Player logger = logging.getLogger(__name__) class PlayerApiConnector(DataApiAccessor): alias_info = pyqtSignal(dict) + player_ready = pyqtSignal(Player) def __init__(self) -> None: super().__init__('/data/player') @@ -26,3 +28,10 @@ def requestDataForAliasViewer(self, nameToFind: str) -> None: def handleDataForAliasViewer(self, message: dict) -> None: self.alias_info.emit(message) + + def request_player(self, player_id: str) -> None: + self.get_by_query({"filter": f"id=={player_id}"}, self.handle_player) + + def handle_player(self, message: dict) -> None: + player, = message["data"] + self.player_ready.emit(Player(**player)) diff --git a/src/chat/chatter_menu.py b/src/chat/chatter_menu.py index c42b02545..3654da485 100644 --- a/src/chat/chatter_menu.py +++ b/src/chat/chatter_menu.py @@ -6,6 +6,7 @@ from PyQt6.QtWidgets import QMenu from model.game import GameState +from model.player import Player from playercard.playerinfodialog import PlayerInfoDialog logger = logging.getLogger(__name__) @@ -236,6 +237,6 @@ def _handle_chatterboxes(self, chatter, player, kind): def _view_aliases(self, chatter): self._alias_viewer.view_aliases(chatter.name) - def _show_user_info(self, player) -> None: - dialog = PlayerInfoDialog(player.login, player.id) + def _show_user_info(self, player: Player) -> None: + dialog = PlayerInfoDialog(str(player.id)) dialog.run() diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index ea090e478..71c7a93d3 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -12,6 +12,8 @@ from api.models.LeaderboardRating import LeaderboardRating from api.models.LeaderboardRatingJournal import LeaderboardRatingJournal from api.models.LeagueSeasonScore import LeagueSeasonScore +from api.models.Player import Player +from api.player_api import PlayerApiConnector from api.stats_api import LeaderboardApiConnector from api.stats_api import LeaderboardRatingApiConnector from api.stats_api import LeaderboardRatingJournalApiConnector @@ -139,13 +141,15 @@ def show_at_pos(self, pos: QPointF) -> None: class PlayerInfoDialog(FormClass, BaseClass): - def __init__(self, login: str, xd: str) -> None: + def __init__(self, player_id: str) -> None: BaseClass.__init__(self) self.setupUi(self) self.load_stylesheet() - self.player_login = login - self.player_id = xd + self.player_id = player_id + + self.player_api = PlayerApiConnector() + self.player_api.player_ready.connect(self.process_player) self.leaderboards_api = LeaderboardApiConnector() self.leaderboards_api.data_ready.connect(self.populate_leaderboards) @@ -176,6 +180,7 @@ def on_leagues_ready(self, message: dict[str, list[LeagueSeasonScore]]) -> None: def run(self) -> None: self.leaderboards_api.requestData() + self.player_api.request_player(self.player_id) self.exec() def populate_leaderboards(self, message: dict[str, list[Leaderboard]]) -> None: @@ -195,6 +200,7 @@ def get_ratings(self, leaderboard_name: str) -> None: def get_chart_series(self, ratings: list[LeaderboardRatingJournal]) -> tuple[list, list]: xvals, yvals = [], [] for entry in ratings: + assert entry.player_stats is not None score_time = QDateTime.fromString(entry.player_stats.score_time, Qt.DateFormat.ISODate) xvals.append(score_time.toSecsSinceEpoch()) yvals.append(entry.mean_after - 3 * entry.deviation_after) @@ -213,3 +219,11 @@ def process_rating_history(self, ratings: dict[str, list[LeaderboardRatingJourna def process_player_ratings(self, ratings: dict[str, list[LeaderboardRating]]) -> None: for rating in ratings["values"]: self.leaguesLayout.addWidget(LegueFormatter(self.player_id, rating, self.leagues_api)) + + def process_player(self, player: Player) -> None: + self.nicknameLabel.setText(player.login) + self.idLabel.setText(player.xd) + registered = QDateTime.fromString(player.create_time, Qt.DateFormat.ISODate).toLocalTime() + self.registeredLabel.setText(registered.toString("yyyy-MM-dd hh:mm")) + last_login = QDateTime.fromString(player.update_time, Qt.DateFormat.ISODate).toLocalTime() + self.lastLoginLabel.setText(last_login.toString("yyyy-MM-dd hh:mm")) diff --git a/src/stats/itemviews/leaderboardtablemenu.py b/src/stats/itemviews/leaderboardtablemenu.py index 16d1d3094..432bd1e7f 100644 --- a/src/stats/itemviews/leaderboardtablemenu.py +++ b/src/stats/itemviews/leaderboardtablemenu.py @@ -85,7 +85,7 @@ def handler(self, name, uid, kind): elif kind == Items.VIEW_REPLAYS: return lambda: self.viewReplays(name) elif kind == Items.SHOW_USER_INFO: - return lambda: self.show_user_info(name, uid) + return lambda: self.show_user_info(uid) elif kind in [ Items.ADD_FRIEND, Items.ADD_FOE, Items.REMOVE_FRIEND, Items.REMOVE_FOE, @@ -114,6 +114,6 @@ def handleFriends(self, uid, kind): elif kind == Items.REMOVE_FOE: ctl.foes.remove(uid) - def show_user_info(self, name: str, uid: str) -> None: - dialog = PlayerInfoDialog(name, uid) + def show_user_info(self, uid: str) -> None: + dialog = PlayerInfoDialog(uid) dialog.run() From dd7ff2fe266c4ddb663ee943fc85455bb4ead5a9 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 16 Jun 2024 14:02:46 +0300 Subject: [PATCH 08/73] Remove unused leftover in PlayerInfoDialog --- src/playercard/playerinfodialog.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 71c7a93d3..8dda0df3c 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -11,7 +11,6 @@ from api.models.Leaderboard import Leaderboard from api.models.LeaderboardRating import LeaderboardRating from api.models.LeaderboardRatingJournal import LeaderboardRatingJournal -from api.models.LeagueSeasonScore import LeagueSeasonScore from api.models.Player import Player from api.player_api import PlayerApiConnector from api.stats_api import LeaderboardApiConnector @@ -164,7 +163,6 @@ def __init__(self, player_id: str) -> None: self.crosshairs = None self.leagues_api = LeagueSeasonScoreApiConnector() - self.leagues_api.data_ready.connect(self.on_leagues_ready) self.ratings_api = LeaderboardRatingApiConnector() self.ratings_api.player_ratings_ready.connect(self.process_player_ratings) @@ -173,11 +171,6 @@ def __init__(self, player_id: str) -> None: def load_stylesheet(self) -> None: self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) - def on_leagues_ready(self, message: dict[str, list[LeagueSeasonScore]]) -> None: - for score in message["values"]: - if score.subdivision is None: - continue - def run(self) -> None: self.leaderboards_api.requestData() self.player_api.request_player(self.player_id) From dc5f8242966212cf0f72ff97b700c24fe5b62870 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 17 Jun 2024 21:59:36 +0300 Subject: [PATCH 09/73] Don't try to parse empty lists as valid pydantic models --- src/api/models/ConfiguredModel.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/api/models/ConfiguredModel.py b/src/api/models/ConfiguredModel.py index d308049f4..7c34e4c26 100644 --- a/src/api/models/ConfiguredModel.py +++ b/src/api/models/ConfiguredModel.py @@ -10,7 +10,9 @@ class ConfiguredModel(BaseModel): @field_validator("*", mode="before") @classmethod - def ensure_not_empty_dict(cls, v: Any) -> Any: + def ensure_not_empty_or_none(cls, v: Any) -> Any: if isinstance(v, dict) and not v: return None + elif isinstance(v, list) and not v: + return None return v From 736c60acbe43b6f45262717c8c0c05139267e672 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 18 Jun 2024 20:22:44 +0300 Subject: [PATCH 10/73] Add avatar list to player card --- res/player_card/playercard.ui | 257 +++++++++++++------- src/api/models/Avatar.py | 7 + src/api/models/AvatarAssignment.py | 10 + src/api/models/LeaderboardRating.py | 2 - src/api/models/Player.py | 10 +- src/api/player_api.py | 6 +- src/api/stats_api.py | 2 +- src/chat/_avatarWidget.py | 4 +- src/chat/channel_view.py | 4 +- src/chat/chatter_menu.py | 2 +- src/chat/chatter_model.py | 5 +- src/chat/chatter_model_item.py | 4 +- src/downloadManager/__init__.py | 35 ++- src/playercard/playerinfodialog.py | 60 ++++- src/stats/itemviews/leaderboardtablemenu.py | 2 +- 15 files changed, 301 insertions(+), 109 deletions(-) create mode 100644 src/api/models/Avatar.py create mode 100644 src/api/models/AvatarAssignment.py diff --git a/res/player_card/playercard.ui b/res/player_card/playercard.ui index e707e7c7a..8fd81730d 100644 --- a/res/player_card/playercard.ui +++ b/res/player_card/playercard.ui @@ -7,7 +7,7 @@ 0 0 843 - 673 + 700 @@ -34,118 +34,207 @@ 0 + + + 0 + 0 + + General + + + + + + Qt::Horizontal + + + + + + + + + Rating Type + + + + + + + + + + + + + + + 0 + 0 + + + + + + + 0 + 0 + + + + + 16777215 + 120 + + QFrame::NoFrame - - - - - + + + + 0 + - - - Qt::Horizontal + + + true + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + Name: + + + + + + + + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + Id: + + + + + + + + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + Last login: + + + + + + + Registered: + + + + + + + + 0 + 0 + + + + + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + + + + + + + Qt::LinksAccessibleByMouse|Qt::TextSelectableByMouse + + + + - + - + - Rating Type + Avatars assigned: - + + + + 0 + 0 + + + + + 40 + 20 + + + + QListView::TopToBottom + + - - - - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - Name: - - - - - - - - - - - - - - Id: - - - - - - - - - - - - - - Last login: - - - - - - - - - - - - - - Registered: - - - - - - - - - - - - - + + + diff --git a/src/api/models/Avatar.py b/src/api/models/Avatar.py new file mode 100644 index 000000000..3ecaa711d --- /dev/null +++ b/src/api/models/Avatar.py @@ -0,0 +1,7 @@ +from api.models.AbstractEntity import AbstractEntity + + +class Avatar(AbstractEntity): + filename: str + tooltip: str + url: str diff --git a/src/api/models/AvatarAssignment.py b/src/api/models/AvatarAssignment.py new file mode 100644 index 000000000..14386606f --- /dev/null +++ b/src/api/models/AvatarAssignment.py @@ -0,0 +1,10 @@ +from api.models.AbstractEntity import AbstractEntity +from api.models.Avatar import Avatar +from pydantic import Field + + +class AvatarAssignment(AbstractEntity): + expires_at: str | None = Field(alias="expiresAt") + selected: bool + + avatar: Avatar = Field(None) diff --git a/src/api/models/LeaderboardRating.py b/src/api/models/LeaderboardRating.py index 952c21328..2a960b494 100644 --- a/src/api/models/LeaderboardRating.py +++ b/src/api/models/LeaderboardRating.py @@ -1,6 +1,5 @@ from api.models.AbstractEntity import AbstractEntity from api.models.Leaderboard import Leaderboard -from api.models.Player import Player from pydantic import Field @@ -12,4 +11,3 @@ class LeaderboardRating(AbstractEntity): won_games: int = Field(alias="wonGames") leaderboard: Leaderboard | None = Field(None) - player: Player | None = Field(None) diff --git a/src/api/models/Player.py b/src/api/models/Player.py index 18001ed06..defab8132 100644 --- a/src/api/models/Player.py +++ b/src/api/models/Player.py @@ -1,10 +1,12 @@ from __future__ import annotations -from pydantic import Field - from api.models.AbstractEntity import AbstractEntity +from api.models.AvatarAssignment import AvatarAssignment +from pydantic import Field class Player(AbstractEntity): - login: str - user_agent: str | None = Field(alias="userAgent") + login: str + user_agent: str | None = Field(alias="userAgent") + + avatar_assignments: list[AvatarAssignment] | None = Field(None, alias="avatarAssignments") diff --git a/src/api/player_api.py b/src/api/player_api.py index 00fc9e8ce..312ca5110 100644 --- a/src/api/player_api.py +++ b/src/api/player_api.py @@ -30,7 +30,11 @@ def handleDataForAliasViewer(self, message: dict) -> None: self.alias_info.emit(message) def request_player(self, player_id: str) -> None: - self.get_by_query({"filter": f"id=={player_id}"}, self.handle_player) + query = { + "include": "avatarAssignments.avatar", + "filter": f"id=={player_id}", + } + self.get_by_query(query, self.handle_player) def handle_player(self, message: dict) -> None: player, = message["data"] diff --git a/src/api/stats_api.py b/src/api/stats_api.py index 11a0d3329..a744c47eb 100644 --- a/src/api/stats_api.py +++ b/src/api/stats_api.py @@ -23,7 +23,7 @@ def __init__(self) -> None: def get_player_ratings(self, pid: str) -> None: query = { - "include": "player,leaderboard", + "include": "leaderboard", "filter": f"player.id=={pid}", } self.get_by_query(query, self.handle_player_ratings) diff --git a/src/chat/_avatarWidget.py b/src/chat/_avatarWidget.py index 9e9e61dd1..486339d34 100644 --- a/src/chat/_avatarWidget.py +++ b/src/chat/_avatarWidget.py @@ -1,5 +1,6 @@ from PyQt6.QtCore import QObject from PyQt6.QtCore import QSize +from PyQt6.QtCore import QUrl from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QListWidgetItem from PyQt6.QtWidgets import QPushButton @@ -68,7 +69,8 @@ def set_avatar_list(self, avatars): for avatar in avatars: self._add_avatar_item(avatar) url = avatar["url"] - icon = self._avatar_dler.avatars.get(url, None) + avatar_name = QUrl(url).fileName() + icon = self._avatar_dler.avatars.get(avatar_name, None) if icon is not None: self._set_avatar_icon(url, icon) else: diff --git a/src/chat/channel_view.py b/src/chat/channel_view.py index 366bdeefe..02b3eccdd 100644 --- a/src/chat/channel_view.py +++ b/src/chat/channel_view.py @@ -3,6 +3,7 @@ import jinja2 from PyQt6.QtCore import QObject +from PyQt6.QtCore import QUrl from PyQt6.QtCore import pyqtSignal from PyQt6.QtGui import QDesktopServices @@ -175,7 +176,8 @@ def build(cls, widget, avatar_dler, **kwargs): return cls(widget, avatar_dler) def add_avatar(self, url): - avatar_pix = self._avatar_dler.avatars.get(url, None) + avatar_name = QUrl(url).fileName() + avatar_pix = self._avatar_dler.avatars.get(avatar_name, None) if avatar_pix is not None: self._add_avatar_resource(url, avatar_pix) elif url not in self._requests: diff --git a/src/chat/chatter_menu.py b/src/chat/chatter_menu.py index 3654da485..d59025a73 100644 --- a/src/chat/chatter_menu.py +++ b/src/chat/chatter_menu.py @@ -238,5 +238,5 @@ def _view_aliases(self, chatter): self._alias_viewer.view_aliases(chatter.name) def _show_user_info(self, player: Player) -> None: - dialog = PlayerInfoDialog(str(player.id)) + dialog = PlayerInfoDialog(self._client_window, str(player.id)) dialog.run() diff --git a/src/chat/chatter_model.py b/src/chat/chatter_model.py index 88262a380..cc27ce78f 100644 --- a/src/chat/chatter_model.py +++ b/src/chat/chatter_model.py @@ -220,11 +220,12 @@ def chatter_rank(self, data): def chatter_avatar_icon(self, data): avatar_url = data.avatar_url() + avatar_name = QtCore.QUrl(avatar_url).fileName() if avatar_url is None: return None - if avatar_url not in self._avatars.avatars: + if avatar_name not in self._avatars.avatars: return - return QIcon(self._avatars.avatars[avatar_url]) + return QIcon(self._avatars.avatars[avatar_name]) def chatter_country(self, data): if data.player is None: diff --git a/src/chat/chatter_model_item.py b/src/chat/chatter_model_item.py index 1cbd44ddf..ac2b7040d 100644 --- a/src/chat/chatter_model_item.py +++ b/src/chat/chatter_model_item.py @@ -1,6 +1,7 @@ from urllib import parse from PyQt6.QtCore import QObject +from PyQt6.QtCore import QUrl from PyQt6.QtCore import pyqtSignal from downloadManager import DownloadRequest @@ -125,9 +126,10 @@ def map_name(self): def _download_avatar_if_needed(self): avatar_url = self.avatar_url() + avatar_name = QUrl(avatar_url).fileName() if avatar_url is None: return - if avatar_url in self._avatar_dler.avatars: + if avatar_name in self._avatar_dler.avatars: return self._avatar_dler.download_avatar(avatar_url, self._avatar_request) diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 22daafad8..479452ebe 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -5,7 +5,7 @@ import zipfile from io import BytesIO -from PyQt6 import QtGui +from PyQt6.QtCore import QByteArray from PyQt6.QtCore import QEventLoop from PyQt6.QtCore import QFile from PyQt6.QtCore import QIODevice @@ -13,11 +13,14 @@ from PyQt6.QtCore import QTimer from PyQt6.QtCore import QUrl from PyQt6.QtCore import pyqtSignal +from PyQt6.QtGui import QPixmap from PyQt6.QtNetwork import QNetworkAccessManager from PyQt6.QtNetwork import QNetworkReply from PyQt6.QtNetwork import QNetworkRequest from config import Settings +from util import CACHE_DIR +from util.qt import qopen logger = logging.getLogger(__name__) @@ -420,6 +423,13 @@ def __init__(self): self._requests = {} self.avatars = {} self._nam.finished.connect(self._avatar_download_finished) + self.cache_dir = os.path.join(CACHE_DIR, "avatars") + self.load_cache() + + def load_cache(self) -> None: + for filename in os.listdir(self.cache_dir): + filepath = os.path.join(self.cache_dir, filename) + self.avatars[filename] = QPixmap(filepath) def download_avatar(self, url, req): self._add_request(url, req) @@ -430,13 +440,20 @@ def _add_request(self, url, req): if should_download: self._nam.get(QNetworkRequest(QUrl(url))) - def _avatar_download_finished(self, reply): - img = QtGui.QImage() - img.loadFromData(reply.readAll()) - url = reply.url().toString() - if url not in self.avatars: - self.avatars[url] = QtGui.QPixmap(img) + def _avatar_download_finished(self, reply: QNetworkReply) -> None: + url_str = reply.url().toString() + avatar_name = reply.url().fileName() + avatar_path = self._save_avatar_to_cache(avatar_name, reply.readAll()) + + if avatar_name not in self.avatars: + self.avatars[avatar_name] = QPixmap(avatar_path) - reqs = self._requests.pop(url, []) + reqs = self._requests.pop(url_str, []) for req in reqs: - req.finished(url, self.avatars[url]) + req.finished(url_str, self.avatars[avatar_name]) + + def _save_avatar_to_cache(self, name: str, qbytes: QByteArray) -> str: + filepath = os.path.join(self.cache_dir, name) + with qopen(filepath, QFile.OpenModeFlag.WriteOnly) as file: + file.write(qbytes.data()) + return filepath diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 8dda0df3c..25fc27f79 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -1,13 +1,22 @@ +from __future__ import annotations + from bisect import bisect_left +from typing import TYPE_CHECKING import pyqtgraph as pg from PyQt6.QtCore import QDateTime from PyQt6.QtCore import QPointF from PyQt6.QtCore import Qt from PyQt6.QtGui import QColor +from PyQt6.QtGui import QIcon +from PyQt6.QtGui import QPixmap +from PyQt6.QtWidgets import QListWidget +from PyQt6.QtWidgets import QListWidgetItem from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem import util +from api.models.Avatar import Avatar +from api.models.AvatarAssignment import AvatarAssignment from api.models.Leaderboard import Leaderboard from api.models.LeaderboardRating import LeaderboardRating from api.models.LeaderboardRatingJournal import LeaderboardRatingJournal @@ -17,8 +26,13 @@ from api.stats_api import LeaderboardRatingApiConnector from api.stats_api import LeaderboardRatingJournalApiConnector from api.stats_api import LeagueSeasonScoreApiConnector +from downloadManager import AvatarDownloader +from downloadManager import DownloadRequest from src.playercard.leagueformatter import LegueFormatter +if TYPE_CHECKING: + from client._clientwindow import ClientWindow + FormClass, BaseClass = util.THEME.loadUiType("player_card/playercard.ui") @@ -140,11 +154,13 @@ def show_at_pos(self, pos: QPointF) -> None: class PlayerInfoDialog(FormClass, BaseClass): - def __init__(self, player_id: str) -> None: + def __init__(self, client_window: ClientWindow, player_id: str) -> None: BaseClass.__init__(self) self.setupUi(self) self.load_stylesheet() + self.avatar_handler = AvatarHandler(self.avatarList, client_window.avatar_downloader) + self.player_id = player_id self.player_api = PlayerApiConnector() @@ -220,3 +236,45 @@ def process_player(self, player: Player) -> None: self.registeredLabel.setText(registered.toString("yyyy-MM-dd hh:mm")) last_login = QDateTime.fromString(player.update_time, Qt.DateFormat.ISODate).toLocalTime() self.lastLoginLabel.setText(last_login.toString("yyyy-MM-dd hh:mm")) + self.add_avatars(player.avatar_assignments) + + def add_avatars(self, avatar_assignments: list[AvatarAssignment] | None) -> None: + self.avatar_handler.populate_avatars(avatar_assignments) + + +class AvatarHandler: + def __init__(self, avatar_list: QListWidget, avatar_downloader: AvatarDownloader) -> None: + self.avatar_list = avatar_list + self.avatar_dler = avatar_downloader + self.requests = {} + + def populate_avatars(self, avatar_assignments: list[AvatarAssignment] | None) -> None: + if avatar_assignments is None: + return + + for assignment in avatar_assignments: + pix = self.avatar_dler.avatars.get(assignment.avatar.filename, None) + if pix is None: + self._download_avatar(assignment.avatar) + else: + self._add_avatar_item(pix, assignment.avatar.tooltip) + + def _prepare_avatar_dl_request(self, avatar: Avatar) -> DownloadRequest: + req = DownloadRequest() + req.done.connect(self._handle_avatar_download) + self.requests[avatar.url] = (req, avatar.tooltip) + return req + + def _download_avatar(self, avatar: Avatar) -> None: + req = self._prepare_avatar_dl_request(avatar) + self.avatar_dler.download_avatar(avatar.url, req) + + def _add_avatar_item(self, pixmap: QPixmap, description: str) -> None: + icon = QIcon(pixmap.scaled(40, 20)) + avatar_item = QListWidgetItem(icon, description) + self.avatar_list.addItem(avatar_item) + + def _handle_avatar_download(self, url: str, pixmap: QPixmap) -> None: + _, tooltip = self.requests[url] + self._add_avatar_item(pixmap, tooltip) + del self.requests[url] diff --git a/src/stats/itemviews/leaderboardtablemenu.py b/src/stats/itemviews/leaderboardtablemenu.py index 432bd1e7f..3b717f6d6 100644 --- a/src/stats/itemviews/leaderboardtablemenu.py +++ b/src/stats/itemviews/leaderboardtablemenu.py @@ -115,5 +115,5 @@ def handleFriends(self, uid, kind): ctl.foes.remove(uid) def show_user_info(self, uid: str) -> None: - dialog = PlayerInfoDialog(uid) + dialog = PlayerInfoDialog(self.client, uid) dialog.run() From 05c8f4e75c1b2a5f7a71051033fc2225deac355f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 19 Jun 2024 02:34:52 +0300 Subject: [PATCH 11/73] Fix type annotation (i mean, there are probably lots of them, but this one triggered me for some reason) --- src/playercard/playerinfodialog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 25fc27f79..7c93882b3 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -1,6 +1,7 @@ from __future__ import annotations from bisect import bisect_left +from collections.abc import Sequence from typing import TYPE_CHECKING import pyqtgraph as pg @@ -80,7 +81,7 @@ def change_visibility(self) -> None: def is_visible(self) -> bool: return self._visible - def _closest_index(self, lst: list[float], value: float) -> int: + def _closest_index(self, lst: Sequence[float | int], value: float | int) -> int: pos = bisect_left(lst, value) if pos == 0: return pos From b1665f0c3ef5b18f9a3f427e472f30e1504d85b2 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 20 Jun 2024 03:49:57 +0300 Subject: [PATCH 12/73] Cache avatars with percent encoded spaces because java client does it, thus we don't redownload already downloaded avatars with spaces in names --- src/chat/chatter_model_item.py | 11 ++--------- src/downloadManager/__init__.py | 16 +++++++++++++++- src/playercard/playerinfodialog.py | 10 ++++++---- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/chat/chatter_model_item.py b/src/chat/chatter_model_item.py index ac2b7040d..3e980061c 100644 --- a/src/chat/chatter_model_item.py +++ b/src/chat/chatter_model_item.py @@ -1,7 +1,6 @@ from urllib import parse from PyQt6.QtCore import QObject -from PyQt6.QtCore import QUrl from PyQt6.QtCore import pyqtSignal from downloadManager import DownloadRequest @@ -41,7 +40,7 @@ def __init__(self, cc, map_preview_dler, avatar_dler, relation_trackers): @classmethod def builder( - cls, map_preview_dler, avatar_dler, relation_trackers, **kwargs + cls, map_preview_dler, avatar_dler, relation_trackers, **kwargs, ): def make(cc): return cls(cc, map_preview_dler, avatar_dler, relation_trackers) @@ -125,13 +124,7 @@ def map_name(self): return self.game.mapname.lower() def _download_avatar_if_needed(self): - avatar_url = self.avatar_url() - avatar_name = QUrl(avatar_url).fileName() - if avatar_url is None: - return - if avatar_name in self._avatar_dler.avatars: - return - self._avatar_dler.download_avatar(avatar_url, self._avatar_request) + self._avatar_dler.download_if_needed(self.avatar_url(), self._avatar_request) def avatar_url(self): try: diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 479452ebe..40d122848 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -431,6 +431,20 @@ def load_cache(self) -> None: filepath = os.path.join(self.cache_dir, filename) self.avatars[filename] = QPixmap(filepath) + def avatar_name(self, url: QUrl | str) -> str: + return QUrl(url).fileName(QUrl.ComponentFormattingOption.EncodeSpaces) + + def has_avatar(self, name_or_url: str) -> bool: + return self.get_avatar(name_or_url) is not None + + def get_avatar(self, name_or_url: str) -> QPixmap: + return self.avatars.get(self.avatar_name(name_or_url), None) + + def download_if_needed(self, url: str | None, req: DownloadRequest) -> None: + if url is None or self.has_avatar(url): + return + self.download_avatar(url, req) + def download_avatar(self, url, req): self._add_request(url, req) @@ -442,7 +456,7 @@ def _add_request(self, url, req): def _avatar_download_finished(self, reply: QNetworkReply) -> None: url_str = reply.url().toString() - avatar_name = reply.url().fileName() + avatar_name = self.avatar_name(reply.url()) avatar_path = self._save_avatar_to_cache(avatar_name, reply.readAll()) if avatar_name not in self.avatars: diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 7c93882b3..ef34541e0 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -254,11 +254,10 @@ def populate_avatars(self, avatar_assignments: list[AvatarAssignment] | None) -> return for assignment in avatar_assignments: - pix = self.avatar_dler.avatars.get(assignment.avatar.filename, None) - if pix is None: - self._download_avatar(assignment.avatar) + if self.avatar_dler.has_avatar(assignment.avatar.filename): + self._add_avatar(assignment.avatar) else: - self._add_avatar_item(pix, assignment.avatar.tooltip) + self._download_avatar(assignment.avatar) def _prepare_avatar_dl_request(self, avatar: Avatar) -> DownloadRequest: req = DownloadRequest() @@ -270,6 +269,9 @@ def _download_avatar(self, avatar: Avatar) -> None: req = self._prepare_avatar_dl_request(avatar) self.avatar_dler.download_avatar(avatar.url, req) + def _add_avatar(self, avatar: Avatar) -> None: + self._add_avatar_item(self.avatar_dler.get_avatar(avatar.filename), avatar.tooltip) + def _add_avatar_item(self, pixmap: QPixmap, description: str) -> None: icon = QIcon(pixmap.scaled(40, 20)) avatar_item = QListWidgetItem(icon, description) From ec3eaee54a8844e61c5d8f6a9afd049b502dee9a Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 21 Jun 2024 23:10:42 +0300 Subject: [PATCH 13/73] pre-commit autoupdate --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9a5293d5e..e67f9adc0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,7 +10,7 @@ repos: hooks: - id: add-trailing-comma - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 + rev: 7.1.0 hooks: - id: flake8 - repo: https://github.com/PyCQA/isort From 8033bc44198843fee5325bf334bbbff8ad5a5881 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 21 Jun 2024 23:19:17 +0300 Subject: [PATCH 14/73] Display rating plots in QTabWidget this automatically 'caches' fetched rating history for different leaderboards because each tab has its own data and plot and it's also more convenient than combo box --- res/player_card/playercard.ui | 58 +++------ src/playercard/playerinfodialog.py | 185 ++++++++++++++++++++--------- 2 files changed, 144 insertions(+), 99 deletions(-) diff --git a/res/player_card/playercard.ui b/res/player_card/playercard.ui index 8fd81730d..31c03b96a 100644 --- a/res/player_card/playercard.ui +++ b/res/player_card/playercard.ui @@ -35,7 +35,7 @@ - + 0 0 @@ -44,41 +44,6 @@ General - - - - - - Qt::Horizontal - - - - - - - - - Rating Type - - - - - - - - - - - - - - - 0 - 0 - - - - @@ -232,9 +197,23 @@ + + + + -1 + + + + + + + Qt::Horizontal + + + @@ -243,13 +222,6 @@ - - - PlotWidget - QGraphicsView -
pyqtgraph
-
-
diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index ef34541e0..ad1073301 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -13,6 +13,7 @@ from PyQt6.QtGui import QPixmap from PyQt6.QtWidgets import QListWidget from PyQt6.QtWidgets import QListWidgetItem +from PyQt6.QtWidgets import QTabWidget from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem import util @@ -36,14 +37,15 @@ FormClass, BaseClass = util.THEME.loadUiType("player_card/playercard.ui") +Numeric = float | int + class Crosshairs: - def __init__(self, plotwidget: pg.PlotWidget, xseries: list[int], yseries: list[float]) -> None: + def __init__(self, plotwidget: pg.PlotWidget, series: LineSeries) -> None: self.plotwidget = plotwidget self.plotwidget.scene().sigMouseMoved.connect(self.update_lines_and_text) - self.xseries = xseries - self.yseries = yseries + self.series = series pen = pg.mkPen("green", width=3) self.xLine = pg.InfiniteLine(angle=90, pen=pen) @@ -62,12 +64,19 @@ def __init__(self, plotwidget: pg.PlotWidget, xseries: list[int], yseries: list[ self.plotwidget.plotItem.getAxis("left").setWidth(40) self._visible = True + def set_series(self, series: LineSeries) -> None: + self.series = series + def set_visible(self, visible: bool) -> None: self.xLine.setVisible(visible) self.yLine.setVisible(visible) self.xText.setVisible(visible) self.yText.setVisible(visible) - self._visible = visible + + def display(self, *, seen: bool) -> None: + if not self._visible: + return + self.set_visible(seen) def hide(self) -> None: self.set_visible(False) @@ -76,12 +85,14 @@ def show(self) -> None: self.set_visible(True) def change_visibility(self) -> None: - self.set_visible(not self._visible) + new_state = not self._visible + self.set_visible(new_state) + self._visible = new_state def is_visible(self) -> bool: return self._visible - def _closest_index(self, lst: Sequence[float | int], value: float | int) -> int: + def _closest_index(self, lst: Sequence[Numeric], value: Numeric) -> int: pos = bisect_left(lst, value) if pos == 0: return pos @@ -98,8 +109,8 @@ def _closest_index(self, lst: Sequence[float | int], value: float | int) -> int: def map_to_data(self, pos: QPointF) -> QPointF: view = self.plotwidget.plotItem.getViewBox() value_pos = view.mapSceneToView(pos) - point_index = self._closest_index(self.xseries, value_pos.x()) - return QPointF(self.xseries[point_index], self.yseries[point_index]) + point_index = self._closest_index(self.series.x(), value_pos.x()) + return self.series.point_at(point_index) def get_xtext_pos(self, scene_point: QPointF) -> tuple[float, float]: scene_width = self.plotwidget.sceneBoundingRect().width() @@ -122,7 +133,7 @@ def get_ytext_pos(self, scene_point: QPointF) -> tuple[float, float]: return x, y def update_lines_and_text(self, pos: QPointF) -> None: - if not self.xseries: + if not self.series.x(): return data_point = self.map_to_data(pos) @@ -151,7 +162,7 @@ def update_lines(self, pos: QPointF, data_point: QPointF) -> None: def show_at_pos(self, pos: QPointF) -> None: seen = self.plotwidget.sceneBoundingRect().contains(pos) - self.set_visible(seen) + self.display(seen=seen) class PlayerInfoDialog(FormClass, BaseClass): @@ -160,6 +171,7 @@ def __init__(self, client_window: ClientWindow, player_id: str) -> None: self.setupUi(self) self.load_stylesheet() + self.tab_widget_ctrl = RatingTabWidgetController(player_id, self.tabWidget) self.avatar_handler = AvatarHandler(self.avatarList, client_window.avatar_downloader) self.player_id = player_id @@ -167,18 +179,6 @@ def __init__(self, client_window: ClientWindow, player_id: str) -> None: self.player_api = PlayerApiConnector() self.player_api.player_ready.connect(self.process_player) - self.leaderboards_api = LeaderboardApiConnector() - self.leaderboards_api.data_ready.connect(self.populate_leaderboards) - - self.ratings_history_api = LeaderboardRatingJournalApiConnector() - self.ratings_history_api.ratings_ready.connect(self.process_rating_history) - - self.plotWidget.setBackground("#202025") - self.plotWidget.setAxisItems({"bottom": DateAxisItem()}) - - self.ratingComboBox.currentTextChanged.connect(self.get_ratings) - self.crosshairs = None - self.leagues_api = LeagueSeasonScoreApiConnector() self.ratings_api = LeaderboardRatingApiConnector() @@ -189,43 +189,10 @@ def load_stylesheet(self) -> None: self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) def run(self) -> None: - self.leaderboards_api.requestData() self.player_api.request_player(self.player_id) + self.tab_widget_ctrl.run() self.exec() - def populate_leaderboards(self, message: dict[str, list[Leaderboard]]) -> None: - for leaderboard in message["values"]: - self.ratingComboBox.addItem(leaderboard.technical_name) - - def clear_graphics(self) -> None: - self.plotWidget.clear() - if self.crosshairs is not None: - self.crosshairs.hide() - self.crosshairs = None - - def get_ratings(self, leaderboard_name: str) -> None: - self.ratingComboBox.setEnabled(False) - self.ratings_history_api.get_full_history(self.player_id, leaderboard_name) - - def get_chart_series(self, ratings: list[LeaderboardRatingJournal]) -> tuple[list, list]: - xvals, yvals = [], [] - for entry in ratings: - assert entry.player_stats is not None - score_time = QDateTime.fromString(entry.player_stats.score_time, Qt.DateFormat.ISODate) - xvals.append(score_time.toSecsSinceEpoch()) - yvals.append(entry.mean_after - 3 * entry.deviation_after) - return xvals, yvals - - def draw_ratings(self, ratings: tuple[list, list]) -> None: - self.plotWidget.plot(*ratings, pen=pg.mkPen("orange")) - self.crosshairs = Crosshairs(self.plotWidget, *ratings) - self.plotWidget.autoRange() - - def process_rating_history(self, ratings: dict[str, list[LeaderboardRatingJournal]]) -> None: - self.clear_graphics() - self.draw_ratings(self.get_chart_series(ratings["values"])) - self.ratingComboBox.setEnabled(True) - def process_player_ratings(self, ratings: dict[str, list[LeaderboardRating]]) -> None: for rating in ratings["values"]: self.leaguesLayout.addWidget(LegueFormatter(self.player_id, rating, self.leagues_api)) @@ -281,3 +248,109 @@ def _handle_avatar_download(self, url: str, pixmap: QPixmap) -> None: _, tooltip = self.requests[url] self._add_avatar_item(pixmap, tooltip) del self.requests[url] + + +class LineSeries: + def __init__(self) -> None: + self._x: list[Numeric] = [] + self._y: list[Numeric] = [] + + def x(self) -> list[Numeric]: + return self._x + + def y(self) -> list[Numeric]: + return self._y + + def append(self, x: Numeric, y: Numeric) -> None: + self._x.append(x) + self._y.append(y) + + def point_at(self, index: int) -> QPointF: + return QPointF(self._x[index], self._y[index]) + + +class RatingsPlotTab: + def __init__(self, player_id: str, leaderboard: Leaderboard, plot: PlotController) -> None: + self.player_id = player_id + self.leaderboard = leaderboard + self.ratings_history_api = LeaderboardRatingJournalApiConnector() + self.ratings_history_api.ratings_ready.connect(self.process_rating_history) + self.plot = plot + self._loaded = False + + def enter(self) -> None: + if self._loaded: + return + self.ratings_history_api.get_full_history(self.player_id, self.leaderboard.technical_name) + + def get_plot_series(self, ratings: list[LeaderboardRatingJournal]) -> LineSeries: + series = LineSeries() + for entry in ratings: + assert entry.player_stats is not None + score_time = QDateTime.fromString(entry.player_stats.score_time, Qt.DateFormat.ISODate) + series.append( + score_time.toSecsSinceEpoch(), + entry.mean_after - 3 * entry.deviation_after, + ) + return series + + def process_rating_history(self, ratings: dict[str, list[LeaderboardRatingJournal]]) -> None: + self.plot.draw_series(self.get_plot_series(ratings["values"])) + self._loaded = True + + +class RatingTabWidgetController: + def __init__(self, player_id: str, tab_widget: QTabWidget) -> None: + self.player_id = player_id + self.widget = tab_widget + self.widget.currentChanged.connect(self.on_tab_changed) + + self.leaderboards_api = LeaderboardApiConnector() + self.leaderboards_api.data_ready.connect(self.populate_leaderboards) + self.tabs: dict[int, RatingsPlotTab] = {} + + def run(self) -> None: + self.leaderboards_api.requestData() + + def populate_leaderboards(self, message: dict[str, list[Leaderboard]]) -> None: + for index, leaderboard in enumerate(message["values"]): + widget = pg.PlotWidget() + tab = RatingsPlotTab(self.player_id, leaderboard, PlotController(widget)) + self.tabs[index] = tab + self.widget.insertTab(index, widget, leaderboard.technical_name) + + def on_tab_changed(self, index: int) -> None: + self.tabs[index].enter() + + +class PlotController: + def __init__(self, widget: pg.PlotWidget) -> None: + self.widget = widget + self.widget.setBackground("#202025") + self.widget.setAxisItems({"bottom": DateAxisItem()}) + self.crosshairs = Crosshairs(self.widget, LineSeries()) + self.hide_irrelevant_plot_actions() + self.add_custom_menu_actions() + + def clear(self) -> None: + self.widget.clear() + + def draw_series(self, series: LineSeries) -> None: + self.widget.plot(series.x(), series.y(), pen=pg.mkPen("orange")) + self.crosshairs.set_series(series) + self.widget.autoRange() + + def hide_irrelevant_plot_actions(self) -> None: + for action in ("Transforms", "Downsample", "Average", "Alpha", "Points"): + self.widget.plotItem.setContextMenuActionVisible(action, visible=False) + + def add_custom_menu_actions(self) -> None: + viewbox = self.widget.plotItem.getViewBox() + if viewbox is None: + return + + menu = viewbox.getMenu(ev=None) + if menu is None: + return + + menu.addAction("Show/Hide crosshair", self.crosshairs.change_visibility) From cfad6cfeb98e5828433b7000dfd25374b719a0ea Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 22 Jun 2024 01:27:46 +0300 Subject: [PATCH 15/73] Add stylings to common QTabWidget controls and to line --- res/client/client.css | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/res/client/client.css b/res/client/client.css index 30880b6fb..a1d895fa7 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -100,6 +100,18 @@ QTabWidget > QTabBar::tab min-width: 80px; } +QTabWidget > QTabBar::tab::hover +{ + color: silver; + background-color: #808080; +} + +QTabWidget > QTabBar::tab::selected +{ + color: white; + background-color: #758fa0; +} + QTabWidget#matchmakerQueues::pane { background-color: none; @@ -930,3 +942,9 @@ QFrame#settingsFrame { border-bottom-left-radius : 5px; border-bottom-right-radius : 5px; } + + +#line +{ + background-color: #202025; +} From b6538c45d2313e40616db81329ff00aa6f47e4d0 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 23 Jun 2024 15:06:16 +0300 Subject: [PATCH 16/73] Display leaderboards in more logical order... ... and prettify their display names --- src/api/models/Leaderboard.py | 28 ++++++++ src/api/parsers/LeaderboardParser.py | 7 +- src/api/parsers/LeaderboardRatingParser.py | 5 +- src/client/_clientwindow.py | 1 + src/playercard/leagueformatter.py | 15 ++-- src/playercard/playerinfodialog.py | 2 +- src/replays/_replayswidget.py | 81 +++++++++++++--------- src/stats/_statswidget.py | 10 +-- 8 files changed, 103 insertions(+), 46 deletions(-) diff --git a/src/api/models/Leaderboard.py b/src/api/models/Leaderboard.py index 5f539e45b..eed847185 100644 --- a/src/api/models/Leaderboard.py +++ b/src/api/models/Leaderboard.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from api.models.AbstractEntity import AbstractEntity from pydantic import Field @@ -6,3 +8,29 @@ class Leaderboard(AbstractEntity): description: str = Field(alias="descriptionKey") name: str = Field(alias="nameKey") technical_name: str = Field(alias="technicalName") + + @property + def pretty_name(self) -> str: + return self._pretty_names().get(self.technical_name, self.technical_name) + + def _pretty_names(self) -> dict[str, str]: + return { + "global": "Global", + "ladder_1v1": "Ladder", + "tmm_2v2": "2v2", + "tmm_3v3": "3v3", + "tmm_4v4_full_share": "4v4 (Full Share)", + "tmm_4v4_share_until_death": "4v4 (No Share)", + } + + def order(self) -> int: + try: + return list(self._pretty_names()).index(self.technical_name) + except ValueError: + return 0 + + def __lt__(self, other: Leaderboard) -> bool: + return self.order() < other.order() + + def __ge__(self, other: Leaderboard) -> bool: + return not self.__lt__(other) diff --git a/src/api/parsers/LeaderboardParser.py b/src/api/parsers/LeaderboardParser.py index 862cb9476..9cbb0f6fc 100644 --- a/src/api/parsers/LeaderboardParser.py +++ b/src/api/parsers/LeaderboardParser.py @@ -1,3 +1,5 @@ +from operator import methodcaller + from api.models.Leaderboard import Leaderboard @@ -9,4 +11,7 @@ def parse(api_result: dict) -> Leaderboard: @staticmethod def parse_many(api_result: dict) -> list[Leaderboard]: - return [LeaderboardParser.parse(entry) for entry in api_result["data"]] + return sorted( + [LeaderboardParser.parse(entry) for entry in api_result["data"]], + key=methodcaller("order"), + ) diff --git a/src/api/parsers/LeaderboardRatingParser.py b/src/api/parsers/LeaderboardRatingParser.py index 03ffcaa54..2ab1200cb 100644 --- a/src/api/parsers/LeaderboardRatingParser.py +++ b/src/api/parsers/LeaderboardRatingParser.py @@ -9,4 +9,7 @@ def parse(api_result: dict) -> LeaderboardRating: @staticmethod def parse_many(api_result: dict) -> list[LeaderboardRating]: - return [LeaderboardRatingParser.parse(entry) for entry in api_result["data"]] + return sorted( + [LeaderboardRatingParser.parse(entry) for entry in api_result["data"]], + key=lambda rating: rating.leaderboard.order() if rating.leaderboard is not None else 0, + ) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 505bb9b4d..2c6245892 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -1549,6 +1549,7 @@ def on_widget_login_data(self, api_changed): self._chatMVC.connection.setPortFromConfig() if api_changed: self.ladder.refreshLeaderboards() + self.replays.refresh_leaderboards() self.games.refreshMods() self.oauth_flow.setup_credentials() diff --git a/src/playercard/leagueformatter.py b/src/playercard/leagueformatter.py index a548301be..234006632 100644 --- a/src/playercard/leagueformatter.py +++ b/src/playercard/leagueformatter.py @@ -32,14 +32,19 @@ def __init__( # chr(0xB1) = +- rating_str = f"{rating.rating:.0f} [{rating.mean:.0f}\xb1{rating.deviation:.0f}]" self.ratingLabel.setText(rating_str) - self.leaderboardLabel.setText(rating.leaderboard.technical_name) + + assert rating.leaderboard is not None + + self.leaderboardLabel.setText(rating.leaderboard.pretty_name) self.league_score_api = league_score_api self.league_score_api.score_ready.connect(self.on_league_score_ready) - self.leaderboard = rating.leaderboard.technical_name - self.league_score_api.get_player_score_in_leaderboard(player_id, self.leaderboard) + self.leaderboard = rating.leaderboard + self.league_score_api.get_player_score_in_leaderboard( + player_id, self.leaderboard.technical_name, + ) - self.leaderboardLabel.setText(self.leaderboard) + self.leaderboardLabel.setText(self.leaderboard.pretty_name) self._downloader = Downloader(os.path.join(util.CACHE_DIR, "divisions")) self._images_dl_request = DownloadRequest() @@ -49,7 +54,7 @@ def load_stylesheet(self) -> None: self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) def on_league_score_ready(self, score: LeagueSeasonScore) -> None: - if score.season.leaderboard.technical_name != self.leaderboard: + if score.season.leaderboard.technical_name != self.leaderboard.technical_name: return if score.score is None: diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index ad1073301..9aa7e6b88 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -317,7 +317,7 @@ def populate_leaderboards(self, message: dict[str, list[Leaderboard]]) -> None: widget = pg.PlotWidget() tab = RatingsPlotTab(self.player_id, leaderboard, PlotController(widget)) self.tabs[index] = tab - self.widget.insertTab(index, widget, leaderboard.technical_name) + self.widget.insertTab(index, widget, leaderboard.pretty_name) def on_tab_changed(self, index: int) -> None: self.tabs[index].enter() diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index 8f48b4d65..7b02ffae4 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -3,7 +3,6 @@ import os import time -from pydantic import ValidationError from PyQt6 import QtCore from PyQt6 import QtGui from PyQt6 import QtWidgets @@ -14,11 +13,14 @@ import client import fa import util +from api.models.Leaderboard import Leaderboard from api.replaysapi import ReplaysApiConnector +from api.stats_api import LeaderboardApiConnector from config import Settings from downloadManager import DownloadRequest from fa.replay import replay from model.game import GameState +from pydantic import ValidationError from replays.models import MetadataModel from replays.replayitem import ReplayItem from replays.replayitem import ReplayItemDelegate @@ -642,6 +644,7 @@ def __init__(self, widget, dispatcher, client, gameset, playerset): self._w = widget self._dispatcher = dispatcher self.client = client + self.client.authorized.connect(self.on_authorized) self._gameset = gameset self._playerset = playerset @@ -649,6 +652,10 @@ def __init__(self, widget, dispatcher, client, gameset, playerset): self.selectedReplay = None self.apiConnector = ReplaysApiConnector() self.apiConnector.data_ready.connect(self.process_replays_data) + + self.leaderboard_api = LeaderboardApiConnector() + self.leaderboard_api.data_ready.connect(self.process_leaderboards) + self.replayDownload = QNetworkAccessManager() self.replayDownload.finished.connect(self.onDownloadFinished) self.toolboxHandler = ReplayToolboxHandler( @@ -696,6 +703,15 @@ def __init__(self, widget, dispatcher, client, gameset, playerset): self.timer = QtCore.QTimer() self.timer.timeout.connect(self.stopSearchVault) + def on_authorized(self) -> None: + if self._w.leaderboardList.count() == 1: + self.refresh_leaderboards() + + def refresh_leaderboards(self) -> None: + while self._w.leaderboardList.count() != 1: + self._w.leaderboardList.removeItem(1) + self.leaderboard_api.requestData() + def showToolTip(self, widget, msg): """ Default tooltips are too slow and disappear when user starts typing @@ -714,14 +730,14 @@ def stopSearchVault(self): def searchVault( self, - minRating=None, - mapName=None, - playerName=None, - leaderboardId=None, - modListIndex=None, - quantity=None, - reset=None, - exactPlayerName=None, + minRating: int | None = None, + mapName: str | None = None, + playerName: str | None = None, + leaderboardListItemIndex: int | None = None, + modListIndex: int | None = None, + quantity: int | None = None, + reset: bool | None = None, + exactPlayerName: bool | None = None, ): w = self._w timePeriod = None @@ -749,8 +765,8 @@ def searchVault( w.mapName.setText(mapName) if playerName is not None: w.playerName.setText(playerName) - if leaderboardId is not None: - w.leaderboardList.setCurrentIndex(leaderboardId) + if leaderboardListItemIndex is not None: + w.leaderboardList.setCurrentIndex(leaderboardListItemIndex) if modListIndex is not None: w.modList.setCurrentIndex(modListIndex) if quantity is not None: @@ -768,7 +784,7 @@ def searchVault( w.minRating.value(), w.mapName.text(), w.playerName.text(), - w.leaderboardList.currentIndex(), + w.leaderboardList.currentData(), w.modList.currentText(), timePeriod, exactPlayerName, @@ -792,13 +808,13 @@ def searchVault( def prepareFilters( self, - minRating, - mapName, - playerName, - leaderboardId, - modListIndex, - timePeriod=None, - exactPlayerName=None, + minRating: int | None, + mapName: str | None, + playerName: str | None, + leaderboardName: str | None, + modListIndex: int | None, + timePeriod: list[str] | None = None, + exactPlayerName: bool | None = None, ): ''' Making filter string here + some logic to exclude "heavy" requests @@ -811,10 +827,10 @@ def prepareFilters( if self.hide_unranked: filters.append('validity=="VALID"') - if leaderboardId: + if leaderboardName not in (None, "All"): filters.append( - 'playerStats.ratingChanges.leaderboard.id=="{}"' - .format(leaderboardId), + 'playerStats.ratingChanges.leaderboard.technicalName=="{}"' + .format(leaderboardName), ) if minRating and minRating > 0: @@ -1022,6 +1038,10 @@ def process_replays_data(self, message: dict) -> None: "No replays found", ) + def process_leaderboards(self, message: dict[str, list[Leaderboard]]) -> None: + for leaderboard in message["values"]: + self._w.leaderboardList.addItem(leaderboard.pretty_name, leaderboard.technical_name) + def updateOnlineTree(self): self.selectedReplay = None # clear, it won't be part of the new tree self._w.replayInfos.clear() @@ -1068,17 +1088,16 @@ def __init__(self, client, dispatcher, gameset, playerset): logger.info("Replays Widget instantiated.") - def set_player(self, name, leaderboardName=None): + def refresh_leaderboards(self) -> None: + self.vaultManager.refresh_leaderboards() + + def set_player(self, name: str, leaderboard_name: str | None = None) -> None: self.setCurrentIndex(2) # focus on Online Fault - if leaderboardName is not None: - leaderboardId = self.leaderboardList.findText(leaderboardName) - self.vaultManager.searchVault( - 0, "", name, leaderboardId, 0, 100, exactPlayerName=True, - ) + if leaderboard_name is not None: + item_index = self.leaderboardList.findData(leaderboard_name) + self.vaultManager.searchVault(0, "", name, item_index, 0, 100, exactPlayerName=True) else: - self.vaultManager.searchVault( - 0, "", name, 0, 0, 100, exactPlayerName=True, - ) + self.vaultManager.searchVault(0, "", name, 0, 0, 100, exactPlayerName=True) def focusEvent(self, event): self.localManager.updatemyTree() diff --git a/src/stats/_statswidget.py b/src/stats/_statswidget.py index 2265197b3..39f69d8f4 100644 --- a/src/stats/_statswidget.py +++ b/src/stats/_statswidget.py @@ -71,8 +71,6 @@ def onAuthorized(self): self.refreshLeaderboards() def refreshLeaderboards(self): - while self.client.replays.leaderboardList.count() != 1: - self.client.replays.leaderboardList.removeItem(1) self.leaderboards.blockSignals(True) while self.leaderboards.widget(0) is not None: self.leaderboards.widget(0).deleteLater() @@ -244,15 +242,13 @@ def leaderboardsTabChanged(self, curr): def process_leaderboards_info(self, message: dict[str, list[Leaderboard]]) -> None: self.leaderboardNames.clear() - for leaderboard in message["values"]: + for index, leaderboard in enumerate(message["values"]): self.leaderboardNames.append(leaderboard.technical_name) - for index, name in enumerate(self.leaderboardNames): self.leaderboards.insertTab( index, - LeaderboardWidget(self.client, self, name), - name.capitalize().replace("_", " "), + LeaderboardWidget(self.client, self, leaderboard.technical_name), + leaderboard.pretty_name, ) - self.client.replays.leaderboardList.addItem(name) self.leaderboards.setCurrentIndex(1) self.leaderboards.currentChanged.connect(self.leaderboardsTabChanged) From 1bbc9da9a8dcc91b37bf2290840f2b310154f2e2 Mon Sep 17 00:00:00 2001 From: Gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 24 Jun 2024 22:25:07 +0300 Subject: [PATCH 17/73] Create cache dirs for avatars and divisions if they don't exist yet --- src/downloadManager/__init__.py | 4 ++-- src/playercard/leagueformatter.py | 2 +- src/util/__init__.py | 9 ++++++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 40d122848..b62c1bee2 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -19,7 +19,7 @@ from PyQt6.QtNetwork import QNetworkRequest from config import Settings -from util import CACHE_DIR +from util import AVATARS_CACHE_DIR from util.qt import qopen logger = logging.getLogger(__name__) @@ -423,7 +423,7 @@ def __init__(self): self._requests = {} self.avatars = {} self._nam.finished.connect(self._avatar_download_finished) - self.cache_dir = os.path.join(CACHE_DIR, "avatars") + self.cache_dir = AVATARS_CACHE_DIR self.load_cache() def load_cache(self) -> None: diff --git a/src/playercard/leagueformatter.py b/src/playercard/leagueformatter.py index 234006632..d1f86e0ad 100644 --- a/src/playercard/leagueformatter.py +++ b/src/playercard/leagueformatter.py @@ -46,7 +46,7 @@ def __init__( self.leaderboardLabel.setText(self.leaderboard.pretty_name) - self._downloader = Downloader(os.path.join(util.CACHE_DIR, "divisions")) + self._downloader = Downloader(util.DIVISIONS_CACHE_DIR) self._images_dl_request = DownloadRequest() self._images_dl_request.done.connect(self.on_image_downloaded) diff --git a/src/util/__init__.py b/src/util/__init__.py index c00d702a3..62549a053 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -60,6 +60,12 @@ # Cache for news images NEWS_CACHE_DIR = os.path.join(CACHE_DIR, "news") +# Cache for avatar images +AVATARS_CACHE_DIR = os.path.join(CACHE_DIR, "avatars") + +# Cache for league division images +DIVISIONS_CACHE_DIR = os.path.join(CACHE_DIR, "divisions") + # This contains cached game files GAME_CACHE_DIR = os.path.join(CACHE_DIR, "featured_mod") @@ -168,7 +174,8 @@ def setPersonalDir(): APPDATA_DIR, PERSONAL_DIR, LUA_DIR, CACHE_DIR, MAP_PREVIEW_SMALL_DIR, MAP_PREVIEW_LARGE_DIR, MOD_PREVIEW_DIR, THEME_DIR, REPLAY_DIR, LOG_DIR, EXTRA_DIR, NEWS_CACHE_DIR, - GAME_CACHE_DIR, GAMEDATA_DIR, BIN_DIR, REPLAY_DIR, + GAME_CACHE_DIR, GAMEDATA_DIR, BIN_DIR, REPLAY_DIR, AVATARS_CACHE_DIR, + DIVISIONS_CACHE_DIR, ]: if not os.path.isdir(data_dir): os.makedirs(data_dir) From 0d2a1e828d6cd004267a7908e787a4435bb7eb57 Mon Sep 17 00:00:00 2001 From: Gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 25 Jun 2024 05:01:19 +0300 Subject: [PATCH 18/73] Let users now that plot is loading and not stuck --- src/playercard/playerinfodialog.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 9aa7e6b88..7af89e7f0 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -6,8 +6,10 @@ import pyqtgraph as pg from PyQt6.QtCore import QDateTime +from PyQt6.QtCore import QObject from PyQt6.QtCore import QPointF from PyQt6.QtCore import Qt +from PyQt6.QtCore import pyqtSignal from PyQt6.QtGui import QColor from PyQt6.QtGui import QIcon from PyQt6.QtGui import QPixmap @@ -269,8 +271,18 @@ def point_at(self, index: int) -> QPointF: return QPointF(self._x[index], self._y[index]) -class RatingsPlotTab: - def __init__(self, player_id: str, leaderboard: Leaderboard, plot: PlotController) -> None: +class RatingsPlotTab(QObject): + name_changed = pyqtSignal(int, str) + + def __init__( + self, + index: int, + player_id: str, + leaderboard: Leaderboard, + plot: PlotController, + ) -> None: + super().__init__() + self.index = index self.player_id = player_id self.leaderboard = leaderboard self.ratings_history_api = LeaderboardRatingJournalApiConnector() @@ -281,6 +293,7 @@ def __init__(self, player_id: str, leaderboard: Leaderboard, plot: PlotControlle def enter(self) -> None: if self._loaded: return + self.name_changed.emit(self.index, "Loading...") self.ratings_history_api.get_full_history(self.player_id, self.leaderboard.technical_name) def get_plot_series(self, ratings: list[LeaderboardRatingJournal]) -> LineSeries: @@ -297,6 +310,7 @@ def get_plot_series(self, ratings: list[LeaderboardRatingJournal]) -> LineSeries def process_rating_history(self, ratings: dict[str, list[LeaderboardRatingJournal]]) -> None: self.plot.draw_series(self.get_plot_series(ratings["values"])) self._loaded = True + self.name_changed.emit(self.index, self.leaderboard.pretty_name) class RatingTabWidgetController: @@ -315,7 +329,8 @@ def run(self) -> None: def populate_leaderboards(self, message: dict[str, list[Leaderboard]]) -> None: for index, leaderboard in enumerate(message["values"]): widget = pg.PlotWidget() - tab = RatingsPlotTab(self.player_id, leaderboard, PlotController(widget)) + tab = RatingsPlotTab(index, self.player_id, leaderboard, PlotController(widget)) + tab.name_changed.connect(self.widget.setTabText) self.tabs[index] = tab self.widget.insertTab(index, widget, leaderboard.pretty_name) From decb02047411214b323004a6a3d90a2de83494a7 Mon Sep 17 00:00:00 2001 From: Gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 26 Jun 2024 19:04:58 +0300 Subject: [PATCH 19/73] Do not use QImage as a mediator to create QPixmap --- src/playercard/leagueformatter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/playercard/leagueformatter.py b/src/playercard/leagueformatter.py index d1f86e0ad..0d5f0b1f0 100644 --- a/src/playercard/leagueformatter.py +++ b/src/playercard/leagueformatter.py @@ -1,6 +1,5 @@ import os -from PyQt6.QtGui import QImage from PyQt6.QtGui import QPixmap import util @@ -73,8 +72,7 @@ def on_league_score_ready(self, score: LeagueSeasonScore) -> None: self.download_league_icon(subdivision.image_url) def set_league_icon(self, image_path: str) -> None: - image = QImage(image_path) - self.iconLabel.setPixmap(QPixmap(image).scaled(160, 80)) + self.iconLabel.setPixmap(QPixmap(image_path).scaled(160, 80)) def download_league_icon(self, url: str) -> None: name = os.path.basename(url) From 9a34569cf6bc0213a2c7a0e92425779d48cf263b Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 27 Jun 2024 21:28:23 +0300 Subject: [PATCH 20/73] Make mew mapgen options work see https://github.com/FAForever/Neroxis-Map-Generator/pull/396 and https://github.com/FAForever/Neroxis-Map-Generator/pull/401 --- res/games/mapgen.ui | 1402 ++++++++++-------------------- src/games/mapgenoptionsdialog.py | 516 +++++++---- 2 files changed, 795 insertions(+), 1123 deletions(-) diff --git a/res/games/mapgen.ui b/res/games/mapgen.ui index c4d04d292..e8cbbee1f 100644 --- a/res/games/mapgen.ui +++ b/res/games/mapgen.ui @@ -6,7 +6,7 @@ 0 0 - 512 + 550 475 @@ -14,6 +14,455 @@ Map Generator Options + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 0 + + + 0 + + + 0 + + + + + 6 + + + + + + 10 + + + + Symmetry + + + + + + + + 0 + 0 + + + + + 10 + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + + 9 + + + + Qt::Vertical + + + + 451 + 20 + + + + + + + + + + 10 + + + + + + 10 + + + + Map Style + + + + + + + + 0 + 0 + + + + + 10 + + + + + + + + + + Qt::Horizontal + + + QSizePolicy::MinimumExpanding + + + + 0 + 0 + + + + + + + + + + + 10 + + + + Use Custom Style + + + + + + + + + + + + 10 + + + + Custom Style Options + + + + + + + 10 + + + + + + + + + 10 + true + + + + Terrain + + + + + + + + + + 10 + + + + From: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 10 + + + + 100 + + + 10 + + + + + + + + 10 + + + + To: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 10 + + + + 100 + + + 10 + + + 100 + + + + + + + + + + 10 + true + + + + Resource Generator + + + + + + + + + + 10 + + + + From: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 10 + + + + 100 + + + 10 + + + + + + + + 10 + + + + To: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 10 + + + + 100 + + + 10 + + + 100 + + + + + + + + + + + + + 10 + true + + + + Prop Generator + + + + + + + + 10 + true + + + + Resources Density + + + + + + + + 10 + + + + + + + + + 10 + + + + + + + + + 10 + true + + + + Reclaim Density + + + + + + + + 10 + true + + + + Texture + + + + + + + + 9 + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + @@ -111,7 +560,6 @@ 11 - 75 true @@ -122,130 +570,6 @@ - - - - - - - 10 - - - - Map Style - - - - - - - - 0 - 0 - - - - - 150 - 0 - - - - - 10 - - - - QComboBox::AdjustToContents - - - 2 - - - - RANDOM - - - - - DEFAULT - - - - - ONE_ISLAND - - - - - BIG_ISLANDS - - - - - SMALL_ISLANDS - - - - - CENTER_LAKE - - - - - VALLEY - - - - - DROP_PLATEAU - - - - - LITTLE_MOUNTAIN - - - - - MOUNTAIN_RANGE - - - - - LAND_BRIDGE - - - - - LOW_MEX - - - - - FLOODED - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 270 - 20 - - - - - - @@ -409,832 +733,6 @@ - - - - - 9 - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - 9 - - - - Qt::Vertical - - - - 451 - 20 - - - - - - - - - - - - - 10 - 75 - true - - - - Water - - - - - - - - 0 - 40 - - - - - 10 - - - - Random - - - - - - - - 9 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - 0 - 0 - - - - - 10 - - - - Less - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 250 - 22 - - - - - 250 - 22 - - - - - 10 - - - - 0 - - - 127 - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - - 10 - - - - More - - - - - - - - - - - - - - - 10 - 75 - true - - - - Plateaus - - - - - - - - 0 - 40 - - - - - 10 - - - - Random - - - - - - - - 9 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - 0 - 0 - - - - - 10 - - - - Less - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 250 - 22 - - - - - 250 - 22 - - - - - 10 - - - - 0 - - - 127 - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - - 10 - - - - More - - - - - - - - - - - - - - - 10 - 75 - true - - - - Mountains - - - - - - - - 0 - 40 - - - - - 10 - - - - Random - - - - - - - - 9 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - 0 - 0 - - - - - 10 - - - - Less - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 250 - 22 - - - - - 250 - 22 - - - - - 10 - - - - 0 - - - 127 - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - - 10 - - - - More - - - - - - - - - - - - - - - 10 - 75 - true - - - - Ramps - - - - - - - - 0 - 40 - - - - - 10 - - - - Random - - - - - - - - 9 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - 0 - 0 - - - - - 10 - - - - Less - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 250 - 22 - - - - - 250 - 22 - - - - - 10 - - - - 0 - - - 127 - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - - 10 - - - - More - - - - - - - - - - - - - - - 10 - 75 - true - - - - Mexes - - - - - - - - 0 - 40 - - - - - 10 - - - - Random - - - - - - - - 9 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - 0 - 0 - - - - - 10 - - - - Less - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 250 - 22 - - - - - 250 - 22 - - - - - 10 - - - - 0 - - - 127 - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - - 10 - - - - More - - - - - - - - - - - - - - - 10 - 75 - true - - - - Reclaim - - - - - - - - 0 - 40 - - - - - 10 - - - - Random - - - - - - - - 9 - - - - QFrame::StyledPanel - - - QFrame::Raised - - - - - - - 0 - 0 - - - - - 10 - - - - Less - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 0 - 0 - - - - - 250 - 22 - - - - - 250 - 22 - - - - - 10 - - - - 0 - - - 127 - - - Qt::Horizontal - - - - - - - - 0 - 0 - - - - - 10 - - - - More - - - - - - - - - - - - - - - 9 - - - - Qt::Vertical - - - - 451 - 20 - - - - diff --git a/src/games/mapgenoptionsdialog.py b/src/games/mapgenoptionsdialog.py index faac64ab3..ce442dc57 100644 --- a/src/games/mapgenoptionsdialog.py +++ b/src/games/mapgenoptionsdialog.py @@ -1,4 +1,8 @@ +from __future__ import annotations + +import random from enum import Enum +from typing import NamedTuple from PyQt6 import QtCore from PyQt6 import QtWidgets @@ -9,23 +13,127 @@ FormClass, BaseClass = util.THEME.loadUiType("games/mapgen.ui") +class Common(Enum): + RANDOM = "RANDOM" + + +class Density(NamedTuple): + minval: int + maxval: int + + def value(self) -> float: + return random.randrange(self.minval, self.maxval + 1) / 100 + + +class GenerationType(Enum): + CASUAL = "CASUAL" + TOURNAMENT = "TOURNAMENT" + BLIND = "BLIND" + UNEXPLORED = "UNEXPLORED" + + +class TerrainSymmtery(Enum): + RANDOM = "RANDOM" + + POINT2 = "POINT2" + POINT3 = "POINT3" + POINT4 = "POINT4" + POINT5 = "POINT5" + POINT6 = "POINT6" + POINT7 = "POINT7" + POINT8 = "POINT8" + POINT9 = "POINT9" + POINT10 = "POINT10" + POINT11 = "POINT11" + POINT12 = "POINT12" + POINT13 = "POINT13" + POINT14 = "POINT14" + POINT15 = "POINT15" + POINT16 = "POINT16" + XZ = "XZ" + ZX = "ZX" + X = "X" + Z = "Z" + QUAD = "QUAD" + DIAG = "DIAG" + NONE = "NONE" + + class MapStyle(Enum): RANDOM = "RANDOM" - DEFAULT = "DEFAULT" - ONE_ISLAND = "ONE_ISLAND" + + BASIC = "BASIC" BIG_ISLANDS = "BIG_ISLANDS" - SMALL_ISLANDS = "SMALL_ISLANDS" CENTER_LAKE = "CENTER_LAKE" - VALLEY = "VALLEY" DROP_PLATEAU = "DROP_PLATEAU" + FLOODED = "FLOODED" + HIGH_RECLAIM = "HIGH_RECLAIM" + LAND_BRIDGE = "LAND_BRIDGE" LITTLE_MOUNTAIN = "LITTLE_MOUNTAIN" + LOW_MEX = "LOW_MEX" MOUNTAIN_RANGE = "MOUNTAIN_RANGE" + ONE_ISLAND = "ONE_ISLAND" + SMALL_ISLANDS = "SMALL_ISLANDS" + VALLEY = "VALLEY" + + @staticmethod + def get_by_index(index: int) -> MapStyle: + return list(MapStyle)[index] + + +class TerrainStyle(Enum): + RANDOM = "RANDOM" + + BASIC = "BASIC" + BIG_ISLANDS = "BIG_ISLANDS" + CENTER_LAKE = "CENTER_LAKE" + DROP_PLATEAU = "DROP_PLATEAU" + FLOODED = "FLOODED" LAND_BRIDGE = "LAND_BRIDGE" + LITTLE_MOUNTAIN = "LITTLE_MOUNTAIN" + MOUNTAIN_RANGE = "MOUNTAIN_RANGE" + ONE_ISLAND = "ONE_ISLAND" + SMALL_ISLANDS = "SMALL_ISLANDS" + VALLEY = "VALLEY" + + +class PropStyle(Enum): + RANDOM = "RANDOM" + + BASIC = "BASIC" + BOULDER_FIELD = "BOULDER_FIELD" + ENEMY_CIV = "ENEMY_CIV" + HIGH_RECLAIM = "HIGH_RECLAIM" + LARGE_BATTLE = "LARGE_BATTLE" + NAVY_WRECKS = "NAVY_WRECKS" + NEUTRAL_CIV = "NEUTRAL_CIV" + ROCK_FIELD = "ROCK_FIELD" + SMALL_BATTLE = "SMALL_BATTLE" + + +class ResourceStyle(Enum): + RANDOM = "RANDOM" + + BASIC = "BASIC" LOW_MEX = "LOW_MEX" - FLOODED = "FLOODED" + WATER_MEX = "WATER_MEX" - def getMapStyle(index): - return list(MapStyle)[index] + +class TextureStyle(Enum): + RANDOM = "RANDOM" + + BRIMSTONE = "BRIMSTONE" + DESERT = "DESERT" + EARLYAUTUMN = "EARLYAUTUMN" + FRITHEN = "FRITHEN" + MARS = "MARS" + MOONLIGHT = "MOONLIGHT" + PRAYER = "PRAYER" + STONES = "STONES" + SUNSET = "SUNSET" + SYRTIS = "SYRTIS" + WINDINGRIVER = "WINDINGRIVER" + WONDER = "WONDER" class MapGenDialog(FormClass, BaseClass): @@ -35,93 +143,42 @@ def __init__(self, parent, *args, **kwargs): self.setupUi(self) util.THEME.stylesheets_reloaded.connect(self.load_stylesheet) + self.load_stylesheet() self.parent = parent - self.generationType.currentIndexChanged.connect( - self.generationTypeChanged, + self.useCustomStyleCheckBox.checkStateChanged.connect(self.on_custom_style) + self.useCustomStyleCheckBox.setChecked( + config.Settings.get("mapGenerator/useCustomStyle", type=bool, default=False), ) - self.numberOfSpawns.currentIndexChanged.connect( - self.numberOfSpawnsChanged, - ) - self.mapSize.valueChanged.connect(self.mapSizeChanged) - self.mapStyle.currentIndexChanged.connect(self.mapStyleChanged) - self.generateMapButton.clicked.connect(self.generateMap) - self.saveMapGenSettingsButton.clicked.connect(self.saveMapGenPrefs) - self.resetMapGenSettingsButton.clicked.connect(self.resetMapGenPrefs) - - self.random_buttons = [ - self.landRandomDensity, - self.plateausRandomDensity, - self.mountainsRandomDensity, - self.rampsRandomDensity, - self.mexRandomDensity, - self.reclaimRandomDensity, - ] - self.sliders = [ - self.landDensity, - self.plateausDensity, - self.mountainsDensity, - self.rampsDensity, - self.mexDensity, - self.reclaimDensity, - ] - self.option_frames = [ - self.landOptions, - self.plateausOptions, - self.mountainsOptions, - self.rampsOptions, - self.mexOptions, - self.reclaimOptions, + self.generationType.currentIndexChanged.connect(self.gen_type_changed) + self.numberOfSpawns.currentIndexChanged.connect(self.num_spawns_changed) + self.mapSize.valueChanged.connect(self.map_size_changed) + self.mapStyle.currentIndexChanged.connect(self.map_style_changed) + self.generateMapButton.clicked.connect(self.generate_map) + self.saveMapGenSettingsButton.clicked.connect(self.save_mapgen_prefs) + self.resetMapGenSettingsButton.clicked.connect(self.reset_mapgen_prefs) + + self.spinners = [ + self.minResourceDensity, + self.maxResourceDensity, + self.minReclaimDensity, + self.maxReclaimDensity, ] - for random_button in self.random_buttons: - random_button.setChecked( - config.Settings.get( - "mapGenerator/{}".format(random_button.objectName()), - type=bool, - default=True, - ), - ) - random_button.toggled.connect(self.configOptionFrames) - - for slider in self.sliders: - slider.setValue( - config.Settings.get( - "mapGenerator/{}".format(slider.objectName()), - type=int, - default=0, - ), - ) - - self.generation_type = "casual" + self.generation_type = GenerationType.CASUAL self.number_of_spawns = 2 self.map_size = 256 self.map_style = MapStyle.RANDOM - self.generationType.setCurrentIndex( - config.Settings.get( - "mapGenerator/generationTypeIndex", type=int, default=0, - ), - ) - self.numberOfSpawns.setCurrentIndex( - config.Settings.get( - "mapGenerator/numberOfSpawnsIndex", type=int, default=0, - ), - ) - self.mapSize.setValue( - config.Settings.get( - "mapGenerator/mapSize", type=float, default=5.0, - ), - ) - self.mapStyle.setCurrentIndex( - config.Settings.get( - "mapGenerator/mapStyleIndex", type=int, default=0, - ), - ) + self.populate_options() + self.load_preferences() - self.configOptionFrames() + @QtCore.pyqtSlot(QtCore.Qt.CheckState) + def on_custom_style(self, state: QtCore.Qt.CheckState) -> None: + self.customStyleGroupBox.setEnabled(state == QtCore.Qt.CheckState.Checked) + self.mapStyle.setEnabled(state == QtCore.Qt.CheckState.Unchecked) def load_stylesheet(self): self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) @@ -135,84 +192,157 @@ def keyPressEvent(self, event): QtWidgets.QDialog.keyPressEvent(self, event) @QtCore.pyqtSlot(int) - def numberOfSpawnsChanged(self, index): + def num_spawns_changed(self, index: int) -> None: self.number_of_spawns = 2 * (index + 1) + @staticmethod + def nearest_to_multiple(value: float, to: float) -> float: + return ((value + to / 2) // to) * to + @QtCore.pyqtSlot(float) - def mapSizeChanged(self, value): + def map_size_changed(self, value): if (value % 1.25): - # nearest to multiple of 1.25 - value = ((value + 0.625) // 1.25) * 1.25 + value = self.nearest_to_multiple(value, 1.25) self.mapSize.blockSignals(True) self.mapSize.setValue(value) self.mapSize.blockSignals(False) self.map_size = int(value * 51.2) @QtCore.pyqtSlot(int) - def generationTypeChanged(self, index): + def gen_type_changed(self, index: int) -> None: if index == -1 or index == 0: - self.generation_type = "casual" + self.generation_type = GenerationType.CASUAL elif index == 1: - self.generation_type = "tournament" + self.generation_type = GenerationType.TOURNAMENT elif index == 2: - self.generation_type = "blind" + self.generation_type = GenerationType.BLIND elif index == 3: - self.generation_type = "unexplored" + self.generation_type = GenerationType.UNEXPLORED if index == -1 or index == 0: - self.mapStyle.setEnabled(True) + self.casualOptionsFrame.setEnabled(True) self.mapStyle.setCurrentIndex( config.Settings.get( "mapGenerator/mapStyleIndex", type=int, default=0, ), ) else: - self.mapStyle.setEnabled(False) - self.mapStyle.setCurrentIndex(0) - - self.checkRandomButtons() + self.casualOptionsFrame.setEnabled(False) @QtCore.pyqtSlot(int) - def mapStyleChanged(self, index): + def map_style_changed(self, index: int) -> None: if index == -1 or index == 0: self.map_style = MapStyle.RANDOM else: - self.map_style = MapStyle.getMapStyle(index) + self.map_style = MapStyle.get_by_index(index) self.checkRandomButtons() @QtCore.pyqtSlot() def checkRandomButtons(self): - for random_button in self.random_buttons: - if ( - self.generation_type != "casual" - or self.map_style != MapStyle.RANDOM - ): - random_button.setEnabled(False) - random_button.setChecked(True) - else: - random_button.setEnabled(True) - random_button.setChecked( - config.Settings.get( - "mapGenerator/{}".format(random_button.objectName()), - type=bool, - default=True, - ), - ) + self.customStyleGroupBox.setEnabled(self.useCustomStyleCheckBox.isChecked()) + + def populate_options(self) -> None: + controls = ( + self.terrainStyle, + self.terrainSymmetry, + self.mapStyle, + self.textureStyle, + self.resourceGenerator, + self.propGenerator, + ) + control_classes = ( + TerrainStyle, + TerrainSymmtery, + MapStyle, + TextureStyle, + ResourceStyle, + PropStyle, + ) + for control, control_cls in zip(controls, control_classes): + for style in iter(control_cls): + control.addItem(style.value, style) @QtCore.pyqtSlot() - def configOptionFrames(self): - for random_button in self.random_buttons: - option_frame = self.option_frames[ - self.random_buttons.index(random_button) - ] - if random_button.isChecked(): - option_frame.setEnabled(False) - else: - option_frame.setEnabled(True) + def load_preferences(self) -> None: + self.generationType.setCurrentIndex( + config.Settings.get( + "mapGenerator/generationTypeIndex", type=int, default=0, + ), + ) + self.numberOfSpawns.setCurrentIndex( + config.Settings.get( + "mapGenerator/numberOfSpawnsIndex", type=int, default=0, + ), + ) + self.mapSize.setValue( + config.Settings.get( + "mapGenerator/mapSize", type=float, default=5.0, + ), + ) + self.mapStyle.setCurrentIndex( + config.Settings.get( + "mapGenerator/mapStyleIndex", type=int, default=0, + ), + ) + + for spinner in self.spinners: + spinner.setValue( + config.Settings.get( + f"mapGenerator/{spinner.objectName()}", + type=int, + default=spinner.value(), + ), + ) + + self.useCustomStyleCheckBox.setChecked( + config.Settings.get( + "mapGenerator/useCustomStyle", + type=bool, + default=False, + ), + ) + self.terrainStyle.setCurrentText( + config.Settings.get( + "mapGenerator/terrainStyle", + default=Common.RANDOM.value, + ), + ) + self.textureStyle.setCurrentText( + config.Settings.get( + "mapGenerator/textureStyle", + default=Common.RANDOM.value, + ), + ) + self.propGenerator.setCurrentText( + config.Settings.get( + "mapGenerator/propGenerator", + default=Common.RANDOM.value, + ), + ) + self.resourceGenerator.setCurrentText( + config.Settings.get( + "mapGenerator/resourceGenerator", + default=Common.RANDOM.value, + ), + ) + self.terrainSymmetry.setCurrentText( + config.Settings.get( + "mapGenerator/terrainSymmetry", + default=Common.RANDOM.value, + ), + ) + for spinner in self.spinners: + spinner.setValue( + config.Settings.get( + f"mapGenerator/{spinner.objectName()}", + type=int, + default=spinner.value(), + ), + ) @QtCore.pyqtSlot() - def saveMapGenPrefs(self): + def save_mapgen_prefs(self) -> None: config.Settings.set( "mapGenerator/generationTypeIndex", self.generationType.currentIndex(), @@ -229,76 +359,120 @@ def saveMapGenPrefs(self): "mapGenerator/mapStyleIndex", self.mapStyle.currentIndex(), ) - for random_button in self.random_buttons: - config.Settings.set( - "mapGenerator/{}".format(random_button.objectName()), - random_button.isChecked(), - ) - for slider in self.sliders: + config.Settings.set( + "mapGenerator/useCustomStyle", + self.useCustomStyleCheckBox.isChecked(), + ) + + config.Settings.set( + "mapGenerator/terrainStyle", + self.terrainStyle.currentText(), + ) + config.Settings.set( + "mapGenerator/textureStyle", + self.textureStyle.currentText(), + ) + config.Settings.set( + "mapGenerator/propGenerator", + self.propGenerator.currentText(), + ) + config.Settings.set( + "mapGenerator/resourceGenerator", + self.resourceGenerator.currentText(), + ) + config.Settings.set( + "mapGenerator/terrainSymmetry", + self.terrainSymmetry.currentText(), + ) + config.Settings.set( + "mapGenerator/minResourceDensity", + self.minResourceDensity.value(), + ) + config.Settings.set( + "mapGenerator/maxResourceDensity", + self.maxResourceDensity.value(), + ) + config.Settings.set( + "mapGenerator/minReclaimDensity", + self.minReclaimDensity.value(), + ) + config.Settings.set( + "mapGenerator/maxReclaimDensity", + self.maxReclaimDensity.value(), + ) + for spinner in self.spinners: config.Settings.set( - "mapGenerator/{}".format(slider.objectName()), slider.value(), + f"mapGenerator/{spinner.objectName()}", + spinner.value(), ) self.done(1) @QtCore.pyqtSlot() - def resetMapGenPrefs(self): + def reset_mapgen_prefs(self) -> None: self.generationType.setCurrentIndex(0) self.mapSize.setValue(5.0) self.numberOfSpawns.setCurrentIndex(0) self.mapStyle.setCurrentIndex(0) - for random_button in self.random_buttons: - random_button.setChecked(True) - for slider in self.sliders: - slider.setValue(0) + for spinner in self.spinners: + spinner.setValue(0) @QtCore.pyqtSlot() - def generateMap(self): + def generate_map(self): map_ = self.parent.client.map_generator.generateMap( - args=self.setArguments(), + args=self.set_arguments(), ) if map_: self.parent.setupMapList() self.parent.set_map(map_) - self.saveMapGenPrefs() + self.save_mapgen_prefs() - def setArguments(self): + def get_density(self, minval: int, maxval: int) -> float: + return random.randrange(minval, maxval + 1) / 100 + + def set_arguments(self) -> list[str]: args = [] args.append("--map-size") args.append(str(self.map_size)) args.append("--spawn-count") args.append(str(self.number_of_spawns)) - if self.map_style != MapStyle.RANDOM: - args.append("--style") - args.append(self.map_style.value) - else: - if self.generation_type == "tournament": - args.append("--tournament-style") - elif self.generation_type == "blind": - args.append("--blind") - elif self.generation_type == "unexplored": - args.append("--unexplored") - - slider_args = [ - ["--land-density", None], - ["--plateau-density", None], - ["--mountain-density", None], - ["--ramp-density", None], - ["--mex-density", None], - ["--reclaim-density", None], - ] - for index, slider in enumerate(self.sliders): - if slider.isEnabled(): - if slider == self.landDensity: - value = float(1 - (slider.value() / 127)) - else: - value = float(slider.value() / 127) - slider_args[index][1] = value - - for arg_key, arg_value in slider_args: - if arg_value is not None: - args.append(arg_key) - args.append(str(arg_value)) + if self.generation_type != GenerationType.CASUAL: + args.append(f"--{self.generation_type.value}") + return args + + if (symmetry := self.terrainSymmetry.currentData()) != TerrainSymmtery.RANDOM: + args.append("--terrain-symmetry") + args.append(symmetry.value) + + if not self.useCustomStyleCheckBox.isChecked(): + if self.map_style != MapStyle.RANDOM: + args.append("--style") + args.append(self.map_style.value) + return args + + resource_density = Density( + self.minResourceDensity.value(), + self.maxResourceDensity.value(), + ) + args.append("--resource-density") + args.append(str(resource_density.value())) + + reclaim_density = Density( + self.minReclaimDensity.value(), + self.maxReclaimDensity.value(), + ) + args.append("--reclaim-density") + args.append(str(reclaim_density.value())) + + for control, control_cls, argname in zip( + (self.terrainStyle, self.textureStyle, self.resourceGenerator, self.propGenerator), + (TerrainStyle, TextureStyle, ResourceStyle, PropStyle), + ("--terrain-style", "--texture-style", "--resource-style", "--prop-style"), + ): + if control.currentData() == control_cls.RANDOM: + continue + args.append(argname) + args.append(control.currentData().value) return args From 13af401be794ce7b61c507655a126a21f3ef83e3 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 28 Jun 2024 23:15:51 +0300 Subject: [PATCH 21/73] MapGen: Do not try to close multiple times on error map generator exits with 0 on wrong command line options so the only way to check if error happened is to read from stderr but the library, which map generator uses, also prints the output of --help command after the error message Starting from this commit we don't act upon second message only log it just in case something else happened --- src/mapGenerator/mapgenProcess.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/mapGenerator/mapgenProcess.py b/src/mapGenerator/mapgenProcess.py index 8eb56db90..d0b8e80e4 100644 --- a/src/mapGenerator/mapgenProcess.py +++ b/src/mapGenerator/mapgenProcess.py @@ -43,6 +43,8 @@ def __init__(self, gen_path, out_path, args): self.map_generator_process.readyReadStandardError.connect( self.on_error_ready, ) + self._error_msgs_received = 0 + self.map_generator_process.finished.connect(self.on_exit) self.map_name = None @@ -90,16 +92,24 @@ def on_log_ready(self): self.progressCounter += 1 self._progress.setValue(self.progressCounter) - def on_error_ready(self): - standard_error = str(self.map_generator_process.readAllStandardError()) - for line in standard_error.splitlines(): - generatorLogger.error("Error: " + line) + def on_error_ready(self) -> None: + self._error_msgs_received += 1 + + message = self.map_generator_process.readAllStandardError().data().decode() + generatorLogger.error(message) + + if self._error_msgs_received > 1: + # Happens on wrong command line usage when the first message + # is useful and the next is output of --help command + return + self.close() QMessageBox.critical( None, "Map generator error", "Something went wrong. Probably because of bad combination of " - "generator options. Please retry with different options", + "generator options. Please retry with different options:\n\n" + f"{message}", ) def on_exit(self, code, status): From d7143f3c5627f682489285e4835c839df4f19e1d Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 29 Jun 2024 09:53:59 +0300 Subject: [PATCH 22/73] Fix getting previews for generated maps --- src/fa/maps.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/fa/maps.py b/src/fa/maps.py index c973e9b87..3ed4b8e38 100644 --- a/src/fa/maps.py +++ b/src/fa/maps.py @@ -421,7 +421,20 @@ def plausible_mapname_preview_name(suffix: str) -> str: iconExtensions = ["png"] -def preview(mapname, pixmap=False): +def get_preview_for_generated_map(mapname: str) -> QtGui.QIcon: + mapdir = os.path.join(getUserMapsFolder(), mapname) + preview_name = f"{mapname}_preview.png" + preview_path = os.path.join(mapdir, preview_name) + + if os.path.isfile(preview_path): + return util.THEME.icon(preview_path) + + return util.THEME.icon("games/generated_map.png") + + +def preview(mapname: str, *, pixmap: bool = False) -> QtGui.QIcon | QtGui.QPixmap | None: + if isGeneratedMap(mapname): + return get_preview_for_generated_map(mapname) try: # Try to load directly from cache for extension in iconExtensions: @@ -444,14 +457,9 @@ def preview(mapname, pixmap=False): ): logger.debug("Using fresh preview image for: " + mapname) return util.THEME.icon(img['cache'], False, pixmap) - - if isGeneratedMap(mapname): - return util.THEME.icon("games/generated_map.png") - - return None - except BaseException: - logger.debug("Error raised in maps.preview(...) for " + mapname) - logger.debug("Map Preview Exception", exc_info=sys.exc_info()) + except Exception: + logger.debug(f"Map Preview Exception ({mapname!r})", exc_info=sys.exc_info()) + return None def downloadMap(name: str, silent: bool = False) -> bool: From 427bd9dcd5a62243a6bb9fa0311b86e1348aaad7 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 30 Jun 2024 23:28:37 +0300 Subject: [PATCH 23/73] Refactor mapgen options to eliminite some duplicate code also, add missed --num-teams argument --- res/games/mapgen.ui | 127 ++++---- src/games/mapgenoptions.py | 155 ++++++++++ src/games/mapgenoptionsdialog.py | 490 +++++++------------------------ src/games/mapgenoptionsvalues.py | 109 +++++++ 4 files changed, 423 insertions(+), 458 deletions(-) create mode 100644 src/games/mapgenoptions.py create mode 100644 src/games/mapgenoptionsvalues.py diff --git a/res/games/mapgen.ui b/res/games/mapgen.ui index e8cbbee1f..8d96caf0f 100644 --- a/res/games/mapgen.ui +++ b/res/games/mapgen.ui @@ -6,7 +6,7 @@ 0 0 - 550 + 584 475 @@ -594,28 +594,8 @@ - Casual + - - - Casual - - - - - Tournament - - - - - Blind - - - - - Unexplored - - @@ -654,11 +634,14 @@ 2.500000000000000 - 40.000000000000000 + 80.000000000000000 1.250000000000000 + + 5.000000000000000 + @@ -673,60 +656,72 @@ - Number of Spawns + Spawns - + + + + 0 + 0 + + 10 - + + 1 + + + 1000 + + + 2 + + + + + + + + + + + + 10 + + + + Teams + + + + + + + + 0 + 0 + + + + + 10 + + + + 1 + + + 1000 + + 2 - - - 2 - - - - - 4 - - - - - 6 - - - - - 8 - - - - - 10 - - - - - 12 - - - - - 14 - - - - - 16 - - diff --git a/src/games/mapgenoptions.py b/src/games/mapgenoptions.py new file mode 100644 index 000000000..d80873fbd --- /dev/null +++ b/src/games/mapgenoptions.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import random +from typing import Any + +from PyQt6.QtWidgets import QComboBox +from PyQt6.QtWidgets import QSpinBox + +import config + + +class OptionMixin: + def reset(self) -> None: + raise NotImplementedError + + def set_value(self, value: Any) -> None: + raise NotImplementedError + + def value(self) -> Any: + raise NotImplementedError + + def active(self) -> bool: + raise NotImplementedError + + def load(self) -> None: + raise NotImplementedError + + def save(self) -> None: + raise NotImplementedError + + def as_cmd_arg(self) -> list[str]: + raise NotImplementedError + + +class MapGenOption(OptionMixin): + def __init__( + self, + name: str, + ui_elem: QComboBox | QSpinBox, + typ: type = str, + default: Any = None, + ) -> None: + self.conf = config.Settings() + self.name = name + self.ui_elem = ui_elem + self.typ = typ + self.default = default + + def reset(self) -> None: + if self.default is not None: + self.set_value(self.default) + + def load(self) -> None: + self.set_value( + self.conf.get( + f"mapGenerator/{self.ui_elem.objectName()}", + default=self.default, + type=self.typ, + ), + ) + + def save(self) -> None: + self.conf.set( + f"mapGenerator/{self.ui_elem.objectName()}", + self.value(), + ) + + def as_cmd_arg(self) -> list[str]: + return [f"--{self.name}", str(self.value())] + + +class ComboBoxOption(MapGenOption): + def __init__( + self, + name: str, + ui_elem: QComboBox, + default: str | None = None, + opts: list[str] | None = None, + ) -> None: + MapGenOption.__init__(self, name, ui_elem, str, default) + self.opts = opts + + def set_value(self, value: str) -> None: + self.ui_elem.setCurrentText(value) + + def value(self) -> str: + return self.ui_elem.currentText() + + def active(self) -> bool: + return self.ui_elem.isEnabled() and self.value() != self.default + + def populate(self) -> None: + if self.opts is None: + return + for opt in self.opts: + self.ui_elem.addItem(opt) + + def load(self) -> None: + self.populate() + MapGenOption.load(self) + + +class SpinBoxOption(MapGenOption): + def __init__( + self, + name: str, + ui_elem: QSpinBox, + typ: type, + default: int | float | None = None, + ) -> None: + MapGenOption.__init__(self, name, ui_elem, typ, default) + + def set_value(self, value: int | float) -> None: + self.ui_elem.setValue(value) + + def value(self) -> int | float: + return self.ui_elem.value() + + def active(self) -> bool: + return self.ui_elem.isEnabled() + + +class RangeOption(OptionMixin): + def __init__( + self, + name: str, + minimum: SpinBoxOption, + maximum: SpinBoxOption, + ) -> None: + self.name = name + self.minimum = minimum + self.maximum = maximum + + def reset(self) -> None: + self.minimum.reset() + self.maximum.reset() + + def value(self) -> float: + minval = min(self.minimum.value(), self.maximum.value()) + maxval = max(self.minimum.value(), self.maximum.value()) + return random.randrange(round(minval), round(maxval + 1)) / 100 + + def active(self) -> bool: + return self.minimum.active() and self.maximum.active() + + def load(self) -> None: + self.minimum.load() + self.maximum.load() + + def save(self) -> None: + self.minimum.save() + self.maximum.save() + + def as_cmd_arg(self) -> list[str]: + return [f"--{self.name}", str(self.value())] diff --git a/src/games/mapgenoptionsdialog.py b/src/games/mapgenoptionsdialog.py index ce442dc57..c88249b51 100644 --- a/src/games/mapgenoptionsdialog.py +++ b/src/games/mapgenoptionsdialog.py @@ -1,141 +1,25 @@ from __future__ import annotations -import random -from enum import Enum -from typing import NamedTuple - from PyQt6 import QtCore from PyQt6 import QtWidgets import config import util +from games.mapgenoptions import ComboBoxOption +from games.mapgenoptions import RangeOption +from games.mapgenoptions import SpinBoxOption +from games.mapgenoptionsvalues import GenerationType +from games.mapgenoptionsvalues import MapStyle +from games.mapgenoptionsvalues import PropStyle +from games.mapgenoptionsvalues import ResourceStyle +from games.mapgenoptionsvalues import Sentinel +from games.mapgenoptionsvalues import TerrainStyle +from games.mapgenoptionsvalues import TerrainSymmetry +from games.mapgenoptionsvalues import TextureStyle FormClass, BaseClass = util.THEME.loadUiType("games/mapgen.ui") -class Common(Enum): - RANDOM = "RANDOM" - - -class Density(NamedTuple): - minval: int - maxval: int - - def value(self) -> float: - return random.randrange(self.minval, self.maxval + 1) / 100 - - -class GenerationType(Enum): - CASUAL = "CASUAL" - TOURNAMENT = "TOURNAMENT" - BLIND = "BLIND" - UNEXPLORED = "UNEXPLORED" - - -class TerrainSymmtery(Enum): - RANDOM = "RANDOM" - - POINT2 = "POINT2" - POINT3 = "POINT3" - POINT4 = "POINT4" - POINT5 = "POINT5" - POINT6 = "POINT6" - POINT7 = "POINT7" - POINT8 = "POINT8" - POINT9 = "POINT9" - POINT10 = "POINT10" - POINT11 = "POINT11" - POINT12 = "POINT12" - POINT13 = "POINT13" - POINT14 = "POINT14" - POINT15 = "POINT15" - POINT16 = "POINT16" - XZ = "XZ" - ZX = "ZX" - X = "X" - Z = "Z" - QUAD = "QUAD" - DIAG = "DIAG" - NONE = "NONE" - - -class MapStyle(Enum): - RANDOM = "RANDOM" - - BASIC = "BASIC" - BIG_ISLANDS = "BIG_ISLANDS" - CENTER_LAKE = "CENTER_LAKE" - DROP_PLATEAU = "DROP_PLATEAU" - FLOODED = "FLOODED" - HIGH_RECLAIM = "HIGH_RECLAIM" - LAND_BRIDGE = "LAND_BRIDGE" - LITTLE_MOUNTAIN = "LITTLE_MOUNTAIN" - LOW_MEX = "LOW_MEX" - MOUNTAIN_RANGE = "MOUNTAIN_RANGE" - ONE_ISLAND = "ONE_ISLAND" - SMALL_ISLANDS = "SMALL_ISLANDS" - VALLEY = "VALLEY" - - @staticmethod - def get_by_index(index: int) -> MapStyle: - return list(MapStyle)[index] - - -class TerrainStyle(Enum): - RANDOM = "RANDOM" - - BASIC = "BASIC" - BIG_ISLANDS = "BIG_ISLANDS" - CENTER_LAKE = "CENTER_LAKE" - DROP_PLATEAU = "DROP_PLATEAU" - FLOODED = "FLOODED" - LAND_BRIDGE = "LAND_BRIDGE" - LITTLE_MOUNTAIN = "LITTLE_MOUNTAIN" - MOUNTAIN_RANGE = "MOUNTAIN_RANGE" - ONE_ISLAND = "ONE_ISLAND" - SMALL_ISLANDS = "SMALL_ISLANDS" - VALLEY = "VALLEY" - - -class PropStyle(Enum): - RANDOM = "RANDOM" - - BASIC = "BASIC" - BOULDER_FIELD = "BOULDER_FIELD" - ENEMY_CIV = "ENEMY_CIV" - HIGH_RECLAIM = "HIGH_RECLAIM" - LARGE_BATTLE = "LARGE_BATTLE" - NAVY_WRECKS = "NAVY_WRECKS" - NEUTRAL_CIV = "NEUTRAL_CIV" - ROCK_FIELD = "ROCK_FIELD" - SMALL_BATTLE = "SMALL_BATTLE" - - -class ResourceStyle(Enum): - RANDOM = "RANDOM" - - BASIC = "BASIC" - LOW_MEX = "LOW_MEX" - WATER_MEX = "WATER_MEX" - - -class TextureStyle(Enum): - RANDOM = "RANDOM" - - BRIMSTONE = "BRIMSTONE" - DESERT = "DESERT" - EARLYAUTUMN = "EARLYAUTUMN" - FRITHEN = "FRITHEN" - MARS = "MARS" - MOONLIGHT = "MOONLIGHT" - PRAYER = "PRAYER" - STONES = "STONES" - SUNSET = "SUNSET" - SYRTIS = "SYRTIS" - WINDINGRIVER = "WINDINGRIVER" - WONDER = "WONDER" - - class MapGenDialog(FormClass, BaseClass): def __init__(self, parent, *args, **kwargs): BaseClass.__init__(self, *args, **kwargs) @@ -149,30 +33,69 @@ def __init__(self, parent, *args, **kwargs): self.parent = parent self.useCustomStyleCheckBox.checkStateChanged.connect(self.on_custom_style) - self.useCustomStyleCheckBox.setChecked( - config.Settings.get("mapGenerator/useCustomStyle", type=bool, default=False), - ) - - self.generationType.currentIndexChanged.connect(self.gen_type_changed) - self.numberOfSpawns.currentIndexChanged.connect(self.num_spawns_changed) + self.generationType.currentTextChanged.connect(self.gen_type_changed) self.mapSize.valueChanged.connect(self.map_size_changed) - self.mapStyle.currentIndexChanged.connect(self.map_style_changed) self.generateMapButton.clicked.connect(self.generate_map) - self.saveMapGenSettingsButton.clicked.connect(self.save_mapgen_prefs) + self.saveMapGenSettingsButton.clicked.connect(self.save_preferences_and_quit) self.resetMapGenSettingsButton.clicked.connect(self.reset_mapgen_prefs) - self.spinners = [ - self.minResourceDensity, - self.maxResourceDensity, - self.minReclaimDensity, - self.maxReclaimDensity, + self.cmd_options: list[ComboBoxOption | SpinBoxOption | RangeOption] = [ + ComboBoxOption( + "visibility", + self.generationType, + GenerationType.CASUAL.value, + GenerationType.values(), + ), + ComboBoxOption( + "terrain-symmetry", + self.terrainSymmetry, + Sentinel.RANDOM.value, + Sentinel.values() + TerrainSymmetry.values(), + ), + ComboBoxOption( + "style", + self.mapStyle, + Sentinel.RANDOM.value, + Sentinel.values() + MapStyle.values(), + ), + ComboBoxOption( + "terrain-style", + self.terrainStyle, + Sentinel.RANDOM.value, + Sentinel.values() + TerrainStyle.values(), + ), + ComboBoxOption( + "texture-style", + self.textureStyle, + Sentinel.RANDOM.value, + Sentinel.values() + TextureStyle.values(), + ), + ComboBoxOption( + "resource-style", + self.resourceGenerator, + Sentinel.RANDOM.value, + Sentinel.values() + ResourceStyle.values(), + ), + ComboBoxOption( + "prop-style", + self.propGenerator, + Sentinel.RANDOM.value, + Sentinel.values() + PropStyle.values(), + ), + SpinBoxOption("spawn-count", self.numberOfSpawns, int, 2), + SpinBoxOption("num-teams", self.numberOfTeams, int, 2), + SpinBoxOption("map-size", self.mapSize, float, 5), + RangeOption( + "resource-density", + SpinBoxOption("", self.minResourceDensity, int, 0), + SpinBoxOption("", self.maxResourceDensity, int, 100), + ), + RangeOption( + "reclaim-density", + SpinBoxOption("", self.minReclaimDensity, int, 0), + SpinBoxOption("", self.maxReclaimDensity, int, 100), + ), ] - - self.generation_type = GenerationType.CASUAL - self.number_of_spawns = 2 - self.map_size = 256 - self.map_style = MapStyle.RANDOM - self.populate_options() self.load_preferences() @QtCore.pyqtSlot(QtCore.Qt.CheckState) @@ -191,110 +114,27 @@ def keyPressEvent(self, event): return QtWidgets.QDialog.keyPressEvent(self, event) - @QtCore.pyqtSlot(int) - def num_spawns_changed(self, index: int) -> None: - self.number_of_spawns = 2 * (index + 1) - @staticmethod def nearest_to_multiple(value: float, to: float) -> float: return ((value + to / 2) // to) * to @QtCore.pyqtSlot(float) def map_size_changed(self, value): - if (value % 1.25): - value = self.nearest_to_multiple(value, 1.25) - self.mapSize.blockSignals(True) - self.mapSize.setValue(value) - self.mapSize.blockSignals(False) - self.map_size = int(value * 51.2) - - @QtCore.pyqtSlot(int) - def gen_type_changed(self, index: int) -> None: - if index == -1 or index == 0: - self.generation_type = GenerationType.CASUAL - elif index == 1: - self.generation_type = GenerationType.TOURNAMENT - elif index == 2: - self.generation_type = GenerationType.BLIND - elif index == 3: - self.generation_type = GenerationType.UNEXPLORED - - if index == -1 or index == 0: - self.casualOptionsFrame.setEnabled(True) - self.mapStyle.setCurrentIndex( - config.Settings.get( - "mapGenerator/mapStyleIndex", type=int, default=0, - ), - ) - else: - self.casualOptionsFrame.setEnabled(False) - - @QtCore.pyqtSlot(int) - def map_style_changed(self, index: int) -> None: - if index == -1 or index == 0: - self.map_style = MapStyle.RANDOM - else: - self.map_style = MapStyle.get_by_index(index) - - self.checkRandomButtons() - - @QtCore.pyqtSlot() - def checkRandomButtons(self): - self.customStyleGroupBox.setEnabled(self.useCustomStyleCheckBox.isChecked()) + if (value % 1.25) == 0: + return + value = self.nearest_to_multiple(value, 1.25) + self.mapSize.blockSignals(True) + self.mapSize.setValue(value) + self.mapSize.blockSignals(False) - def populate_options(self) -> None: - controls = ( - self.terrainStyle, - self.terrainSymmetry, - self.mapStyle, - self.textureStyle, - self.resourceGenerator, - self.propGenerator, - ) - control_classes = ( - TerrainStyle, - TerrainSymmtery, - MapStyle, - TextureStyle, - ResourceStyle, - PropStyle, - ) - for control, control_cls in zip(controls, control_classes): - for style in iter(control_cls): - control.addItem(style.value, style) + @QtCore.pyqtSlot(str) + def gen_type_changed(self, text: str) -> None: + self.casualOptionsFrame.setEnabled(text == GenerationType.CASUAL.value) @QtCore.pyqtSlot() def load_preferences(self) -> None: - self.generationType.setCurrentIndex( - config.Settings.get( - "mapGenerator/generationTypeIndex", type=int, default=0, - ), - ) - self.numberOfSpawns.setCurrentIndex( - config.Settings.get( - "mapGenerator/numberOfSpawnsIndex", type=int, default=0, - ), - ) - self.mapSize.setValue( - config.Settings.get( - "mapGenerator/mapSize", type=float, default=5.0, - ), - ) - self.mapStyle.setCurrentIndex( - config.Settings.get( - "mapGenerator/mapStyleIndex", type=int, default=0, - ), - ) - - for spinner in self.spinners: - spinner.setValue( - config.Settings.get( - f"mapGenerator/{spinner.objectName()}", - type=int, - default=spinner.value(), - ), - ) - + for option in self.cmd_options: + option.load() self.useCustomStyleCheckBox.setChecked( config.Settings.get( "mapGenerator/useCustomStyle", @@ -302,177 +142,43 @@ def load_preferences(self) -> None: default=False, ), ) - self.terrainStyle.setCurrentText( - config.Settings.get( - "mapGenerator/terrainStyle", - default=Common.RANDOM.value, - ), - ) - self.textureStyle.setCurrentText( - config.Settings.get( - "mapGenerator/textureStyle", - default=Common.RANDOM.value, - ), - ) - self.propGenerator.setCurrentText( - config.Settings.get( - "mapGenerator/propGenerator", - default=Common.RANDOM.value, - ), - ) - self.resourceGenerator.setCurrentText( - config.Settings.get( - "mapGenerator/resourceGenerator", - default=Common.RANDOM.value, - ), - ) - self.terrainSymmetry.setCurrentText( - config.Settings.get( - "mapGenerator/terrainSymmetry", - default=Common.RANDOM.value, - ), - ) - for spinner in self.spinners: - spinner.setValue( - config.Settings.get( - f"mapGenerator/{spinner.objectName()}", - type=int, - default=spinner.value(), - ), - ) + self.on_custom_style(self.useCustomStyleCheckBox.checkState()) - @QtCore.pyqtSlot() - def save_mapgen_prefs(self) -> None: - config.Settings.set( - "mapGenerator/generationTypeIndex", - self.generationType.currentIndex(), - ) - config.Settings.set( - "mapGenerator/mapSize", - self.mapSize.value(), - ) - config.Settings.set( - "mapGenerator/numberOfSpawnsIndex", - self.numberOfSpawns.currentIndex(), - ) - config.Settings.set( - "mapGenerator/mapStyleIndex", - self.mapStyle.currentIndex(), - ) + def save_preferences(self) -> None: + for option in self.cmd_options: + option.save() config.Settings.set( "mapGenerator/useCustomStyle", self.useCustomStyleCheckBox.isChecked(), ) - config.Settings.set( - "mapGenerator/terrainStyle", - self.terrainStyle.currentText(), - ) - config.Settings.set( - "mapGenerator/textureStyle", - self.textureStyle.currentText(), - ) - config.Settings.set( - "mapGenerator/propGenerator", - self.propGenerator.currentText(), - ) - config.Settings.set( - "mapGenerator/resourceGenerator", - self.resourceGenerator.currentText(), - ) - config.Settings.set( - "mapGenerator/terrainSymmetry", - self.terrainSymmetry.currentText(), - ) - config.Settings.set( - "mapGenerator/minResourceDensity", - self.minResourceDensity.value(), - ) - config.Settings.set( - "mapGenerator/maxResourceDensity", - self.maxResourceDensity.value(), - ) - config.Settings.set( - "mapGenerator/minReclaimDensity", - self.minReclaimDensity.value(), - ) - config.Settings.set( - "mapGenerator/maxReclaimDensity", - self.maxReclaimDensity.value(), - ) - for spinner in self.spinners: - config.Settings.set( - f"mapGenerator/{spinner.objectName()}", - spinner.value(), - ) + @QtCore.pyqtSlot() + def save_preferences_and_quit(self) -> None: + self.save_preferences() self.done(1) @QtCore.pyqtSlot() def reset_mapgen_prefs(self) -> None: - self.generationType.setCurrentIndex(0) - self.mapSize.setValue(5.0) - self.numberOfSpawns.setCurrentIndex(0) - self.mapStyle.setCurrentIndex(0) - - for spinner in self.spinners: - spinner.setValue(0) + for option in self.cmd_options: + option.reset() @QtCore.pyqtSlot() - def generate_map(self): + def generate_map(self) -> None: map_ = self.parent.client.map_generator.generateMap( args=self.set_arguments(), ) if map_: self.parent.setupMapList() self.parent.set_map(map_) - self.save_mapgen_prefs() - - def get_density(self, minval: int, maxval: int) -> float: - return random.randrange(minval, maxval + 1) / 100 + self.save_preferences_and_quit() def set_arguments(self) -> list[str]: args = [] - args.append("--map-size") - args.append(str(self.map_size)) - args.append("--spawn-count") - args.append(str(self.number_of_spawns)) - - if self.generation_type != GenerationType.CASUAL: - args.append(f"--{self.generation_type.value}") - return args - - if (symmetry := self.terrainSymmetry.currentData()) != TerrainSymmtery.RANDOM: - args.append("--terrain-symmetry") - args.append(symmetry.value) - - if not self.useCustomStyleCheckBox.isChecked(): - if self.map_style != MapStyle.RANDOM: - args.append("--style") - args.append(self.map_style.value) - return args - - resource_density = Density( - self.minResourceDensity.value(), - self.maxResourceDensity.value(), - ) - args.append("--resource-density") - args.append(str(resource_density.value())) - - reclaim_density = Density( - self.minReclaimDensity.value(), - self.maxReclaimDensity.value(), - ) - args.append("--reclaim-density") - args.append(str(reclaim_density.value())) - - for control, control_cls, argname in zip( - (self.terrainStyle, self.textureStyle, self.resourceGenerator, self.propGenerator), - (TerrainStyle, TextureStyle, ResourceStyle, PropStyle), - ("--terrain-style", "--texture-style", "--resource-style", "--prop-style"), - ): - if control.currentData() == control_cls.RANDOM: - continue - args.append(argname) - args.append(control.currentData().value) - + for option in self.cmd_options: + if option.name == "map-size": + args.append("--map-size") + size_px = int(option.value() * 51.2) + args.append(str(size_px)) + elif option.active(): + args.extend(option.as_cmd_arg()) return args diff --git a/src/games/mapgenoptionsvalues.py b/src/games/mapgenoptionsvalues.py new file mode 100644 index 000000000..cdd40441d --- /dev/null +++ b/src/games/mapgenoptionsvalues.py @@ -0,0 +1,109 @@ +from enum import Enum +from operator import attrgetter +from typing import Any + + +class ValuesListableEnum(Enum): + + @classmethod + def values(cls) -> list[Any]: + return list(map(attrgetter("value"), iter(cls))) + + +class Sentinel(ValuesListableEnum): + RANDOM = "RANDOM" + + +class GenerationType(ValuesListableEnum): + CASUAL = "CASUAL" + TOURNAMENT = "TOURNAMENT" + BLIND = "BLIND" + UNEXPLORED = "UNEXPLORED" + + +class TerrainSymmetry(ValuesListableEnum): + POINT2 = "POINT2" + POINT3 = "POINT3" + POINT4 = "POINT4" + POINT5 = "POINT5" + POINT6 = "POINT6" + POINT7 = "POINT7" + POINT8 = "POINT8" + POINT9 = "POINT9" + POINT10 = "POINT10" + POINT11 = "POINT11" + POINT12 = "POINT12" + POINT13 = "POINT13" + POINT14 = "POINT14" + POINT15 = "POINT15" + POINT16 = "POINT16" + XZ = "XZ" + ZX = "ZX" + X = "X" + Z = "Z" + QUAD = "QUAD" + DIAG = "DIAG" + NONE = "NONE" + + +class MapStyle(ValuesListableEnum): + BASIC = "BASIC" + BIG_ISLANDS = "BIG_ISLANDS" + CENTER_LAKE = "CENTER_LAKE" + DROP_PLATEAU = "DROP_PLATEAU" + FLOODED = "FLOODED" + HIGH_RECLAIM = "HIGH_RECLAIM" + LAND_BRIDGE = "LAND_BRIDGE" + LITTLE_MOUNTAIN = "LITTLE_MOUNTAIN" + LOW_MEX = "LOW_MEX" + MOUNTAIN_RANGE = "MOUNTAIN_RANGE" + ONE_ISLAND = "ONE_ISLAND" + SMALL_ISLANDS = "SMALL_ISLANDS" + VALLEY = "VALLEY" + + +class TerrainStyle(ValuesListableEnum): + BASIC = "BASIC" + BIG_ISLANDS = "BIG_ISLANDS" + CENTER_LAKE = "CENTER_LAKE" + DROP_PLATEAU = "DROP_PLATEAU" + FLOODED = "FLOODED" + LAND_BRIDGE = "LAND_BRIDGE" + LITTLE_MOUNTAIN = "LITTLE_MOUNTAIN" + MOUNTAIN_RANGE = "MOUNTAIN_RANGE" + ONE_ISLAND = "ONE_ISLAND" + SMALL_ISLANDS = "SMALL_ISLANDS" + VALLEY = "VALLEY" + + +class PropStyle(ValuesListableEnum): + BASIC = "BASIC" + BOULDER_FIELD = "BOULDER_FIELD" + ENEMY_CIV = "ENEMY_CIV" + HIGH_RECLAIM = "HIGH_RECLAIM" + LARGE_BATTLE = "LARGE_BATTLE" + NAVY_WRECKS = "NAVY_WRECKS" + NEUTRAL_CIV = "NEUTRAL_CIV" + ROCK_FIELD = "ROCK_FIELD" + SMALL_BATTLE = "SMALL_BATTLE" + + +class ResourceStyle(ValuesListableEnum): + BASIC = "BASIC" + LOW_MEX = "LOW_MEX" + WATER_MEX = "WATER_MEX" + + +class TextureStyle(ValuesListableEnum): + BRIMSTONE = "BRIMSTONE" + DESERT = "DESERT" + EARLYAUTUMN = "EARLYAUTUMN" + FRITHEN = "FRITHEN" + MARS = "MARS" + MOONLIGHT = "MOONLIGHT" + PRAYER = "PRAYER" + STONES = "STONES" + SUNSET = "SUNSET" + SYRTIS = "SYRTIS" + WINDINGRIVER = "WINDINGRIVER" + WONDER = "WONDER" From 232c2fdb320094467880d6a9e9b1d2da46224620 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 1 Jul 2024 21:40:43 +0300 Subject: [PATCH 24/73] MapGen: Show busy indicator instead of steps we are unable to predefine number of steps it will take to generate a map and seeing progress stuck at on of the first steps when all the work is done in it is somewhat annoying (using --debug option to display more information about progress significantly (2x) slows down generation process, so we won't use that) --- src/mapGenerator/mapgenProcess.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/mapGenerator/mapgenProcess.py b/src/mapGenerator/mapgenProcess.py index d0b8e80e4..116167c86 100644 --- a/src/mapGenerator/mapgenProcess.py +++ b/src/mapGenerator/mapgenProcess.py @@ -6,6 +6,7 @@ from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QMessageBox +from PyQt6.QtWidgets import QProgressBar from PyQt6.QtWidgets import QProgressDialog import fafpath @@ -30,8 +31,11 @@ def __init__(self, gen_path, out_path, args): ) self._progress.setAutoReset(False) self._progress.setModal(1) - self._progress.setMinimum(0) - self._progress.setMaximum(30) + bar = QProgressBar() + bar.setMinimum(0) + bar.setMaximum(0) + bar.setTextVisible(False) + self._progress.setBar(bar) self._progress.canceled.connect(self.close) self.progressCounter = 1 @@ -89,8 +93,6 @@ def on_log_ready(self): # Kinda fake progress bar. Better than nothing :) if len(line) > 4: self._progress.setLabelText(line[:25] + "...") - self.progressCounter += 1 - self._progress.setValue(self.progressCounter) def on_error_ready(self) -> None: self._error_msgs_received += 1 From a4f675ab91c89b5bf397d8d92c54e67bfd4f060b Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 2 Jul 2024 20:43:21 +0300 Subject: [PATCH 25/73] Decouple HostGame widget and MapGenDialog (or at least try to do so) --- src/games/hostgamewidget.py | 12 +++++++++--- src/games/mapgenoptionsdialog.py | 17 +++++++++-------- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/games/hostgamewidget.py b/src/games/hostgamewidget.py index 99c1dabf0..3b36fa850 100644 --- a/src/games/hostgamewidget.py +++ b/src/games/hostgamewidget.py @@ -3,11 +3,11 @@ from PyQt6 import QtCore import fa.check -import games.mapgenoptionsdialog as MapGenDialog import util import vaults.modvault.utils from fa import maps from games.gamemodel import GameModel +from games.mapgenoptionsdialog import MapGenDialog from model.game import Game from model.game import GameState from model.game import GameVisibility @@ -252,9 +252,15 @@ def save_last_hosted_settings(self, password): util.settings.endGroup() @QtCore.pyqtSlot() - def generateMap(self): - dialog = MapGenDialog.MapGenDialog(self) + def generateMap(self) -> None: + dialog = MapGenDialog(self.client.map_generator) + dialog.map_generated.connect(self.on_map_generated) dialog.exec() + dialog.deleteLater() + + def on_map_generated(self, mapname: str) -> None: + self.setupMapList() + self.set_map(mapname) def build_launcher(playerset, me, client, view_builder, map_preview_dler): diff --git a/src/games/mapgenoptionsdialog.py b/src/games/mapgenoptionsdialog.py index c88249b51..1b1215743 100644 --- a/src/games/mapgenoptionsdialog.py +++ b/src/games/mapgenoptionsdialog.py @@ -16,12 +16,15 @@ from games.mapgenoptionsvalues import TerrainStyle from games.mapgenoptionsvalues import TerrainSymmetry from games.mapgenoptionsvalues import TextureStyle +from mapGenerator.mapgenManager import MapGeneratorManager FormClass, BaseClass = util.THEME.loadUiType("games/mapgen.ui") class MapGenDialog(FormClass, BaseClass): - def __init__(self, parent, *args, **kwargs): + map_generated = QtCore.pyqtSignal(str) + + def __init__(self, mapgen_manager: MapGeneratorManager, *args, **kwargs) -> None: BaseClass.__init__(self, *args, **kwargs) self.setupUi(self) @@ -30,7 +33,7 @@ def __init__(self, parent, *args, **kwargs): self.load_stylesheet() - self.parent = parent + self.mapgen_manager = mapgen_manager self.useCustomStyleCheckBox.checkStateChanged.connect(self.on_custom_style) self.generationType.currentTextChanged.connect(self.gen_type_changed) @@ -164,13 +167,11 @@ def reset_mapgen_prefs(self) -> None: @QtCore.pyqtSlot() def generate_map(self) -> None: - map_ = self.parent.client.map_generator.generateMap( - args=self.set_arguments(), - ) - if map_: - self.parent.setupMapList() - self.parent.set_map(map_) + if result := self.mapgen_manager.generateMap(args=self.set_arguments()): + self.map_generated.emit(result) self.save_preferences_and_quit() + else: + self.save_preferences() def set_arguments(self) -> list[str]: args = [] From 2e26a3f3a7a928be784d6418950027ec666dbe4a Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 3 Jul 2024 20:31:31 +0300 Subject: [PATCH 26/73] Allow map generation by mapname --- res/games/mapgen.ui | 1052 ++++++++++++++++-------------- src/games/mapgenoptionsdialog.py | 23 +- 2 files changed, 595 insertions(+), 480 deletions(-) diff --git a/res/games/mapgen.ui b/res/games/mapgen.ui index 8d96caf0f..6904f4548 100644 --- a/res/games/mapgen.ui +++ b/res/games/mapgen.ui @@ -6,7 +6,7 @@ 0 0 - 584 + 601 475 @@ -14,15 +14,15 @@ Map Generator Options - - + + QFrame::StyledPanel QFrame::Raised - + 0 @@ -35,41 +35,57 @@ 0 - - 0 - - - - 0 - - - 0 - - - 0 + + + 6 - - - 6 - + - + 10 - Symmetry + Gerenation Type - + + + + 10 + + + + + + + + + + + + + + + + 10 + + + + Map Size (km) + + + + + - + 0 0 @@ -79,66 +95,43 @@ 10 + + false + + + 2.500000000000000 + + + 80.000000000000000 + + + 1.250000000000000 + + + 5.000000000000000 + - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - 9 - - - - Qt::Vertical - - - - 451 - 20 - - - - - - - - - - 10 - + - + 10 - Map Style + Spawns - + - + 0 0 @@ -148,37 +141,54 @@ 10 + + 1 + + + 1000 + + + 2 + - - - Qt::Horizontal - - - QSizePolicy::MinimumExpanding - - - - 0 - 0 - - - - - - + - + 10 - Use Custom Style + Teams + + + + + + + + 0 + 0 + + + + + 10 + + + + 1 + + + 1000 + + + 2 @@ -186,283 +196,537 @@ - - - - - 10 - + + + + QFrame::StyledPanel - - Custom Style Options + + QFrame::Raised - - - - - - 10 - - - - + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + - - - - 10 - true - + + + 0 - - Terrain + + 0 + + + 0 - - - - - - - - - 10 - - - - From: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - - - - - - 10 - - - - 100 - - - 10 - - - - - - - 10 - - - - To: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 6 - + + + + + 10 + + + + Symmetry + + + + + + + + 0 + 0 + + + + + 10 + + + + + - - - - 10 - + + + Qt::Horizontal - - 100 + + + 40 + 20 + - - 10 - - - 100 - - + - - + + - 10 - true + 9 - - Resource Generator + + Qt::Vertical - + + + 451 + 20 + + + - - - - - - - 10 - - - - From: - - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter - - - + + - - - - 10 - - - - 100 - - + + 10 - + + + + + 10 + + + + Map Style + + + + + + + + 0 + 0 + + + + + 10 + + + + + - - - - 10 - + + + Qt::Horizontal - - To: + + QSizePolicy::MinimumExpanding - - Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + 0 + 0 + - + - - - - 10 - - - - 100 - - - 10 - - - 100 - - + + + + + + 10 + + + + Use Custom Style + + + + - - - - - - - - 10 - true - - - - Prop Generator - - - - - - - 10 - true - - - - Resources Density - - - - - - - - 10 - - - - - - - - - 10 - - - - - - - - - 10 - true - - - - Reclaim Density - - - - - + 10 - true - - Texture + + Custom Style Options + + + + + + 10 + + + + + + + + + + + 10 + + + + From: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 10 + + + + 100 + + + 10 + + + + + + + + 10 + + + + To: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 10 + + + + 100 + + + 10 + + + 100 + + + + + + + + + + 10 + true + + + + Terrain + + + + + + + + 10 + true + + + + Resources Density + + + + + + + + 10 + + + + + + + + + 10 + true + + + + Prop Generator + + + + + + + + 10 + + + + + + + + + 10 + true + + + + Reclaim Density + + + + + + + + 10 + true + + + + Resource Generator + + + + + + + + 10 + true + + + + Texture + + + + + + + + + + + + + 10 + + + + From: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 10 + + + + 100 + + + 10 + + + + + + + + 10 + + + + To: + + + Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter + + + + + + + + 10 + + + + 100 + + + 10 + + + 100 + + + + + + + + + + 9 + + + + Qt::Vertical + + + + 20 + 40 + + + + + - - - - - 9 - - - - Qt::Vertical - - - - 20 - 40 - - - - + + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 15 + + + + + + 0 + 0 + + + + + 10 + + + + Generate by mapname + + + + + + + + 0 + 0 + + + + + 16777215 + 30 + + + + + 10 + + + + neroxis_map_generator_(version)_(seed)_(options) + + + + + + + + @@ -570,164 +834,6 @@ - - - - - - - - - 10 - - - - Gerenation Type - - - - - - - - 10 - - - - - - - - - - - - - - - - 10 - - - - Map Size (km) - - - - - - - - 0 - 0 - - - - - 10 - - - - false - - - 2.500000000000000 - - - 80.000000000000000 - - - 1.250000000000000 - - - 5.000000000000000 - - - - - - - - - - - - 10 - - - - Spawns - - - - - - - - 0 - 0 - - - - - 10 - - - - 1 - - - 1000 - - - 2 - - - - - - - - - - - - 10 - - - - Teams - - - - - - - - 0 - 0 - - - - - 10 - - - - 1 - - - 1000 - - - 2 - - - - - - - diff --git a/src/games/mapgenoptionsdialog.py b/src/games/mapgenoptionsdialog.py index 1b1215743..8644ebbef 100644 --- a/src/games/mapgenoptionsdialog.py +++ b/src/games/mapgenoptionsdialog.py @@ -35,6 +35,7 @@ def __init__(self, mapgen_manager: MapGeneratorManager, *args, **kwargs) -> None self.mapgen_manager = mapgen_manager + self.mapNamePlainTextEdit.textChanged.connect(self.user_mapname_changed) self.useCustomStyleCheckBox.checkStateChanged.connect(self.on_custom_style) self.generationType.currentTextChanged.connect(self.gen_type_changed) self.mapSize.valueChanged.connect(self.map_size_changed) @@ -101,6 +102,11 @@ def __init__(self, mapgen_manager: MapGeneratorManager, *args, **kwargs) -> None ] self.load_preferences() + @QtCore.pyqtSlot() + def user_mapname_changed(self) -> None: + mapname = self.mapNamePlainTextEdit.toPlainText() + self.optionsFrame.setEnabled(mapname.strip() == "") + @QtCore.pyqtSlot(QtCore.Qt.CheckState) def on_custom_style(self, state: QtCore.Qt.CheckState) -> None: self.customStyleGroupBox.setEnabled(state == QtCore.Qt.CheckState.Checked) @@ -175,11 +181,14 @@ def generate_map(self) -> None: def set_arguments(self) -> list[str]: args = [] - for option in self.cmd_options: - if option.name == "map-size": - args.append("--map-size") - size_px = int(option.value() * 51.2) - args.append(str(size_px)) - elif option.active(): - args.extend(option.as_cmd_arg()) + if mapname := self.mapNamePlainTextEdit.toPlainText().strip(): + args.extend(["--map-name", mapname]) + else: + for option in self.cmd_options: + if option.name == "map-size": + args.append("--map-size") + size_px = int(option.value() * 51.2) + args.append(str(size_px)) + elif option.active(): + args.extend(option.as_cmd_arg()) return args From dd68e02e58ea0ce75794d3637c85c292c12ce220 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 4 Jul 2024 10:20:53 +0300 Subject: [PATCH 27/73] PlayerCard: Handle empty avatar images some avatars' urls return 404 --- src/playercard/playerinfodialog.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 7af89e7f0..0725973dd 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -242,7 +242,10 @@ def _add_avatar(self, avatar: Avatar) -> None: self._add_avatar_item(self.avatar_dler.get_avatar(avatar.filename), avatar.tooltip) def _add_avatar_item(self, pixmap: QPixmap, description: str) -> None: - icon = QIcon(pixmap.scaled(40, 20)) + if pixmap.isNull(): + icon = util.THEME.icon("chat/avatar/avatar_blank.png") + else: + icon = QIcon(pixmap.scaled(40, 20)) avatar_item = QListWidgetItem(icon, description) self.avatar_list.addItem(avatar_item) From 5c3c19c9a2d33f2255fe6a81dcc24c88285d23a1 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 5 Jul 2024 23:06:35 +0300 Subject: [PATCH 28/73] Add player names to player card --- res/player_card/playercard.ui | 39 ++++++++++++++++++++++++++++++ src/api/models/NameRecord.py | 8 ++++++ src/api/models/Player.py | 2 ++ src/api/player_api.py | 2 +- src/playercard/playerinfodialog.py | 14 +++++++++++ 5 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 src/api/models/NameRecord.py diff --git a/res/player_card/playercard.ui b/res/player_card/playercard.ui index 31c03b96a..218dd47a7 100644 --- a/res/player_card/playercard.ui +++ b/res/player_card/playercard.ui @@ -216,6 +216,45 @@ + + + Previous Names + + + + + + QAbstractItemView::NoEditTriggers + + + true + + + QAbstractItemView::SelectRows + + + false + + + true + + + false + + + + Name + + + + + Used until + + + + + + diff --git a/src/api/models/NameRecord.py b/src/api/models/NameRecord.py new file mode 100644 index 000000000..970d627cd --- /dev/null +++ b/src/api/models/NameRecord.py @@ -0,0 +1,8 @@ +from api.models.ConfiguredModel import ConfiguredModel +from pydantic import Field + + +class NameRecord(ConfiguredModel): + xd: str = Field(alias="id") + change_time: str = Field(alias="changeTime") + name: str diff --git a/src/api/models/Player.py b/src/api/models/Player.py index defab8132..37b9a8a6c 100644 --- a/src/api/models/Player.py +++ b/src/api/models/Player.py @@ -2,6 +2,7 @@ from api.models.AbstractEntity import AbstractEntity from api.models.AvatarAssignment import AvatarAssignment +from api.models.NameRecord import NameRecord from pydantic import Field @@ -10,3 +11,4 @@ class Player(AbstractEntity): user_agent: str | None = Field(alias="userAgent") avatar_assignments: list[AvatarAssignment] | None = Field(None, alias="avatarAssignments") + names: list[NameRecord] | None = Field(None) diff --git a/src/api/player_api.py b/src/api/player_api.py index 312ca5110..5270ea19d 100644 --- a/src/api/player_api.py +++ b/src/api/player_api.py @@ -31,7 +31,7 @@ def handleDataForAliasViewer(self, message: dict) -> None: def request_player(self, player_id: str) -> None: query = { - "include": "avatarAssignments.avatar", + "include": "avatarAssignments.avatar,names", "filter": f"id=={player_id}", } self.get_by_query(query, self.handle_player) diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 0725973dd..b578ea4bb 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -15,6 +15,7 @@ from PyQt6.QtGui import QPixmap from PyQt6.QtWidgets import QListWidget from PyQt6.QtWidgets import QListWidgetItem +from PyQt6.QtWidgets import QTableWidgetItem from PyQt6.QtWidgets import QTabWidget from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem @@ -24,6 +25,7 @@ from api.models.Leaderboard import Leaderboard from api.models.LeaderboardRating import LeaderboardRating from api.models.LeaderboardRatingJournal import LeaderboardRatingJournal +from api.models.NameRecord import NameRecord from api.models.Player import Player from api.player_api import PlayerApiConnector from api.stats_api import LeaderboardApiConnector @@ -207,6 +209,18 @@ def process_player(self, player: Player) -> None: last_login = QDateTime.fromString(player.update_time, Qt.DateFormat.ISODate).toLocalTime() self.lastLoginLabel.setText(last_login.toString("yyyy-MM-dd hh:mm")) self.add_avatars(player.avatar_assignments) + self.add_names(player.names) + + def add_names(self, names: list[NameRecord] | None) -> None: + if names is None: + return + self.tableWidget.setRowCount(len(names)) + for row, name_record in enumerate(names): + name = QTableWidgetItem(name_record.name) + change_time = QDateTime.fromString(name_record.change_time, Qt.DateFormat.ISODate) + used_until = QTableWidgetItem(change_time.toString("yyyy-MM-dd hh:mm")) + self.tableWidget.setItem(row, 0, name) + self.tableWidget.setItem(row, 1, used_until) def add_avatars(self, avatar_assignments: list[AvatarAssignment] | None) -> None: self.avatar_handler.populate_avatars(avatar_assignments) From 72dcbd747e592c8e0883e0419fd857be7fb4aa02 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 6 Jul 2024 20:39:19 +0300 Subject: [PATCH 29/73] Add very important CSS controls --- res/client/client.css | 88 +++++++++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/res/client/client.css b/res/client/client.css index a1d895fa7..118768b3c 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -382,6 +382,21 @@ QTextEdit, QPlainTextEdit, QLineEdit, QListWidget, QListView, QTableWidget, QTre border-bottom-right-radius : 5px; } +QPlainTextEdit#mapNamePlainTextEdit +{ + border-width:0px; + border-style:solid; + border-color:#353535; + color:orange; + padding:0px; + background-color:#202025; + alternate-background-color: #303035; + border-top-right-radius : 0px; + border-top-left-radius : 0px; + border-bottom-left-radius : 0px; + border-bottom-right-radius : 0px; +} + QTableView { border-style:solid; @@ -711,6 +726,11 @@ QLabel color: silver; } +QLabel::disabled +{ + color: gray; +} + /* Used for dialogs*/ QDialog { @@ -732,34 +752,34 @@ QCheckBox#spoilerCheckbox, #automaticCheckbox, #showLatestCheckbox, #hideUnrChec QCheckBox#spoilerCheckbox::indicator:unchecked, #automaticCheckbox::indicator:unchecked, #showLatestCheckbox::indicator:unchecked, #hideUnrCheckbox::indicator:unchecked, #matchUsernameCheckbox::indicator:unchecked, -#landRandomDensity::indicator::unchecked, #plateausRandomDensity::indicator::unchecked, #mountainsRandomDensity::indicator::unchecked, +#landRandomDensity::indicator::unchecked, #resourceRandomGenerator::indicator::unchecked, #mountainsRandomDensity::indicator::unchecked, #rampsRandomDensity::indicator::unchecked, #mexRandomDensity::indicator::unchecked ,#reclaimRandomDensity::indicator::unchecked { image: url('%THEMEPATH%/client/chboxUncheked.png'); } -QCheckBox#spoilerCheckbox::indicator:checked, #automaticCheckbox::indicator:checked, #showLatestCheckbox::indicator:checked, -#hideUnrCheckbox::indicator:checked, #matchUsernameCheckbox::indicator:checked, -#landRandomDensity::indicator::checked, #plateausRandomDensity::indicator::checked, #mountainsRandomDensity::indicator::checked, -#rampsRandomDensity::indicator::checked, #mexRandomDensity::indicator::checked ,#reclaimRandomDensity::indicator::checked -{ - image: url('%THEMEPATH%/client/chboxCheked.png'); - } - - QCheckBox::enabled + QCheckBox#spoilerCheckbox::indicator:checked, #automaticCheckbox::indicator:checked, #showLatestCheckbox::indicator:checked, + #hideUnrCheckbox::indicator:checked, #matchUsernameCheckbox::indicator:checked, + #landRandomDensity::indicator::checked, #plateausRandomDensity::indicator::checked, #mountainsRandomDensity::indicator::checked, + #rampsRandomDensity::indicator::checked, #mexRandomDensity::indicator::checked ,#reclaimRandomDensity::indicator::checked { - color: silver; - } + image: url('%THEMEPATH%/client/chboxCheked.png'); + } - QCheckBox::indicator:unchecked - { - image: url('%THEMEPATH%/client/chboxUnchecked.png'); - } +QCheckBox::enabled +{ + color: silver; +} - QCheckBox::indicator:checked - { - image: url('%THEMEPATH%/client/chboxChecked.png'); - } +QCheckBox::indicator:unchecked +{ + image: url('%THEMEPATH%/client/chboxUncheked.png'); +} + +QCheckBox::indicator:checked +{ + image: url('%THEMEPATH%/client/chboxCheked.png'); +} /* Used for Ranked Buttons only at the moment*/ QToolButton#rankedPlay @@ -909,6 +929,29 @@ QPushButton#showAllButton border-radius: 2px; } +QSpinBox +{ + color:orange; + selection-color:orange; + background-color: #575656; + selection-background-color: #575656; + border: 1 solid; + border-color: #555450; + +} + +QSpinBox::disabled +{ + color:gray; + selection-color:gray; + background-color: #575656; + selection-background-color: #575656; + border: 1 solid; + border-color: #555450; + +} + + QComboBox, QComboBox:selected, QDoubleSpinBox#mapSize { color:orange; @@ -917,6 +960,11 @@ QComboBox, QComboBox:selected, QDoubleSpinBox#mapSize selection-background-color: #575656; } +QComboBox::disabled +{ + color:gray; +} + QDoubleSpinBox#mapSize { border: 1 solid; From b1ba224536f9d2015b7a292c1b12f78b320acad3 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 7 Jul 2024 13:39:53 +0300 Subject: [PATCH 30/73] Disable highliting on hover for name history table --- res/client/client.css | 6 ++++++ res/player_card/playercard.ui | 5 ++++- src/playercard/playerinfodialog.py | 6 +++--- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/res/client/client.css b/res/client/client.css index 118768b3c..e1dfee384 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -482,6 +482,12 @@ QTableWidget::item::hover border-radius: 3px; } +QTableWidget#nameHistoryTableWidget::item::hover +{ + background: none; + border-radius: 0px; +} + QTableWidget::item:selected, QListWidget::item:previously-selected, QListView::item:previously-selected { diff --git a/res/player_card/playercard.ui b/res/player_card/playercard.ui index 218dd47a7..cce809526 100644 --- a/res/player_card/playercard.ui +++ b/res/player_card/playercard.ui @@ -222,13 +222,16 @@ - + QAbstractItemView::NoEditTriggers true + + QAbstractItemView::NoSelection + QAbstractItemView::SelectRows diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index b578ea4bb..3982d2136 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -214,13 +214,13 @@ def process_player(self, player: Player) -> None: def add_names(self, names: list[NameRecord] | None) -> None: if names is None: return - self.tableWidget.setRowCount(len(names)) + self.nameHistoryTableWidget.setRowCount(len(names)) for row, name_record in enumerate(names): name = QTableWidgetItem(name_record.name) change_time = QDateTime.fromString(name_record.change_time, Qt.DateFormat.ISODate) used_until = QTableWidgetItem(change_time.toString("yyyy-MM-dd hh:mm")) - self.tableWidget.setItem(row, 0, name) - self.tableWidget.setItem(row, 1, used_until) + self.nameHistoryTableWidget.setItem(row, 0, name) + self.nameHistoryTableWidget.setItem(row, 1, used_until) def add_avatars(self, avatar_assignments: list[AvatarAssignment] | None) -> None: self.avatar_handler.populate_avatars(avatar_assignments) From 9dbd3551d785f6ebec86f160149d50dca1778ac9 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 8 Jul 2024 06:28:37 +0300 Subject: [PATCH 31/73] Fix SpinBoxes styles --- res/client/client.css | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/res/client/client.css b/res/client/client.css index e1dfee384..d700f0ace 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -759,18 +759,20 @@ QCheckBox#spoilerCheckbox, #automaticCheckbox, #showLatestCheckbox, #hideUnrChec QCheckBox#spoilerCheckbox::indicator:unchecked, #automaticCheckbox::indicator:unchecked, #showLatestCheckbox::indicator:unchecked, #hideUnrCheckbox::indicator:unchecked, #matchUsernameCheckbox::indicator:unchecked, #landRandomDensity::indicator::unchecked, #resourceRandomGenerator::indicator::unchecked, #mountainsRandomDensity::indicator::unchecked, -#rampsRandomDensity::indicator::unchecked, #mexRandomDensity::indicator::unchecked ,#reclaimRandomDensity::indicator::unchecked +#rampsRandomDensity::indicator::unchecked, #mexRandomDensity::indicator::unchecked ,#reclaimRandomDensity::indicator::unchecked, +#useCustomStyleCheckBox::indicator::unchecked { image: url('%THEMEPATH%/client/chboxUncheked.png'); - } +} QCheckBox#spoilerCheckbox::indicator:checked, #automaticCheckbox::indicator:checked, #showLatestCheckbox::indicator:checked, #hideUnrCheckbox::indicator:checked, #matchUsernameCheckbox::indicator:checked, #landRandomDensity::indicator::checked, #plateausRandomDensity::indicator::checked, #mountainsRandomDensity::indicator::checked, - #rampsRandomDensity::indicator::checked, #mexRandomDensity::indicator::checked ,#reclaimRandomDensity::indicator::checked - { - image: url('%THEMEPATH%/client/chboxCheked.png'); - } + #rampsRandomDensity::indicator::checked, #mexRandomDensity::indicator::checked ,#reclaimRandomDensity::indicator::checked, + #useCustomStyleCheckBox::indicator::checked +{ + image: url('%THEMEPATH%/client/chboxCheked.png'); +} QCheckBox::enabled { @@ -779,12 +781,12 @@ QCheckBox::enabled QCheckBox::indicator:unchecked { - image: url('%THEMEPATH%/client/chboxUncheked.png'); + image: url('%THEMEPATH%/client/chboxUnchecked.png'); } QCheckBox::indicator:checked { - image: url('%THEMEPATH%/client/chboxCheked.png'); + image: url('%THEMEPATH%/client/chboxChecked.png'); } /* Used for Ranked Buttons only at the moment*/ @@ -935,7 +937,7 @@ QPushButton#showAllButton border-radius: 2px; } -QSpinBox +QSpinBox, QDoubleSpinBox { color:orange; selection-color:orange; @@ -946,7 +948,7 @@ QSpinBox } -QSpinBox::disabled +QSpinBox::disabled, QDoubleSpinBox::disabled { color:gray; selection-color:gray; @@ -958,7 +960,7 @@ QSpinBox::disabled } -QComboBox, QComboBox:selected, QDoubleSpinBox#mapSize +QComboBox, QComboBox:selected { color:orange; selection-color:orange; @@ -971,13 +973,6 @@ QComboBox::disabled color:gray; } -QDoubleSpinBox#mapSize -{ - border: 1 solid; - border-color: #555450; - selection-color: white; - selection-background-color: slateblue; -} QComboBox QAbstractItemView { border: 2px solid darkgray; From b86310173b2dda414a5c3760d94140b60b3a1d03 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 9 Jul 2024 20:54:49 +0300 Subject: [PATCH 32/73] Move 'Show user info' action to the top --- src/chat/chatter_menu.py | 7 ++++++- src/stats/itemviews/leaderboardtablemenu.py | 13 +++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/chat/chatter_menu.py b/src/chat/chatter_menu.py index d59025a73..3df9c7670 100644 --- a/src/chat/chatter_menu.py +++ b/src/chat/chatter_menu.py @@ -1,4 +1,5 @@ import logging +from collections.abc import Generator from enum import Enum from PyQt6.QtGui import QAction @@ -67,6 +68,7 @@ def actions(self, cc): else: is_me = player.id == self._me.player.id + yield list(self.user_actions(player)) yield list(self.me_actions(is_me)) yield list(self.power_actions(self._power_tools.power)) yield list(self.chatter_actions()) @@ -75,6 +77,10 @@ def actions(self, cc): yield list(self.ignore_actions(player, chatter, cc, is_me)) yield list(self.party_actions(player, is_me)) + def user_actions(self, player: Player | None) -> Generator[ChatterMenuItems, None, None]: + if player is not None: + yield ChatterMenuItems.SHOW_USER_INFO + def chatter_actions(self): yield ChatterMenuItems.COPY_USERNAME yield ChatterMenuItems.VIEW_ALIASES @@ -97,7 +103,6 @@ def player_actions(self, player, game, is_me): yield ChatterMenuItems.VIEW_LIVEREPLAY if player is not None: - yield ChatterMenuItems.SHOW_USER_INFO if player.ladder_estimate != 0: yield ChatterMenuItems.VIEW_IN_LEADERBOARDS yield ChatterMenuItems.VIEW_REPLAYS diff --git a/src/stats/itemviews/leaderboardtablemenu.py b/src/stats/itemviews/leaderboardtablemenu.py index 3b717f6d6..10da8876b 100644 --- a/src/stats/itemviews/leaderboardtablemenu.py +++ b/src/stats/itemviews/leaderboardtablemenu.py @@ -1,3 +1,4 @@ +from collections.abc import Generator from enum import Enum from PyQt6 import QtWidgets @@ -27,9 +28,13 @@ def __init__(self, parent, client, leaderboardName): def build(cls, parent, client, leaderboardName): return cls(parent, client, leaderboardName) - def actions(self, name, uid): + def actions( + self, + name: str, + uid: str, + ) -> Generator[list[LeaderboardTableMenuItems], None, None]: + yield list(self.player_actions()) yield list(self.usernameActions()) - yield list(self.playerActions()) if self.client.me.player is None: return @@ -41,9 +46,9 @@ def usernameActions(self): yield LeaderboardTableMenuItems.COPY_USERNAME yield LeaderboardTableMenuItems.VIEW_ALIASES - def playerActions(self): - yield LeaderboardTableMenuItems.VIEW_REPLAYS + def player_actions(self) -> Generator[LeaderboardTableMenuItems, None, None]: yield LeaderboardTableMenuItems.SHOW_USER_INFO + yield LeaderboardTableMenuItems.VIEW_REPLAYS def friendActions(self, name, uid, is_me): if is_me: From eaae92790fa5f3f54840934e8338f10b5fae4680 Mon Sep 17 00:00:00 2001 From: Gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 10 Jul 2024 19:56:37 +0300 Subject: [PATCH 33/73] MapGen: Don't allow to set densities when respective generator is random --- src/games/mapgenoptionsdialog.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/games/mapgenoptionsdialog.py b/src/games/mapgenoptionsdialog.py index 8644ebbef..e6ac5cedf 100644 --- a/src/games/mapgenoptionsdialog.py +++ b/src/games/mapgenoptionsdialog.py @@ -39,6 +39,8 @@ def __init__(self, mapgen_manager: MapGeneratorManager, *args, **kwargs) -> None self.useCustomStyleCheckBox.checkStateChanged.connect(self.on_custom_style) self.generationType.currentTextChanged.connect(self.gen_type_changed) self.mapSize.valueChanged.connect(self.map_size_changed) + self.propGenerator.currentTextChanged.connect(self.prop_generator_changed) + self.resourceGenerator.currentTextChanged.connect(self.resource_generator_changed) self.generateMapButton.clicked.connect(self.generate_map) self.saveMapGenSettingsButton.clicked.connect(self.save_preferences_and_quit) self.resetMapGenSettingsButton.clicked.connect(self.reset_mapgen_prefs) @@ -140,6 +142,16 @@ def map_size_changed(self, value): def gen_type_changed(self, text: str) -> None: self.casualOptionsFrame.setEnabled(text == GenerationType.CASUAL.value) + @QtCore.pyqtSlot(str) + def resource_generator_changed(self, text: str) -> None: + self.minResourceDensity.setEnabled(text != Sentinel.RANDOM.value) + self.maxResourceDensity.setEnabled(text != Sentinel.RANDOM.value) + + @QtCore.pyqtSlot(str) + def prop_generator_changed(self, text: str) -> None: + self.minReclaimDensity.setEnabled(text != Sentinel.RANDOM.value) + self.maxReclaimDensity.setEnabled(text != Sentinel.RANDOM.value) + @QtCore.pyqtSlot() def load_preferences(self) -> None: for option in self.cmd_options: From cc535c58239b0683d8cea45e83dfc139981226b9 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 11 Jul 2024 21:27:01 +0300 Subject: [PATCH 34/73] Add pyqtgraph to requirements which is used since 1dd7441f9bf5b4c85d3d0fa6b3b2a77933992bee but wasn't added to dependencies --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index ae30a7404..aa5f3fb49 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,6 +7,7 @@ pathlib pydantic pyqt6 pyqt6-networkauth +pyqtgraph pytest pytest-cov pytest-mock From 049a3c8dcf98aa303c585d3789fb4777be09f377 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 12 Jul 2024 23:46:18 +0300 Subject: [PATCH 35/73] Add statistics charts to player card as in java client, but these work --- requirements.txt | 1 + res/client/client.css | 6 +- res/player_card/playercard.ui | 51 ++++++++ src/api/models/ConfiguredModel.py | 7 +- src/api/models/Event.py | 9 ++ src/api/models/PlayerEvent.py | 11 ++ src/api/stats_api.py | 18 +++ src/playercard/events.py | 116 +++++++++++++++++ src/playercard/playerinfodialog.py | 17 ++- src/playercard/statistics.py | 199 +++++++++++++++++++++++++++++ 10 files changed, 430 insertions(+), 5 deletions(-) create mode 100644 src/api/models/Event.py create mode 100644 src/api/models/PlayerEvent.py create mode 100644 src/playercard/events.py create mode 100644 src/playercard/statistics.py diff --git a/requirements.txt b/requirements.txt index aa5f3fb49..1d2d1bc17 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,6 +6,7 @@ jinja2 pathlib pydantic pyqt6 +pyqt6-charts pyqt6-networkauth pyqtgraph pytest diff --git a/res/client/client.css b/res/client/client.css index d700f0ace..5bb808fda 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -992,8 +992,12 @@ QFrame#settingsFrame { border-bottom-right-radius : 5px; } - #line { background-color: #202025; } + +QWidget#scrollAreaWidgetContents +{ + background-color: #383838; +} diff --git a/res/player_card/playercard.ui b/res/player_card/playercard.ui index cce809526..d7caae5b2 100644 --- a/res/player_card/playercard.ui +++ b/res/player_card/playercard.ui @@ -248,16 +248,67 @@ Name + + AlignLeading|AlignVCenter + Used until + + AlignLeading|AlignVCenter + + + + Statistics + + + + + + true + + + + + 0 + 0 + 817 + 648 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 10 + + + + + + + + + diff --git a/src/api/models/ConfiguredModel.py b/src/api/models/ConfiguredModel.py index 7c34e4c26..1154f55ce 100644 --- a/src/api/models/ConfiguredModel.py +++ b/src/api/models/ConfiguredModel.py @@ -10,9 +10,10 @@ class ConfiguredModel(BaseModel): @field_validator("*", mode="before") @classmethod - def ensure_not_empty_or_none(cls, v: Any) -> Any: - if isinstance(v, dict) and not v: - return None + def ensure_included_and_not_empty_or_none(cls, v: Any) -> Any: + if isinstance(v, dict): + if not v or ("id" in v and "type" in v and len(v) == 2): + return None elif isinstance(v, list) and not v: return None return v diff --git a/src/api/models/Event.py b/src/api/models/Event.py new file mode 100644 index 000000000..c6f3b8958 --- /dev/null +++ b/src/api/models/Event.py @@ -0,0 +1,9 @@ +from api.models.ConfiguredModel import ConfiguredModel +from pydantic import Field + + +class Event(ConfiguredModel): + xd: str = Field(alias="id") + name: str + image_url: str | None = Field(alias="imageUrl") + typ: str = Field(alias="type") diff --git a/src/api/models/PlayerEvent.py b/src/api/models/PlayerEvent.py new file mode 100644 index 000000000..64ee39f6b --- /dev/null +++ b/src/api/models/PlayerEvent.py @@ -0,0 +1,11 @@ +from api.models.AbstractEntity import AbstractEntity +from api.models.Event import Event +from api.models.Player import Player +from pydantic import Field + + +class PlayerEvent(AbstractEntity): + current_count: int = Field(alias="currentCount") + + event: Event | None = Field(None) + player: Player | None = Field(None) diff --git a/src/api/stats_api.py b/src/api/stats_api.py index a744c47eb..018f42794 100644 --- a/src/api/stats_api.py +++ b/src/api/stats_api.py @@ -8,6 +8,7 @@ from api.models.Leaderboard import Leaderboard from api.models.LeaderboardRatingJournal import LeaderboardRatingJournal from api.models.LeagueSeasonScore import LeagueSeasonScore +from api.models.PlayerEvent import PlayerEvent from api.parsers.LeaderboardParser import LeaderboardParser from api.parsers.LeaderboardRatingJournalParser import LeaderboardRatingJournalParser from api.parsers.LeaderboardRatingParser import LeaderboardRatingParser @@ -109,3 +110,20 @@ def get_player_score_in_leaderboard(self, player_id: str, leaderboard: str) -> N ) query_params = {"include": ",".join(include), "filter": ";".join(filters)} self.get_by_query(query_params, self.handle_score) + + +class PlayerEventApiAccessor(DataApiAccessor): + events_ready = pyqtSignal(list) + + def __init__(self) -> None: + super().__init__("/data/playerEvent") + + def get_player_events(self, player_id: str) -> None: + query = { + "include": "event", + "filter": f"player.id=={player_id}", + } + self.get_by_query(query, self.handle_player_events) + + def handle_player_events(self, message: dict) -> None: + self.events_ready.emit([PlayerEvent(**entry) for entry in message["data"]]) diff --git a/src/playercard/events.py b/src/playercard/events.py new file mode 100644 index 000000000..a74c29972 --- /dev/null +++ b/src/playercard/events.py @@ -0,0 +1,116 @@ +from enum import Enum +from typing import NamedTuple + +from api.models.PlayerEvent import PlayerEvent +from fa.factions import Factions + + +class PlayerEvents(Enum): + EVENT_LOST_ACUS = "d6a699b7-99bc-4a7f-b128-15e1e289a7b3" + EVENT_BUILT_AIR_UNITS = "3ebb0c4d-5e92-4446-bf52-d17ba9c5cd3c" + EVENT_LOST_AIR_UNITS = "225e9b2e-ae09-4ae1-a198-eca8780b0fcd" + EVENT_BUILT_LAND_UNITS = "ea123d7f-bb2e-4a71-bd31-88859f0c3c00" + EVENT_LOST_LAND_UNITS = "a1a3fd33-abe2-4e56-800a-b72f4c925825" + EVENT_BUILT_NAVAL_UNITS = "b5265b42-1747-4ba1-936c-292202637ce6" + EVENT_LOST_NAVAL_UNITS = "3a7b3667-0f79-4ac7-be63-ba841fd5ef05" + EVENT_BUILT_TECH_1_UNITS = "a8ee4f40-1e30-447b-bc2c-b03065219795" + EVENT_LOST_TECH_1_UNITS = "3dd3ed78-ce78-4006-81fd-10926738fbf3" + EVENT_BUILT_TECH_2_UNITS = "89d4f391-ed2d-4beb-a1ca-6b93db623c04" + EVENT_LOST_TECH_2_UNITS = "aebd750b-770b-4869-8e37-4d4cfdc480d0" + EVENT_BUILT_TECH_3_UNITS = "92617974-8c1f-494d-ab86-65c2a95d1486" + EVENT_LOST_TECH_3_UNITS = "7f15c2be-80b7-4573-8f41-135f84773e0f" + EVENT_BUILT_EXPERIMENTALS = "ed9fd79d-5ec7-4243-9ccf-f18c4f5baef1" + EVENT_LOST_EXPERIMENTALS = "701ca426-0943-4931-85af-6a08d36d9aaa" + EVENT_BUILT_ENGINEERS = "60bb1fc0-601b-45cd-bd26-83b1a1ac979b" + EVENT_LOST_ENGINEERS = "e8e99a68-de1b-4676-860d-056ad2207119" + EVENT_AEON_PLAYS = "96ccc66a-c5a0-4f48-acaa-888b00778b57" + EVENT_AEON_WINS = "a6b51c26-64e6-4e7a-bda7-ea1cfe771ebb" + EVENT_CYBRAN_PLAYS = "ad193982-e7ca-465c-80b0-5493f9739559" + EVENT_CYBRAN_WINS = "56b06197-1890-42d0-8b59-25e1add8dc9a" + EVENT_UEF_PLAYS = "1b900d26-90d2-43d0-a64e-ed90b74c3704" + EVENT_UEF_WINS = "7be6fdc5-7867-4467-98ce-f7244a66625a" + EVENT_SERAPHIM_PLAYS = "fefcb392-848f-4836-9683-300b283bc308" + EVENT_SERAPHIM_WINS = "15b6c19a-6084-4e82-ada9-6c30e282191f" + + +class PlayerEventMetric(NamedTuple): + name: str + total: Enum + subcategory: Enum + + def get_components_values(self, events_map: dict[str, PlayerEvent]) -> tuple[int, int]: + total_ev = events_map.get(self.total.value) + subcat_ev = events_map.get(self.subcategory.value) + total = total_ev.current_count if total_ev else 0 + sub = subcat_ev.current_count if subcat_ev else 0 + return max(0, sub), max(0, total - sub) + + +FACTION_PLAYS_METRICS = ( + PlayerEventMetric( + Factions.AEON.to_name().capitalize(), + PlayerEvents.EVENT_AEON_PLAYS, + PlayerEvents.EVENT_AEON_WINS, + ), + PlayerEventMetric( + Factions.CYBRAN.to_name().capitalize(), + PlayerEvents.EVENT_CYBRAN_PLAYS, + PlayerEvents.EVENT_CYBRAN_WINS, + ), + PlayerEventMetric( + Factions.UEF.to_name().capitalize(), + PlayerEvents.EVENT_UEF_PLAYS, + PlayerEvents.EVENT_UEF_WINS, + ), + PlayerEventMetric( + Factions.SERAPHIM.to_name().capitalize(), + PlayerEvents.EVENT_SERAPHIM_PLAYS, + PlayerEvents.EVENT_SERAPHIM_WINS, + ), +) + +BUILT_LOST_METRICS = ( + PlayerEventMetric( + "Air", + PlayerEvents.EVENT_BUILT_AIR_UNITS, + PlayerEvents.EVENT_LOST_AIR_UNITS, + ), + PlayerEventMetric( + "Land", + PlayerEvents.EVENT_BUILT_LAND_UNITS, + PlayerEvents.EVENT_LOST_LAND_UNITS, + ), + PlayerEventMetric( + "Naval", + PlayerEvents.EVENT_BUILT_NAVAL_UNITS, + PlayerEvents.EVENT_LOST_NAVAL_UNITS, + ), + PlayerEventMetric( + "Tech 1", + PlayerEvents.EVENT_BUILT_TECH_1_UNITS, + PlayerEvents.EVENT_LOST_TECH_1_UNITS, + ), + PlayerEventMetric( + "Tech 2", + PlayerEvents.EVENT_BUILT_TECH_2_UNITS, + PlayerEvents.EVENT_LOST_TECH_2_UNITS, + ), + PlayerEventMetric( + "Tech 3", + PlayerEvents.EVENT_BUILT_TECH_3_UNITS, + PlayerEvents.EVENT_LOST_TECH_3_UNITS, + ), + PlayerEventMetric( + "Engineers", + PlayerEvents.EVENT_BUILT_ENGINEERS, + PlayerEvents.EVENT_LOST_ENGINEERS, + ), +) + +EXPERIMENTALS_BUILT_LOST_METRICS = ( + PlayerEventMetric( + "Experimentals", + PlayerEvents.EVENT_BUILT_EXPERIMENTALS, + PlayerEvents.EVENT_LOST_EXPERIMENTALS, + ), +) diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 3982d2136..8aa4b37b6 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -27,13 +27,16 @@ from api.models.LeaderboardRatingJournal import LeaderboardRatingJournal from api.models.NameRecord import NameRecord from api.models.Player import Player +from api.models.PlayerEvent import PlayerEvent from api.player_api import PlayerApiConnector from api.stats_api import LeaderboardApiConnector from api.stats_api import LeaderboardRatingApiConnector from api.stats_api import LeaderboardRatingJournalApiConnector from api.stats_api import LeagueSeasonScoreApiConnector +from api.stats_api import PlayerEventApiAccessor from downloadManager import AvatarDownloader from downloadManager import DownloadRequest +from playercard.statistics import StatsCharts from src.playercard.leagueformatter import LegueFormatter if TYPE_CHECKING: @@ -187,19 +190,27 @@ def __init__(self, client_window: ClientWindow, player_id: str) -> None: self.ratings_api = LeaderboardRatingApiConnector() self.ratings_api.player_ratings_ready.connect(self.process_player_ratings) - self.ratings_api.get_player_ratings(self.player_id) + + self.player_event_api = PlayerEventApiAccessor() + self.player_event_api.events_ready.connect(self.process_player_events) + + self.stats_charts = StatsCharts() def load_stylesheet(self) -> None: self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) def run(self) -> None: + self.ratings_api.get_player_ratings(self.player_id) self.player_api.request_player(self.player_id) + self.player_event_api.get_player_events(self.player_id) self.tab_widget_ctrl.run() self.exec() def process_player_ratings(self, ratings: dict[str, list[LeaderboardRating]]) -> None: for rating in ratings["values"]: self.leaguesLayout.addWidget(LegueFormatter(self.player_id, rating, self.leagues_api)) + pie_chart = self.stats_charts.game_types_played(ratings["values"]) + self.statsChartsLayout.addWidget(pie_chart) def process_player(self, player: Player) -> None: self.nicknameLabel.setText(player.login) @@ -225,6 +236,10 @@ def add_names(self, names: list[NameRecord] | None) -> None: def add_avatars(self, avatar_assignments: list[AvatarAssignment] | None) -> None: self.avatar_handler.populate_avatars(avatar_assignments) + def process_player_events(self, events: list[PlayerEvent]) -> None: + for chartview in self.stats_charts.player_events_charts(events): + self.statsChartsLayout.addWidget(chartview) + class AvatarHandler: def __init__(self, avatar_list: QListWidget, avatar_downloader: AvatarDownloader) -> None: diff --git a/src/playercard/statistics.py b/src/playercard/statistics.py new file mode 100644 index 000000000..7e35ae926 --- /dev/null +++ b/src/playercard/statistics.py @@ -0,0 +1,199 @@ +from collections.abc import Generator +from typing import Iterable + +from PyQt6.QtCharts import QBarCategoryAxis +from PyQt6.QtCharts import QBarSet +from PyQt6.QtCharts import QChart +from PyQt6.QtCharts import QChartView +from PyQt6.QtCharts import QPieSeries +from PyQt6.QtCharts import QStackedBarSeries +from PyQt6.QtCharts import QValueAxis +from PyQt6.QtCore import QMargins +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QBrush +from PyQt6.QtGui import QColor +from PyQt6.QtGui import QFont +from PyQt6.QtGui import QPainter + +from api.models.LeaderboardRating import LeaderboardRating +from api.models.PlayerEvent import PlayerEvent +from playercard.events import BUILT_LOST_METRICS +from playercard.events import EXPERIMENTALS_BUILT_LOST_METRICS +from playercard.events import FACTION_PLAYS_METRICS +from playercard.events import PlayerEventMetric + + +class ChartsBuilder: + def __init__(self) -> None: + self.background_color = QColor("#202025") + self.background_brush = QBrush(self.background_color) + self.chart_content_margins = (0, 0, 0, 0) + self.chart_margins = QMargins() + self.background_roundess = 0 + self.title_brush = QBrush(QColor("silver")) + self.title_size = 12 + self.legend_label_color = QColor("silver") + + def customize_title_font(self, chart: QChart) -> QFont: + current_font = chart.titleFont() + current_font.setPointSize(self.title_size) + return current_font + + def create_customized_chart(self) -> QChart: + chart = QChart() + chart.setBackgroundBrush(self.background_brush) + chart.setTitleBrush(self.title_brush) + chart.setTitleFont(self.customize_title_font(chart)) + chart.legend().setLabelColor(self.legend_label_color) + chart.legend().setVisible(True) + chart.legend().setAlignment(Qt.AlignmentFlag.AlignRight) + chart.layout().setContentsMargins(*self.chart_content_margins) + chart.setMargins(self.chart_margins) + chart.setBackgroundRoundness(self.background_roundess) + return chart + + def create_chartview(self, chart: QChart) -> QChartView: + view = QChartView(chart) + view.setMinimumHeight(500) + view.setRenderHint(QPainter.RenderHint.Antialiasing) + return view + + +class PieChartBuilder(ChartsBuilder): + + def create_series( + self, + data: Iterable[tuple[str, int | float]], + ) -> QPieSeries: + series = QPieSeries() + for slice_data in data: + series.append(*slice_data) + return series + + def customize_series_labels(self, series: QPieSeries) -> None: + for pie_slice in series.slices(): + percentage = round(pie_slice.percentage() * 100, 2) + pie_slice.setLabel(f"{pie_slice.label()} ({percentage}%)") + pie_slice.setLabelColor(self.legend_label_color) + series.setLabelsVisible(True) + + def build( + self, + title: str, + data: Iterable[tuple[str, int | float]], + ) -> QChartView: + series = self.create_series(data) + self.customize_series_labels(series) + + chart = self.create_customized_chart() + chart.addSeries(series) + chart.setTitle(title) + + return self.create_chartview(chart) + + +class StackedBarChartBuilder(ChartsBuilder): + + def create_series( + self, + set_names: Iterable[str], + set_values: Iterable[Iterable[int | float]], + ) -> QStackedBarSeries: + series = QStackedBarSeries() + for name, dataset in zip(set_names, set_values): + barset = QBarSet(name) + barset.setPen(self.background_color) + barset.append(dataset) + series.append(barset) + return series + + def customize_series_labels(self, series: QStackedBarSeries) -> None: + series.setLabelsFormat("@value") + series.setLabelsVisible(True) + + def build( + self, + title: str, + data: Iterable[Iterable[int | float]], + set_names: Iterable[str], + categories: Iterable[str], + ) -> QChartView: + series = self.create_series(set_names, data) + self.customize_series_labels(series) + + chart = self.create_customized_chart() + chart.addSeries(series) + chart.setTitle(title) + + axis_x = QBarCategoryAxis() + axis_x.append(categories) + + axis_y = QValueAxis() + axis_y.setLabelFormat("i") + + for axis in (axis_x, axis_y): + axis.setLabelsColor(self.legend_label_color) + axis.setGridLineVisible(False) + series.attachAxis(axis) + + chart.addAxis(axis_x, Qt.AlignmentFlag.AlignBottom) + chart.addAxis(axis_y, Qt.AlignmentFlag.AlignLeft) + + return self.create_chartview(chart) + + +class StatsCharts: + + def bar_chart( + self, + title: str, + set_names: Iterable[str], + metrics: tuple[PlayerEventMetric, ...], + mapping: dict[str, PlayerEvent], + ) -> QChartView: + set0, set1, categories = [], [], [] + for metric in metrics: + value0, value1 = metric.get_components_values(mapping) + set0.append(value0) + set1.append(value1) + categories.append(metric.name) + builder = StackedBarChartBuilder() + return builder.build(title, (set0, set1), set_names, categories) + + def faction_won_lost(self, mapping: dict[str, PlayerEvent]) -> QChartView: + return self.bar_chart( + "Wins/Losses per faction", + ("Wins", "Losses"), + FACTION_PLAYS_METRICS, + mapping, + ) + + def tech_built_lost(self, mapping: dict[str, PlayerEvent]) -> QChartView: + return self.bar_chart( + "Survived/Lost", + ("Survived", "Lost"), + BUILT_LOST_METRICS, + mapping, + ) + + def exp_built_lost(self, mapping: dict[str, PlayerEvent]) -> QChartView: + return self.bar_chart( + "Survived/Lost experimentals", + ("Survived", "Lost"), + EXPERIMENTALS_BUILT_LOST_METRICS, + mapping, + ) + + def game_types_played(self, ratings: list[LeaderboardRating]) -> QChartView: + pie_data = [ + (rating.leaderboard.pretty_name, rating.total_games) + for rating in ratings + ] + builder = PieChartBuilder() + return builder.build("Games played", pie_data) + + def player_events_charts(self, events: list[PlayerEvent]) -> Generator[QChartView, None, None]: + mapping = {player_event.event.xd: player_event for player_event in events} + yield self.faction_won_lost(mapping) + yield self.tech_built_lost(mapping) + yield self.exp_built_lost(mapping) From 7a27a8dc5d7a7b0b5bf953a0bd3af7438d19df9a Mon Sep 17 00:00:00 2001 From: Gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 13 Jul 2024 10:53:53 +0300 Subject: [PATCH 36/73] Stop search automatch when joining coop game ugly fix, but fast and it works --- src/coop/_coopwidget.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py index 78fb5dc39..01ea1a5ba 100644 --- a/src/coop/_coopwidget.py +++ b/src/coop/_coopwidget.py @@ -223,6 +223,8 @@ def game_double_clicked(self, game: Game) -> None: if not fa.instance.available(): return + self.client.games.stopSearch() + if not fa.check.check(game.featured_mod, game.mapname, sim_mods=game.sim_mods): return From 4fe82fa942ccedbc9a2d7b529a28a742bcd319f4 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 14 Jul 2024 22:50:29 +0300 Subject: [PATCH 37/73] Fix type hint --- src/chat/chatter_model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/chat/chatter_model.py b/src/chat/chatter_model.py index cc27ce78f..36b69e5a5 100644 --- a/src/chat/chatter_model.py +++ b/src/chat/chatter_model.py @@ -391,7 +391,7 @@ def _handle_highlight( if option.state & QtWidgets.QStyle.StateFlag.State_Selected: painter.fillRect(option.rect, option.palette.highlight) - def _draw_nick(self, painter: QtGui.QPainter, data: str) -> None: + def _draw_nick(self, painter: QtGui.QPainter, data: ChatterModelItem) -> None: text = self._formatter.chatter_name(data) color = QColor(self._formatter.chatter_color(data)) clip = QRect(self.layout.sizes[ChatterLayoutElements.NICK]) From 190afc5636832780713ec96c5f18920637f0006f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 16 Jul 2024 20:27:22 +0300 Subject: [PATCH 38/73] Use QListViews to display replay's scoreboard it allows to use context menus and look at users' info through replays it also gives more control on how things are displayed and removes html formatters also, add display rating changes alongside the scores --- res/client/client.css | 49 ++- res/replays/replays.ui | 165 ++++----- src/replays/_replayswidget.py | 52 +-- src/replays/replayitem.py | 670 ++++++++++++++++++++++++---------- 4 files changed, 605 insertions(+), 331 deletions(-) diff --git a/res/client/client.css b/res/client/client.css index 5bb808fda..0fe93270a 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -170,6 +170,17 @@ QWidget#centralwidget, QProgressDialog, QWindow, QWizard background-color: qlineargradient(spread:pad, x1:0, y1:0, x2:0, y2:1, stop:0 #FFFFFF, stop:0.1 #000000, stop:1 #1F1F1F); } +QWidget#replayScore +{ + background-color: #202025; + background: #202025; + padding: 0px; +} + +QWidget#scrollAreaWidgetContents +{ + background-color: #383838; +} QTabWidget#mainTabs::pane { /* The tab widget frame */ @@ -382,6 +393,21 @@ QTextEdit, QPlainTextEdit, QLineEdit, QListWidget, QListView, QTableWidget, QTre border-bottom-right-radius : 5px; } +QListView#replayScoreTeamList +{ + border-width:0px; + border-color:#353535; + color:silver; + padding:0px; + background-color:#202025; + alternate-background-color: #303035; + border-top-right-radius : 0px; + border-top-left-radius : 0px; + border-bottom-left-radius : 0px; + border-bottom-right-radius : 0px; + +} + QPlainTextEdit#mapNamePlainTextEdit { border-width:0px; @@ -597,6 +623,16 @@ QScrollArea border-bottom-right-radius : 5px; } +QScrollArea#replayScoreScrollArea +{ + background-color: #202025; + background: #202025; + color:#202025; + border-width: 0px; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; + padding: 0px; +} /* Scrollbars*/ QScrollBar:horizontal { @@ -716,6 +752,14 @@ QLabel#labelLoading,#labelAutomatchInfo color: gold; } +QLabel#VSLabel +{ + color: black; + background-color: #202025; + margin: 0px; +} + + QGroupBox { margin: 5px; @@ -996,8 +1040,3 @@ QFrame#settingsFrame { { background-color: #202025; } - -QWidget#scrollAreaWidgetContents -{ - background-color: #383838; -} diff --git a/res/replays/replays.ui b/res/replays/replays.ui index 8270bfada..ee35e1a36 100644 --- a/res/replays/replays.ui +++ b/res/replays/replays.ui @@ -7,7 +7,7 @@ 0 0 1171 - 638 + 830 @@ -36,10 +36,10 @@ - Qt::NoFocus + Qt::FocusPolicy::NoFocus - QAbstractItemView::NoSelection + QAbstractItemView::SelectionMode::NoSelection @@ -48,7 +48,7 @@ - QAbstractItemView::ScrollPerPixel + QAbstractItemView::ScrollMode::ScrollPerPixel false @@ -66,7 +66,7 @@ false - 32 + 39 false @@ -110,16 +110,16 @@ - Qt::NoFocus + Qt::FocusPolicy::NoFocus false - QAbstractItemView::NoSelection + QAbstractItemView::SelectionMode::NoSelection - QAbstractItemView::SelectRows + QAbstractItemView::SelectionBehavior::SelectRows @@ -128,10 +128,10 @@ - QAbstractItemView::ScrollPerPixel + QAbstractItemView::ScrollMode::ScrollPerPixel - QAbstractItemView::ScrollPerPixel + QAbstractItemView::ScrollMode::ScrollPerPixel false @@ -149,7 +149,7 @@ false - 32 + 39 false @@ -219,7 +219,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -232,7 +232,7 @@ - Qt::Horizontal + Qt::Orientation::Horizontal @@ -245,7 +245,7 @@ - Qt::Vertical + Qt::Orientation::Vertical @@ -258,10 +258,10 @@ - Qt::Horizontal + Qt::Orientation::Horizontal - QSizePolicy::Expanding + QSizePolicy::Policy::Expanding @@ -295,7 +295,7 @@ Standard Search - Qt::AlignHCenter|Qt::AlignTop + Qt::AlignmentFlag::AlignHCenter|Qt::AlignmentFlag::AlignTop true @@ -832,98 +832,55 @@ - - - - Qt::Horizontal - - - - 10 - 0 - - - - - - - - Qt::Horizontal - - - - 10 - 0 - - - - - - - - - 0 - 0 - - - - - 380 - 180 - - - + + + - 0 - 0 + 360 + 120 - - - 0 - 0 - - - - QFrame::Plain - - - Qt::ScrollBarAlwaysOff - - - Qt::TextSelectableByMouse - - + true + + + + 0 + 0 + 358 + 118 + + + + + 0 + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + - - - - Qt::Vertical - - - - 0 - 5 - - - - - - - - Qt::Vertical - - - - 0 - 10 - - - - @@ -984,7 +941,7 @@ - Qt::LeftToRight + Qt::LayoutDirection::LeftToRight Reset all @@ -1452,7 +1409,7 @@ advQuantity advResetButton advSearchButton - replayInfos + replayScore onlineTree diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index 7b02ffae4..4f1d540e7 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -671,14 +671,14 @@ def __init__(self, widget, dispatcher, client, gameset, playerset): "sort": "-startTime", "include": ( "featuredMod,mapVersion,mapVersion.map,playerStats," - "playerStats.player" + "playerStats.player,playerStats.ratingChanges" ), } _w = self._w _w.onlineTree.setItemDelegate(ReplayItemDelegate(_w)) _w.onlineTree.itemDoubleClicked.connect(self.onlineTreeDoubleClicked) - _w.onlineTree.itemPressed.connect(self.onlineTreeClicked) + _w.onlineTree.itemPressed.connect(self.online_tree_clicked) _w.searchButton.pressed.connect(self.searchVault) _w.playerName.returnPressed.connect(self.searchVault) @@ -690,7 +690,7 @@ def __init__(self, widget, dispatcher, client, gameset, playerset): _w.showLatestCheckbox.stateChanged.connect( self.showLatestCheckboxchange, ) - _w.spoilerCheckbox.stateChanged.connect(self.spoilerCheckboxchange) + _w.spoilerCheckbox.checkStateChanged.connect(self.spoiler_checkbox_change) _w.hideUnrCheckbox.stateChanged.connect(self.hideUnrCheckboxchange) _w.RefreshResetButton.pressed.connect(self.resetRefreshPressed) @@ -885,22 +885,30 @@ def reloadView(self): if self.automatic or self.onlineReplays == {}: self.searchVault(reset=True) - def onlineTreeClicked(self, item): + def clear_scoreboard(self) -> None: + if (layout_item := self._w.replayScoreLayout.itemAt(0)) is not None: + scoreboard = layout_item.widget() + scoreboard.setParent(None) + self._w.replayScoreLayout.removeWidget(scoreboard) + scoreboard.deleteLater() + + def adjust_scoreboard_size(self, width: int, height: int) -> None: + self._w.replayScoreScrollArea.setMaximumWidth(width) + self._w.replayScoreScrollArea.setMaximumHeight(height) + + def add_scoreboard(self, item: ReplayItem) -> None: + self.clear_scoreboard() + scoreboard = item.generate_scoreboard() + self._w.replayScoreLayout.addWidget(scoreboard) + self.adjust_scoreboard_size(scoreboard.width(), scoreboard.height()) + + def online_tree_clicked(self, item: ReplayItem) -> None: if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.MouseButton.RightButton: - if isinstance(item.parent, ReplaysWidget): # FIXME - hack + if isinstance(item.parent, ReplaysWidget): # FIXME - hack item.pressed(item) else: self.selectedReplay = item - if hasattr(item, "moreInfo"): - if item.moreInfo is False: - item.infoPlayers() - elif item.spoiled != self._w.spoilerCheckbox.isChecked(): - self._w.replayInfos.clear() - self._w.replayInfos.setHtml(item.replayInfo) - item.resize() - else: - self._w.replayInfos.clear() - item.generateInfoPlayersHtml() + self.add_scoreboard(item) if self.toolboxHandler.mapPreview: self.toolboxHandler.updateMapPreview() @@ -970,14 +978,14 @@ def matchUsernameCheckboxChange(self, state): def automaticCheckboxchange(self, state): self.automatic = state - def spoilerCheckboxchange(self, state): - self.spoiler_free = state + def spoiler_checkbox_change(self, state: QtCore.Qt.CheckState) -> None: + self.spoiler_free = state == QtCore.Qt.CheckState.Checked # if something is selected in the tree to the left if self.selectedReplay: # and if it is a game if isinstance(self.selectedReplay, ReplayItem): # then we redo it - self.selectedReplay.generateInfoPlayersHtml() + self.add_scoreboard(self.selectedReplay) def showLatestCheckboxchange(self, state): self.showLatest = state @@ -1020,7 +1028,7 @@ def onDownloadFinished(self, reply): def process_replays_data(self, message: dict) -> None: self.stopSearchVault() - self._w.replayInfos.clear() + self.clear_scoreboard() self.onlineReplays = {} replays = message["data"] for replay_item in replays: @@ -1028,7 +1036,7 @@ def process_replays_data(self, message: dict) -> None: if uid not in self.onlineReplays: self.onlineReplays[uid] = ReplayItem(uid, self._w) self.onlineReplays[uid].update(replay_item, self.client) - self.updateOnlineTree() + self.update_online_tree() if len(message["data"]) == 0: self._w.searchInfoLabel.setText( @@ -1042,9 +1050,9 @@ def process_leaderboards(self, message: dict[str, list[Leaderboard]]) -> None: for leaderboard in message["values"]: self._w.leaderboardList.addItem(leaderboard.pretty_name, leaderboard.technical_name) - def updateOnlineTree(self): + def update_online_tree(self) -> None: self.selectedReplay = None # clear, it won't be part of the new tree - self._w.replayInfos.clear() + self.clear_scoreboard() self._w.onlineTree.clear() buckets = {} for uid in self.onlineReplays: diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index c3ee5aef3..055a268a8 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -1,17 +1,472 @@ -import os +from __future__ import annotations + import time from datetime import datetime from datetime import timezone +from enum import Enum +from typing import Any +from typing import Callable +from typing import Iterable from PyQt6 import QtCore from PyQt6 import QtGui from PyQt6 import QtWidgets +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QRect +from PyQt6.QtCore import QSize +from PyQt6.QtCore import Qt +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtGui import QIcon +from PyQt6.QtGui import QPainter +from PyQt6.QtGui import QPen +from PyQt6.QtWidgets import QHBoxLayout +from PyQt6.QtWidgets import QLabel +from PyQt6.QtWidgets import QLayout +from PyQt6.QtWidgets import QListView +from PyQt6.QtWidgets import QStyle +from PyQt6.QtWidgets import QStyledItemDelegate +from PyQt6.QtWidgets import QStyleOptionViewItem +from PyQt6.QtWidgets import QVBoxLayout +from PyQt6.QtWidgets import QWidget import util from config import Settings from downloadManager import DownloadRequest from fa import maps from games.moditem import mods +from util.qt import qpainter +from util.qt_list_model import QtListModel + + +class GameResult(Enum): + WIN = "Win" + LOSE = "Lose" + PLAYING = "Playing" + UNKNOWN = "???" + + +class ScoreboardModelItem(QObject): + updated = pyqtSignal() + + def __init__(self, player: dict, mod: str | None) -> None: + QObject.__init__(self) + self.player = player + self.mod = mod or "" + + if len(self.player["ratingChanges"]) > 0: + self.rating_stats = self.player["ratingChanges"][0] + else: + self.rating_stats = None + + @classmethod + def builder(cls, mod: str | None) -> Callable[[dict], ScoreboardModelItem]: + def build(data: dict) -> ScoreboardModelItem: + return cls(data, mod) + return build + + def score(self) -> int: + return self.player["score"] + + def login(self) -> str: + return self.player["player"]["login"] + + def rating_before(self) -> int: + # gamePlayerStats' fields 'before*' and 'after*' can be removed + # at any time and 'ratingChanges' can be absent if game result is + # undefined + if self.rating_stats is not None: + return round(self.rating_stats["meanBefore"] - self.rating_stats["deviationBefore"] * 3) + elif self.player.get("beforeMean") and self.player.get("beforeDeviation"): + return round(self.player["beforeMean"] - self.player["beforeDeviation"] * 3) + return 0 + + def rating_after(self) -> int: + if self.rating_stats is not None: + return round(self.rating_stats["meanAfter"] - self.rating_stats["deviationAfter"] * 3) + elif self.player.get("afterMean") and self.player.get("afterDeviation"): + return round(self.player["afterMean"] - self.player["afterDeviation"] * 3) + return 0 + + def rating(self) -> int | None: + if self.rating_stats is None and "beforeMean" not in self.player: + return None + return self.rating_before() + + def rating_change(self) -> int: + if self.rating_stats is None: + return 0 + return self.rating_after() - self.rating_before() + + def faction_name(self) -> str: + if "faction" in self.player: + if self.player["faction"] == 1: + faction = "UEF" + elif self.player["faction"] == 2: + faction = "Aeon" + elif self.player["faction"] == 3: + faction = "Cybran" + elif self.player["faction"] == 4: + faction = "Seraphim" + elif self.player["faction"] == 5: + if self.mod == "nomads": + faction = "Nomads" + else: + faction = "Random" + elif self.player["faction"] == 6: + if self.mod == "nomads": + faction = "Random" + else: + faction = "broken" + else: + faction = "broken" + else: + faction = "Missing" + return faction + + def icon(self) -> QIcon: + return util.THEME.icon(f"replays/{self.faction_name()}.png") + + +class ScoreboardModel(QtListModel): + def __init__( + self, + spoiled: bool, + alignment: Qt.AlignmentFlag, + item_builder: Callable[[Any], QObject], + ) -> None: + QtListModel.__init__(self, item_builder) + self.spoiled = spoiled + self.alignment = alignment + + def get_alignment(self) -> Qt.AlignmentFlag: + return self.alignment + + def add_player(self, player: dict) -> None: + self._add_item(player, player["player"]["id"]) + + +class ScoreboardItemDelegate(QStyledItemDelegate): + def __init__(self) -> None: + QStyledItemDelegate.__init__(self) + self._row_height = 22 + + def row_height(self) -> int: + return self._row_height + + def sizeHint(self, option, index) -> QSize: + size = QStyledItemDelegate.sizeHint(self, option, index) + return QSize(size.width(), self._row_height) + + def _draw_score( + self, + painter: QPainter, + rect: QRect, + player_data: ScoreboardModelItem, + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, + ) -> QRect: + score = f"{player_data.score()}" + score_rect = QRect(rect) + score_rect.setWidth(20) + if alignment == Qt.AlignmentFlag.AlignRight: + score_rect.moveLeft(rect.width() - score_rect.width()) + painter.drawText(score_rect, Qt.AlignmentFlag.AlignCenter, score) + return score_rect + + def _draw_icon( + self, + painter: QPainter, + rect: QRect, + player_data: ScoreboardModelItem, + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, + ) -> QRect: + icon = player_data.icon() + icon_rect = QRect(rect) + icon_rect.setWidth(40) + icon_rect.setHeight(20) + if alignment == Qt.AlignmentFlag.AlignRight: + icon_rect.moveLeft(rect.width() - icon_rect.width()) + icon.paint(painter, icon_rect) + return icon_rect + + def _draw_nick_and_rating( + self, + painter: QPainter, + rect: QRect, + player_data: ScoreboardModelItem, + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, + ) -> QRect: + rating = player_data.rating() + rating_str = f"{rating}" if rating is not None else "???" + text = self._get_elided_text(painter, f"{player_data.login()} ({rating_str})", rect.width()) + painter.drawText(rect, alignment, text) + return rect + + def _draw_rating_change( + self, + painter: QPainter, + rect: QRect, + player_data: ScoreboardModelItem, + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, + ) -> QRect: + change = player_data.rating_change() + rating_change_rect = QRect(rect) + rating_change_rect.setWidth(30) + if alignment == Qt.AlignmentFlag.AlignRight: + rating_change_rect.moveLeft(rect.width() - rating_change_rect.width()) + color = painter.pen().color() + if change > 0: + color = Qt.GlobalColor.green + elif change < 0: + color = Qt.GlobalColor.red + with qpainter(painter): + painter.setPen(QPen(color)) + painter.drawText(rating_change_rect, Qt.AlignmentFlag.AlignCenter, f"{change:+}") + return rating_change_rect + + def _draw_clear_option(self, painter: QPainter, option: QStyleOptionViewItem) -> None: + option.text = "" + control_element = QStyle.ControlElement.CE_ItemViewItem + option.widget.style().drawControl(control_element, option, painter, option.widget) + + def _shrink_rect_along( + self, + rect: QRect, + adjustment: int, + alignment: Qt.AlignmentFlag, + ) -> QRect: + """ + Returns a new rect shrinked from left or right side + by given adjustment + """ + direction = 1 if alignment == Qt.AlignmentFlag.AlignLeft else -1 + index = 0 if alignment == Qt.AlignmentFlag.AlignLeft else 2 + adjustments = [0, 0, 0, 0] + adjustments[index] = adjustment * direction + return rect.adjusted(*adjustments) + + def _get_elided_text(self, painter: QPainter, text: str, width: int) -> str: + metrics = painter.fontMetrics() + return metrics.elidedText(text, Qt.TextElideMode.ElideRight, width) + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: + player_data: ScoreboardModelItem = index.data() + team_model: ScoreboardModel = index.model() + team_alignment = team_model.get_alignment() + rect = QRect(option.rect) + + with qpainter(painter): + self._draw_clear_option(painter, option) + + if team_model.spoiled: + score_rect = self._draw_score(painter, rect, player_data, team_alignment) + rect = self._shrink_rect_along(rect, score_rect.width(), team_alignment) + + diff_rect = self._draw_rating_change(painter, rect, player_data, team_alignment) + rect = self._shrink_rect_along(rect, diff_rect.width(), team_alignment) + + icon_rect = self._draw_icon(painter, rect, player_data, team_alignment) + rect = self._shrink_rect_along(rect, icon_rect.width() + 3, team_alignment) + + self._draw_nick_and_rating(painter, rect, player_data, team_alignment) + + +class Scoreboard(QWidget): + GAME_RESULT_RESERVED_HEIGHT = 30 + TITLE_RESERVED_HEIGHT = 30 + + def __init__( + self, + mod: str | None, + winner: dict | None, + spoiled: bool, + duration: str | None, + teamwin: dict | None, + uid: str, + teams: dict, + ) -> None: + super().__init__() + self.winner = winner + self.spoiled = spoiled + self.duration = duration or "" + self.teamwin = teamwin + self.uid = uid + self.teams = teams + self.num_teams = len(self.teams) + self.biggest_team = max(len(team) for team in self.teams.values()) if self.teams else 0 + + self.main_layout = QVBoxLayout() + if self.num_teams == 2: + self.teams_layout = QHBoxLayout() + else: + self.teams_layout = QVBoxLayout() + self.setLayout(self.main_layout) + self.mod = mod + self._height = 0 + self._team_heights = [] + + def create_teamlist_view(self) -> QListView: + team_view = QListView() + team_view.setObjectName("replayScoreTeamList") + return team_view + + def _create_team_result_label(self, text: str) -> QLabel: + result_label = QLabel(text) + result_label.setObjectName("replayGameResult") + result_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + font = result_label.font() + font.setPointSize(font.pointSize() + 4) + result_label.setFont(font) + + return result_label + + def add_result_label(self, text: str, layout: QLayout) -> None: + result_label = self._create_team_result_label(text) + layout.addWidget(result_label) + + def teamview_rows(self, view: QListView) -> int: + model: ScoreboardModel = view.model() + if self.num_teams == 2: + return self.biggest_team + return model.rowCount(QModelIndex()) + + def teamview_height(self, view: QListView) -> int: + row_count = self.teamview_rows(view) + delegate: ScoreboardItemDelegate = view.itemDelegate() + return row_count * delegate.row_height() + + def adjust_teamview_height(self, view: QListView, height: int) -> None: + view.setMinimumHeight(height) + view.setMaximumHeight(height) + + def add_team_score( + self, + alignment: Qt.AlignmentFlag, + team_result: GameResult, + players: Iterable[dict], + ) -> None: + team_layout = QVBoxLayout() + self.add_result_label(team_result.value, team_layout) + + model = ScoreboardModel(self.spoiled, alignment, ScoreboardModelItem.builder(self.mod)) + for player in players: + model.add_player(player) + + team_view = self.create_teamlist_view() + team_view.setModel(model) + team_view.setItemDelegate(ScoreboardItemDelegate()) + team_layout.addWidget(team_view) + + view_height = self.teamview_height(team_view) + self.adjust_teamview_height(team_view, view_height) + self._team_heights.append(self.GAME_RESULT_RESERVED_HEIGHT + view_height) + + self.teams_layout.addLayout(team_layout) + + def add_team_score_if_needed( + self, + alignment: Qt.AlignmentFlag, + team_result: GameResult, + players: Iterable[dict], + ) -> None: + if len(list(players)) == 0: + return + self.add_team_score(alignment, team_result, players) + + def height(self) -> int: + # there must be a way to dissect all of the layouts and widgets + # with all of their paddings, spacings, margins etc. to determine + # scoreboard's precise height, but this works good enough + magic = 40 + if len(self.teams) == 2: + return self._height + max(self._team_heights) + magic + return sum((self._height, *self._team_heights, magic)) + + def width(self) -> int: + if len(self.teams) == 2: + return 560 if self.spoiled else 500 + return 335 if self.spoiled else 275 + + def one_team_layout(self) -> None: + team = list(self.teams.values())[0] + alignment = Qt.AlignmentFlag.AlignLeft + if self.spoiled: + winners, losers = [], [] + for player in team: + if self.winner is not None and player["score"] == self.winner["score"]: + winners.append(player) + else: + losers.append(player) + self.add_team_score_if_needed(alignment, self.game_result(is_winner=True), winners) + self.add_team_score_if_needed(alignment, self.game_result(is_winner=False), losers) + else: + self.add_team_score_if_needed(alignment, self.game_result(is_winner=False), team) + self.main_layout.addLayout(self.teams_layout) + + def default_layout(self) -> None: + alignment = Qt.AlignmentFlag.AlignLeft + for team in self.teams: + game_result = self.game_result(is_winner=(team == self.teamwin)) + self.add_team_score(alignment, game_result, self.teams[team]) + self.main_layout.addLayout(self.teams_layout) + + def game_result(self, *, is_winner: bool) -> GameResult: + if not self.spoiled: + return GameResult.UNKNOWN + if "playing" in self.duration: + return GameResult.PLAYING + return (GameResult.LOSE, GameResult.WIN)[is_winner] + + def two_teams_layout(self) -> None: + for index, team_num in enumerate(self.teams): + alignment = (Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignRight)[index] + is_winner = team_num == self.teamwin + game_result = self.game_result(is_winner=is_winner) + self.add_team_score(alignment, game_result, self.teams[team_num]) + if index == 0: + self.add_vs_label() + self.main_layout.addLayout(self.teams_layout) + + def create_title_label(self) -> QLabel: + title_label = QLabel(f"Replay UID: {self.uid}") + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + title_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + title_font = title_label.font() + title_font.setPointSize(title_font.pointSize() + 4) + title_font.setBold(True) + title_label.setFont(title_font) + + return title_label + + def add_title_label(self) -> None: + self.main_layout.addWidget(self.create_title_label()) + self._height += self.TITLE_RESERVED_HEIGHT + + def create_vs_label(self) -> QLabel: + vs_label = QLabel("VS") + vs_label.setObjectName("VSLabel") + vs_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + font = vs_label.font() + font.setPointSize(font.pointSize() + 13) + vs_label.setFont(font) + + return vs_label + + def add_vs_label(self) -> None: + self.teams_layout.addWidget(self.create_vs_label()) + + def setup(self) -> None: + self.add_title_label() + + if self.num_teams == 1: + self.one_team_layout() + elif self.num_teams == 2: + self.two_teams_layout() + else: + self.default_layout() class ReplayItemDelegate(QtWidgets.QStyledItemDelegate): @@ -143,8 +598,7 @@ def __init__(self, uid, parent, *args, **kwargs): self.duration = None self.live_delay = False - self.moreInfo = False - self.replayInfo = False + self.extra_info_loaded = False self.spoiled = False self.url = "{}/{}".format(Settings.get('replay_vault/host'), self.uid) @@ -248,14 +702,14 @@ def _on_map_preview_downloaded(self, mapname, result): self.icon = util.THEME.icon(path, is_local) self.setIcon(0, self.icon) - def infoPlayers(self): + def load_extra_info(self) -> None: """ processes information from the server about a replay into readable extra information for the user, also calls method to show the information """ - self.moreInfo = True + self.extra_info_loaded = True playersList = self.replay['playerStats'] self.numberplayers = len(playersList) @@ -277,6 +731,9 @@ def infoPlayers(self): else: team = int(player["team"]) + if team == -1: + continue + if "score" in player: if team in scores: scores[team] = scores[team] + player["score"] @@ -306,203 +763,16 @@ def infoPlayers(self): self.teamWin = team mvt = scores[team] - self.generateInfoPlayersHtml() - - def generateInfoPlayersHtml(self): - """ - Creates the ui and extra information about a replay, - Either teamWin or winner must be set if the replay is to be spoiled - """ - - teams = "" - winnerHTML = "" - + def generate_scoreboard(self) -> Scoreboard: + if not self.extra_info_loaded: + self.load_extra_info() self.spoiled = not self.parent.spoilerCheckbox.isChecked() - - i = 0 - for team in self.teams: - if team != -1: - i += 1 - - if len(self.teams[team]) > self.biggestTeam: - self.biggestTeam = len(self.teams[team]) - - players = "" - for player in self.teams[team]: - alignment, playerIcon, playerLabel, playerScore = ( - self.generatePlayerHTML(i, player) - ) - - if ( - self.winner is not None - and player["score"] == self.winner["score"] - and self.spoiled - ): - winnerHTML += ( - "{}{}{}".format( - playerScore, - playerIcon, - playerLabel, - ) - ) - elif alignment == "left": - players += ( - "{}{}{}".format( - playerScore, - playerIcon, - playerLabel, - ) - ) - else: # alignment == "right" - players += ( - "{}{}{}".format( - playerLabel, - playerIcon, - playerScore, - ) - ) - - if self.spoiled: - if self.winner is not None: # FFA in rows: Win... Lose... - teams += self.FORMATTER_REPLAY_FFA_SPOILED.format( - winner=winnerHTML, players=players, - ) - else: - if "playing" in self.duration: - teamTitle = "Playing" - elif self.teamWin == team: - teamTitle = "Win" - else: - teamTitle = "Lose" - - if len(self.teams) == 2: # pack team in - teams += ( - self.FORMATTER_REPLAY_TEAM2_SPOILED.format( - title=teamTitle, players=players, - ) - ) - else: # just row on - teams += self.FORMATTER_REPLAY_TEAM_SPOILED.format( - title=teamTitle, players=players, - ) - else: - if len(self.teams) == 2: # pack team in
- teams += self.FORMATTER_REPLAY_TEAM2.format( - players=players, - ) - else: # just row on - teams += players - - if len(self.teams) == 2 and i == 1: # add the 'vs' - teams += ( - "" - ) - - # prepare the package to 'fit in' with its {}".format(teams) - - self.replayInfo = self.FORMATTER_REPLAY_INFORMATION.format( - uid=self.uid, teams=teams, - ) - - if self.isSelected(): - self.parent.replayInfos.clear() - self.resize() - self.parent.replayInfos.setHtml(self.replayInfo) - - def generatePlayerHTML(self, i, player): - if i == 2 and len(self.teams) == 2: - alignment = "right" - else: - alignment = "left" - - if "login" not in player["player"]: - player["player"]["login"] = "No data" - - playerRating = int( - round((player["beforeMean"] - player["beforeDeviation"] * 3) / 100) - * 100, - ) - playerLabel = self.FORMATTER_REPLAY_PLAYER_LABEL.format( - player_name=player["player"]["login"], - player_rating=playerRating, - alignment=alignment, - ) - - iconPath = os.path.join( - util.COMMON_DIR, - "replays/{}.png".format( - self.retrieveIconFaction(player, self.mod), - ), - ) - iconUrl = QtCore.QUrl.fromLocalFile(iconPath).url() - - playerIcon = self.FORMATTER_REPLAY_PLAYER_ICON.format( - faction_icon_uri=iconUrl, + scoreboard = Scoreboard( + self.mod, self.winner, self.spoiled, + self.duration, self.teamWin, self.uid, self.teams, ) - - if self.spoiled and not self.mod == "ladder1v1": - playerScore = self.FORMATTER_REPLAY_PLAYER_SCORE.format( - player_score=player["score"], - ) - else: # no score for ladder - playerScore = self.FORMATTER_REPLAY_PLAYER_SCORE.format( - player_score=" ", - ) - - return alignment, playerIcon, playerLabel, playerScore - - @staticmethod - def retrieveIconFaction(player, mod): - if "faction" in player: - if player["faction"] == 1: - faction = "UEF" - elif player["faction"] == 2: - faction = "Aeon" - elif player["faction"] == 3: - faction = "Cybran" - elif player["faction"] == 4: - faction = "Seraphim" - elif player["faction"] == 5: - if mod == "nomads": - faction = "Nomads" - else: - faction = "Random" - elif player["faction"] == 6: - if mod == "nomads": - faction = "Random" - else: - faction = "Broken" - else: - faction = "Broken" - else: - faction = "Missing" - return faction - - def resize(self): - if self.isSelected(): - if self.extraInfoWidth == 0 or self.extraInfoHeight == 0: - if len(self.teams) == 1: # ladder, FFA - self.extraInfoWidth = 275 - # + 1 -> second title - self.extraInfoHeight = 75 + (self.numberplayers + 1) * 25 - elif len(self.teams) == 2: # Team vs Team - self.extraInfoWidth = 500 - self.extraInfoHeight = 75 + self.biggestTeam * 22 - else: # FAF - self.extraInfoWidth = 275 - self.extraInfoHeight = ( - 75 + (self.numberplayers + len(self.teams)) * 25 - ) - - self.parent.replayInfos.setMinimumWidth(self.extraInfoWidth) - self.parent.replayInfos.setMaximumWidth(600) - - self.parent.replayInfos.setMinimumHeight(self.extraInfoHeight) - self.parent.replayInfos.setMaximumHeight(self.extraInfoHeight) + scoreboard.setup() + return scoreboard def pressed(self, item): menu = QtWidgets.QMenu(self.parent) From 7674f4dd7f15e48f507c7dd1afbc3160a30bf416 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 17 Jul 2024 02:13:26 +0300 Subject: [PATCH 39/73] Fix replay tree widget's mouse clicks --- src/replays/_replayswidget.py | 9 ++++++--- src/replays/replayitem.py | 5 +++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index 4f1d540e7..fc9533858 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -9,6 +9,7 @@ from PyQt6.QtNetwork import QNetworkAccessManager from PyQt6.QtNetwork import QNetworkReply from PyQt6.QtNetwork import QNetworkRequest +from PyQt6.QtWidgets import QTreeWidgetItem import client import fa @@ -902,10 +903,12 @@ def add_scoreboard(self, item: ReplayItem) -> None: self._w.replayScoreLayout.addWidget(scoreboard) self.adjust_scoreboard_size(scoreboard.width(), scoreboard.height()) - def online_tree_clicked(self, item: ReplayItem) -> None: + def online_tree_clicked(self, item: ReplayItem | QTreeWidgetItem) -> None: + if not isinstance(item, ReplayItem): + return + if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.MouseButton.RightButton: - if isinstance(item.parent, ReplaysWidget): # FIXME - hack - item.pressed(item) + item.pressed() else: self.selectedReplay = item self.add_scoreboard(item) diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index 055a268a8..69261ae40 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -17,6 +17,7 @@ from PyQt6.QtCore import QSize from PyQt6.QtCore import Qt from PyQt6.QtCore import pyqtSignal +from PyQt6.QtGui import QAction from PyQt6.QtGui import QIcon from PyQt6.QtGui import QPainter from PyQt6.QtGui import QPen @@ -774,9 +775,9 @@ def generate_scoreboard(self) -> Scoreboard: scoreboard.setup() return scoreboard - def pressed(self, item): + def pressed(self) -> None: menu = QtWidgets.QMenu(self.parent) - actionDownload = QtWidgets.QAction("Download replay", menu) + actionDownload = QAction("Download replay", menu) actionDownload.triggered.connect(self.downloadReplay) menu.addAction(actionDownload) menu.popup(QtGui.QCursor.pos()) From 23f6a01ee585135c41f9f03323d586f1f4cbd542 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 18 Jul 2024 21:03:28 +0300 Subject: [PATCH 40/73] Create Rating named tuple and move all the "mean - 3 * devaition" calculations into 1 place --- src/model/player.py | 4 +++- src/model/rating.py | 9 +++++++++ src/playercard/playerinfodialog.py | 3 ++- src/replays/replayitem.py | 25 +++++++++++++++++++++---- 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/model/player.py b/src/model/player.py index 407edc29d..db76163f8 100644 --- a/src/model/player.py +++ b/src/model/player.py @@ -1,6 +1,7 @@ from PyQt6.QtCore import pyqtSignal from model.modelitem import ModelItem +from model.rating import Rating from model.rating import RatingType from model.transaction import transactional @@ -99,7 +100,8 @@ def rating_estimate(self, rating_type=RatingType.GLOBAL.value): try: mean = self.ratings[rating_type]["rating"][0] deviation = self.ratings[rating_type]["rating"][1] - return int(max(0, (mean - 3 * deviation))) + rating = Rating(mean, deviation) + return int(max(0, rating.displayed())) except (KeyError, IndexError): return 0 diff --git a/src/model/rating.py b/src/model/rating.py index 83fea2913..254df8b2c 100644 --- a/src/model/rating.py +++ b/src/model/rating.py @@ -1,6 +1,7 @@ # TODO: fetch this from API from enum import Enum +from typing import NamedTuple # copied from the server code according to which # this will need be fixed when the database @@ -39,3 +40,11 @@ def fromRatingType(ratingTypeName): if ratingTypeName.replace("_", "") == matchmakerQueue.value: return matchmakerQueue.value return MatchmakerQueueType.LADDER.value + + +class Rating(NamedTuple): + mean: float + deviation: float + + def displayed(self) -> float: + return self.mean - 3 * self.deviation diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 8aa4b37b6..5e1a5805e 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -36,6 +36,7 @@ from api.stats_api import PlayerEventApiAccessor from downloadManager import AvatarDownloader from downloadManager import DownloadRequest +from model.rating import Rating from playercard.statistics import StatsCharts from src.playercard.leagueformatter import LegueFormatter @@ -335,7 +336,7 @@ def get_plot_series(self, ratings: list[LeaderboardRatingJournal]) -> LineSeries score_time = QDateTime.fromString(entry.player_stats.score_time, Qt.DateFormat.ISODate) series.append( score_time.toSecsSinceEpoch(), - entry.mean_after - 3 * entry.deviation_after, + Rating(entry.mean_after, entry.deviation_after).displayed(), ) return series diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index 69261ae40..346076cb3 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -36,6 +36,7 @@ from downloadManager import DownloadRequest from fa import maps from games.moditem import mods +from model.rating import Rating from util.qt import qpainter from util.qt_list_model import QtListModel @@ -77,16 +78,32 @@ def rating_before(self) -> int: # at any time and 'ratingChanges' can be absent if game result is # undefined if self.rating_stats is not None: - return round(self.rating_stats["meanBefore"] - self.rating_stats["deviationBefore"] * 3) + rating = Rating( + self.rating_stats["meanBefore"], + self.rating_stats["deviationBefore"], + ) + return round(rating.displayed()) elif self.player.get("beforeMean") and self.player.get("beforeDeviation"): - return round(self.player["beforeMean"] - self.player["beforeDeviation"] * 3) + rating = Rating( + self.player["beforeMean"], + self.player["beforeDeviation"], + ) + return round(rating.displayed()) return 0 def rating_after(self) -> int: if self.rating_stats is not None: - return round(self.rating_stats["meanAfter"] - self.rating_stats["deviationAfter"] * 3) + rating = Rating( + self.rating_stats["meanAfter"], + self.rating_stats["deviationAfter"], + ) + return round(rating.displayed()) elif self.player.get("afterMean") and self.player.get("afterDeviation"): - return round(self.player["afterMean"] - self.player["afterDeviation"] * 3) + rating = Rating( + self.player["afterMean"], + self.player["afterDeviation"], + ) + return round(rating.displayed()) return 0 def rating(self) -> int | None: From f8cd6785e19855d14c7afaefd27859e6c8eade89 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 19 Jul 2024 04:57:29 +0300 Subject: [PATCH 41/73] Move common style item delegates' methods into superclass --- src/chat/chatter_model.py | 18 +++---------- src/games/gameitem.py | 12 +++------ src/qt/itemviews/tableitemdelegte.py | 9 ++----- src/replays/replayitem.py | 40 +++++++++++----------------- src/util/qtstyleditemdelegate.py | 24 +++++++++++++++++ 5 files changed, 49 insertions(+), 54 deletions(-) create mode 100644 src/util/qtstyleditemdelegate.py diff --git a/src/chat/chatter_model.py b/src/chat/chatter_model.py index 36b69e5a5..62a37f364 100644 --- a/src/chat/chatter_model.py +++ b/src/chat/chatter_model.py @@ -21,6 +21,7 @@ from model.game import GameState from model.rating import RatingType from util.qt_list_model import QtListModel +from util.qtstyleditemdelegate import QtStyledItemDelegate class ChatterModel(QtListModel): @@ -335,9 +336,9 @@ def nick_tooltip(self, data): return self.country_tooltip(data) -class ChatterItemDelegate(QtWidgets.QStyledItemDelegate): +class ChatterItemDelegate(QtStyledItemDelegate): def __init__(self, layout, formatter): - QtWidgets.QStyledItemDelegate.__init__(self) + QtStyledItemDelegate.__init__(self) self.layout = layout self._formatter = formatter @@ -376,13 +377,6 @@ def paint(self, painter, option, index): painter.restore() - def _draw_clear_option(self, painter, option): - option.icon = QtGui.QIcon() - option.text = "" - option.widget.style().drawControl( - QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, - ) - def _handle_highlight( self, painter: QtGui.QPainter, @@ -395,7 +389,7 @@ def _draw_nick(self, painter: QtGui.QPainter, data: ChatterModelItem) -> None: text = self._formatter.chatter_name(data) color = QColor(self._formatter.chatter_color(data)) clip = QRect(self.layout.sizes[ChatterLayoutElements.NICK]) - text = self._get_elided_text(painter, text, clip.width()) + text = self._get_elided_text(painter, text, width=clip.width()) painter.save() pen = painter.pen() @@ -406,10 +400,6 @@ def _draw_nick(self, painter: QtGui.QPainter, data: ChatterModelItem) -> None: painter.restore() - def _get_elided_text(self, painter: QtGui.QPainter, text: str, width: int) -> str: - metrics = painter.fontMetrics() - return metrics.elidedText(text, Qt.TextElideMode.ElideRight, width) - def _draw_status(self, painter, data): status = self._formatter.chatter_status(data) icon = util.THEME.icon("chat/status/{}.png".format(status)) diff --git a/src/games/gameitem.py b/src/games/gameitem.py index 0d89a848b..549d113f7 100644 --- a/src/games/gameitem.py +++ b/src/games/gameitem.py @@ -8,6 +8,7 @@ import util from fa import maps +from util.qtstyleditemdelegate import QtStyledItemDelegate class GameView(QtCore.QObject): @@ -38,7 +39,7 @@ def _game_double_clicked(self, idx): self.game_double_clicked.emit(idx.data().game) -class GameItemDelegate(QtWidgets.QStyledItemDelegate): +class GameItemDelegate(QtStyledItemDelegate): ICON_RECT = 100 ICON_CLIP_TOP_LEFT = 3 ICON_CLIP_BOTTOM_RIGHT = -7 @@ -54,7 +55,7 @@ class GameItemDelegate(QtWidgets.QStyledItemDelegate): PADDING = 10 def __init__(self, formatter): - QtWidgets.QStyledItemDelegate.__init__(self) + QtStyledItemDelegate.__init__(self) self._formatter = formatter self.tooltip_filter = GameTooltipFilter(self._formatter) @@ -73,13 +74,6 @@ def paint(self, painter, option, index): painter.restore() - def _draw_clear_option(self, painter, option): - option.icon = QtGui.QIcon() - option.text = "" - option.widget.style().drawControl( - QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget, - ) - def _draw_icon_shadow(self, painter, option): painter.fillRect( option.rect.left() + self.ICON_SHADOW_OFFSET, diff --git a/src/qt/itemviews/tableitemdelegte.py b/src/qt/itemviews/tableitemdelegte.py index b5c7ded05..ec1140f78 100644 --- a/src/qt/itemviews/tableitemdelegte.py +++ b/src/qt/itemviews/tableitemdelegte.py @@ -2,14 +2,14 @@ from PyQt6.QtCore import Qt from PyQt6.QtGui import QPainter from PyQt6.QtWidgets import QStyle -from PyQt6.QtWidgets import QStyledItemDelegate from PyQt6.QtWidgets import QStyleOptionViewItem from PyQt6.QtWidgets import QTableView from util.qt import qpainter +from util.qtstyleditemdelegate import QtStyledItemDelegate -class TableItemDelegate(QStyledItemDelegate): +class TableItemDelegate(QtStyledItemDelegate): """ Highlights the entire row on mouse hover when table's SelectionBehavior is set to SelectRows @@ -42,11 +42,6 @@ def _customize_style_option( self.initStyleOption(opt, index) return opt - def _draw_clear_option(self, painter: QPainter, option: QStyleOptionViewItem) -> None: - option.text = "" - control_element = QStyle.ControlElement.CE_ItemViewItem - option.widget.style().drawControl(control_element, option, painter, option.widget) - def _set_pen(self, painter: QPainter, option: QStyleOptionViewItem) -> None: if option.state & QStyle.StateFlag.State_Selected: painter.setPen(Qt.GlobalColor.white) diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index 346076cb3..ecdf7ca0c 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -25,7 +25,6 @@ from PyQt6.QtWidgets import QLabel from PyQt6.QtWidgets import QLayout from PyQt6.QtWidgets import QListView -from PyQt6.QtWidgets import QStyle from PyQt6.QtWidgets import QStyledItemDelegate from PyQt6.QtWidgets import QStyleOptionViewItem from PyQt6.QtWidgets import QVBoxLayout @@ -39,6 +38,7 @@ from model.rating import Rating from util.qt import qpainter from util.qt_list_model import QtListModel +from util.qtstyleditemdelegate import QtStyledItemDelegate class GameResult(Enum): @@ -164,9 +164,9 @@ def add_player(self, player: dict) -> None: self._add_item(player, player["player"]["id"]) -class ScoreboardItemDelegate(QStyledItemDelegate): +class ScoreboardItemDelegate(QtStyledItemDelegate): def __init__(self) -> None: - QStyledItemDelegate.__init__(self) + QtStyledItemDelegate.__init__(self) self._row_height = 22 def row_height(self) -> int: @@ -208,24 +208,25 @@ def _draw_icon( return icon_rect def _draw_nick_and_rating( - self, - painter: QPainter, - rect: QRect, - player_data: ScoreboardModelItem, - alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, + self, + painter: QPainter, + rect: QRect, + player_data: ScoreboardModelItem, + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, ) -> QRect: rating = player_data.rating() rating_str = f"{rating}" if rating is not None else "???" - text = self._get_elided_text(painter, f"{player_data.login()} ({rating_str})", rect.width()) - painter.drawText(rect, alignment, text) + text = f"{player_data.login()} ({rating_str})" + elided = self._get_elided_text(painter, text, width=rect.width()) + painter.drawText(rect, alignment, elided) return rect def _draw_rating_change( - self, - painter: QPainter, - rect: QRect, - player_data: ScoreboardModelItem, - alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, + self, + painter: QPainter, + rect: QRect, + player_data: ScoreboardModelItem, + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, ) -> QRect: change = player_data.rating_change() rating_change_rect = QRect(rect) @@ -242,11 +243,6 @@ def _draw_rating_change( painter.drawText(rating_change_rect, Qt.AlignmentFlag.AlignCenter, f"{change:+}") return rating_change_rect - def _draw_clear_option(self, painter: QPainter, option: QStyleOptionViewItem) -> None: - option.text = "" - control_element = QStyle.ControlElement.CE_ItemViewItem - option.widget.style().drawControl(control_element, option, painter, option.widget) - def _shrink_rect_along( self, rect: QRect, @@ -263,10 +259,6 @@ def _shrink_rect_along( adjustments[index] = adjustment * direction return rect.adjusted(*adjustments) - def _get_elided_text(self, painter: QPainter, text: str, width: int) -> str: - metrics = painter.fontMetrics() - return metrics.elidedText(text, Qt.TextElideMode.ElideRight, width) - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: player_data: ScoreboardModelItem = index.data() team_model: ScoreboardModel = index.model() diff --git a/src/util/qtstyleditemdelegate.py b/src/util/qtstyleditemdelegate.py new file mode 100644 index 000000000..924a587a3 --- /dev/null +++ b/src/util/qtstyleditemdelegate.py @@ -0,0 +1,24 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QIcon +from PyQt6.QtGui import QPainter +from PyQt6.QtWidgets import QStyle +from PyQt6.QtWidgets import QStyledItemDelegate +from PyQt6.QtWidgets import QStyleOptionViewItem + + +class QtStyledItemDelegate(QStyledItemDelegate): + def _get_elided_text( + self, + painter: QPainter, + text: str, + mode: Qt.TextElideMode = Qt.TextElideMode.ElideRight, + width: int = 0, + ) -> str: + metrics = painter.fontMetrics() + return metrics.elidedText(text, mode, width) + + def _draw_clear_option(self, painter: QPainter, option: QStyleOptionViewItem) -> None: + option.icon = QIcon() + option.text = "" + control_element = QStyle.ControlElement.CE_ItemViewItem + option.widget.style().drawControl(control_element, option, painter, option.widget) From c1cb73c525bcec7e6391637e66e604ae6711fe6b Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 20 Jul 2024 09:45:40 +0300 Subject: [PATCH 42/73] Move some qt related things into dedicated folder --- src/chat/channel_widget.py | 2 +- src/chat/chatter_model.py | 8 ++++---- src/coop/_coopwidget.py | 2 +- src/coop/cooptableitemdelegate.py | 2 +- src/downloadManager/__init__.py | 2 +- src/fa/game_updater/patcher.py | 2 +- src/games/gameitem.py | 6 +++--- src/games/gamemodel.py | 2 +- .../itemviews/styleditemdelegate.py} | 2 +- src/qt/itemviews/tableitemdelegte.py | 6 +++--- .../qt_list_model.py => qt/models/qtlistmodel.py} | 0 src/{util/qt.py => qt/utils.py} | 0 src/replays/replayitem.py | 10 +++++----- src/stats/itemviews/leaderboarditemdelegate.py | 2 +- 14 files changed, 23 insertions(+), 23 deletions(-) rename src/{util/qtstyleditemdelegate.py => qt/itemviews/styleditemdelegate.py} (94%) rename src/{util/qt_list_model.py => qt/models/qtlistmodel.py} (100%) rename src/{util/qt.py => qt/utils.py} (100%) diff --git a/src/chat/channel_widget.py b/src/chat/channel_widget.py index a4a0af1fc..b67cea2e7 100644 --- a/src/chat/channel_widget.py +++ b/src/chat/channel_widget.py @@ -8,7 +8,7 @@ from PyQt6.QtGui import QTextCursor from PyQt6.QtGui import QTextDocument -from util.qt import monkeypatch_method +from qt.utils import monkeypatch_method logger = logging.getLogger(__name__) diff --git a/src/chat/chatter_model.py b/src/chat/chatter_model.py index 62a37f364..031505443 100644 --- a/src/chat/chatter_model.py +++ b/src/chat/chatter_model.py @@ -20,8 +20,8 @@ from fa import maps from model.game import GameState from model.rating import RatingType -from util.qt_list_model import QtListModel -from util.qtstyleditemdelegate import QtStyledItemDelegate +from qt.itemviews.styleditemdelegate import StyledItemDelegate +from qt.models.qtlistmodel import QtListModel class ChatterModel(QtListModel): @@ -336,9 +336,9 @@ def nick_tooltip(self, data): return self.country_tooltip(data) -class ChatterItemDelegate(QtStyledItemDelegate): +class ChatterItemDelegate(StyledItemDelegate): def __init__(self, layout, formatter): - QtStyledItemDelegate.__init__(self) + StyledItemDelegate.__init__(self) self.layout = layout self._formatter = formatter diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py index 01ea1a5ba..8b06f07de 100644 --- a/src/coop/_coopwidget.py +++ b/src/coop/_coopwidget.py @@ -27,8 +27,8 @@ from games.gamemodel import GameModel from games.hostgamewidget import GameLauncher from model.game import Game +from qt.utils import qopen from ui.busy_widget import BusyWidget -from util.qt import qopen if TYPE_CHECKING: from client._clientwindow import ClientWindow diff --git a/src/coop/cooptableitemdelegate.py b/src/coop/cooptableitemdelegate.py index 60afc71d3..04b8c2191 100644 --- a/src/coop/cooptableitemdelegate.py +++ b/src/coop/cooptableitemdelegate.py @@ -5,7 +5,7 @@ from PyQt6.QtWidgets import QStyleOptionViewItem from qt.itemviews.tableitemdelegte import TableItemDelegate -from util.qt import qpainter +from qt.utils import qpainter class CoopLeaderboardItemDelegate(TableItemDelegate): diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index b62c1bee2..7e3387e53 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -19,8 +19,8 @@ from PyQt6.QtNetwork import QNetworkRequest from config import Settings +from qt.utils import qopen from util import AVATARS_CACHE_DIR -from util.qt import qopen logger = logging.getLogger(__name__) diff --git a/src/fa/game_updater/patcher.py b/src/fa/game_updater/patcher.py index 028c52d02..18e7ba221 100644 --- a/src/fa/game_updater/patcher.py +++ b/src/fa/game_updater/patcher.py @@ -2,7 +2,7 @@ from PyQt6.QtCore import QFile -from util.qt import qopen +from qt.utils import qopen logger = logging.getLogger(__name__) diff --git a/src/games/gameitem.py b/src/games/gameitem.py index 549d113f7..d32527bf1 100644 --- a/src/games/gameitem.py +++ b/src/games/gameitem.py @@ -8,7 +8,7 @@ import util from fa import maps -from util.qtstyleditemdelegate import QtStyledItemDelegate +from qt.itemviews.styleditemdelegate import StyledItemDelegate class GameView(QtCore.QObject): @@ -39,7 +39,7 @@ def _game_double_clicked(self, idx): self.game_double_clicked.emit(idx.data().game) -class GameItemDelegate(QtStyledItemDelegate): +class GameItemDelegate(StyledItemDelegate): ICON_RECT = 100 ICON_CLIP_TOP_LEFT = 3 ICON_CLIP_BOTTOM_RIGHT = -7 @@ -55,7 +55,7 @@ class GameItemDelegate(QtStyledItemDelegate): PADDING = 10 def __init__(self, formatter): - QtStyledItemDelegate.__init__(self) + StyledItemDelegate.__init__(self) self._formatter = formatter self.tooltip_filter = GameTooltipFilter(self._formatter) diff --git a/src/games/gamemodel.py b/src/games/gamemodel.py index 69c07471c..eefe78f8b 100644 --- a/src/games/gamemodel.py +++ b/src/games/gamemodel.py @@ -5,7 +5,7 @@ from games.moditem import mod_invisible from model.game import GameState -from util.qt_list_model import QtListModel +from qt.models.qtlistmodel import QtListModel from .gamemodelitem import GameModelItem diff --git a/src/util/qtstyleditemdelegate.py b/src/qt/itemviews/styleditemdelegate.py similarity index 94% rename from src/util/qtstyleditemdelegate.py rename to src/qt/itemviews/styleditemdelegate.py index 924a587a3..f0498d262 100644 --- a/src/util/qtstyleditemdelegate.py +++ b/src/qt/itemviews/styleditemdelegate.py @@ -6,7 +6,7 @@ from PyQt6.QtWidgets import QStyleOptionViewItem -class QtStyledItemDelegate(QStyledItemDelegate): +class StyledItemDelegate(QStyledItemDelegate): def _get_elided_text( self, painter: QPainter, diff --git a/src/qt/itemviews/tableitemdelegte.py b/src/qt/itemviews/tableitemdelegte.py index ec1140f78..7ca184bcb 100644 --- a/src/qt/itemviews/tableitemdelegte.py +++ b/src/qt/itemviews/tableitemdelegte.py @@ -5,11 +5,11 @@ from PyQt6.QtWidgets import QStyleOptionViewItem from PyQt6.QtWidgets import QTableView -from util.qt import qpainter -from util.qtstyleditemdelegate import QtStyledItemDelegate +from qt.itemviews.styleditemdelegate import StyledItemDelegate +from qt.utils import qpainter -class TableItemDelegate(QtStyledItemDelegate): +class TableItemDelegate(StyledItemDelegate): """ Highlights the entire row on mouse hover when table's SelectionBehavior is set to SelectRows diff --git a/src/util/qt_list_model.py b/src/qt/models/qtlistmodel.py similarity index 100% rename from src/util/qt_list_model.py rename to src/qt/models/qtlistmodel.py diff --git a/src/util/qt.py b/src/qt/utils.py similarity index 100% rename from src/util/qt.py rename to src/qt/utils.py diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index ecdf7ca0c..379c0c233 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -36,9 +36,9 @@ from fa import maps from games.moditem import mods from model.rating import Rating -from util.qt import qpainter -from util.qt_list_model import QtListModel -from util.qtstyleditemdelegate import QtStyledItemDelegate +from qt.itemviews.styleditemdelegate import StyledItemDelegate +from qt.models.qtlistmodel import QtListModel +from qt.utils import qpainter class GameResult(Enum): @@ -164,9 +164,9 @@ def add_player(self, player: dict) -> None: self._add_item(player, player["player"]["id"]) -class ScoreboardItemDelegate(QtStyledItemDelegate): +class ScoreboardItemDelegate(StyledItemDelegate): def __init__(self) -> None: - QtStyledItemDelegate.__init__(self) + StyledItemDelegate.__init__(self) self._row_height = 22 def row_height(self) -> int: diff --git a/src/stats/itemviews/leaderboarditemdelegate.py b/src/stats/itemviews/leaderboarditemdelegate.py index 3142963ed..244476558 100644 --- a/src/stats/itemviews/leaderboarditemdelegate.py +++ b/src/stats/itemviews/leaderboarditemdelegate.py @@ -5,7 +5,7 @@ from PyQt6.QtWidgets import QStyleOptionViewItem from qt.itemviews.tableitemdelegte import TableItemDelegate -from util.qt import qpainter +from qt.utils import qpainter class LeaderboardItemDelegate(TableItemDelegate): From d20e5073dc12d94b42b281077ce6458dde2fe8ea Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 21 Jul 2024 21:02:47 +0300 Subject: [PATCH 43/73] Take replay scoreboard and its friends away from replayitem file --- src/replays/models.py | 129 +++++++ src/replays/replayitem.py | 464 +------------------------- src/replays/scoreboard.py | 222 ++++++++++++ src/replays/scoreboarditemdelegate.py | 132 ++++++++ 4 files changed, 484 insertions(+), 463 deletions(-) create mode 100644 src/replays/scoreboard.py create mode 100644 src/replays/scoreboarditemdelegate.py diff --git a/src/replays/models.py b/src/replays/models.py index 21700a128..6ab949989 100644 --- a/src/replays/models.py +++ b/src/replays/models.py @@ -1,5 +1,18 @@ +from __future__ import annotations + +from typing import Any +from typing import Callable + +from PyQt6.QtCore import QObject +from PyQt6.QtCore import Qt +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtGui import QIcon + +import util +from model.rating import Rating from pydantic import BaseModel from pydantic import Field +from qt.models.qtlistmodel import QtListModel # FIXME - this is what the widget uses so far, we should define this @@ -13,3 +26,119 @@ class MetadataModel(BaseModel): teams: dict[str, list[str]] title: str game_time: float = Field(0.0) + + +class ScoreboardModelItem(QObject): + updated = pyqtSignal() + + def __init__(self, player: dict, mod: str | None) -> None: + QObject.__init__(self) + self.player = player + self.mod = mod or "" + + if len(self.player["ratingChanges"]) > 0: + self.rating_stats = self.player["ratingChanges"][0] + else: + self.rating_stats = None + + @classmethod + def builder(cls, mod: str | None) -> Callable[[dict], ScoreboardModelItem]: + def build(data: dict) -> ScoreboardModelItem: + return cls(data, mod) + return build + + def score(self) -> int: + return self.player["score"] + + def login(self) -> str: + return self.player["player"]["login"] + + def rating_before(self) -> int: + # gamePlayerStats' fields 'before*' and 'after*' can be removed + # at any time and 'ratingChanges' can be absent if game result is + # undefined + if self.rating_stats is not None: + rating = Rating( + self.rating_stats["meanBefore"], + self.rating_stats["deviationBefore"], + ) + return round(rating.displayed()) + elif self.player.get("beforeMean") and self.player.get("beforeDeviation"): + rating = Rating( + self.player["beforeMean"], + self.player["beforeDeviation"], + ) + return round(rating.displayed()) + return 0 + + def rating_after(self) -> int: + if self.rating_stats is not None: + rating = Rating( + self.rating_stats["meanAfter"], + self.rating_stats["deviationAfter"], + ) + return round(rating.displayed()) + elif self.player.get("afterMean") and self.player.get("afterDeviation"): + rating = Rating( + self.player["afterMean"], + self.player["afterDeviation"], + ) + return round(rating.displayed()) + return 0 + + def rating(self) -> int | None: + if self.rating_stats is None and "beforeMean" not in self.player: + return None + return self.rating_before() + + def rating_change(self) -> int: + if self.rating_stats is None: + return 0 + return self.rating_after() - self.rating_before() + + def faction_name(self) -> str: + if "faction" in self.player: + if self.player["faction"] == 1: + faction = "UEF" + elif self.player["faction"] == 2: + faction = "Aeon" + elif self.player["faction"] == 3: + faction = "Cybran" + elif self.player["faction"] == 4: + faction = "Seraphim" + elif self.player["faction"] == 5: + if self.mod == "nomads": + faction = "Nomads" + else: + faction = "Random" + elif self.player["faction"] == 6: + if self.mod == "nomads": + faction = "Random" + else: + faction = "broken" + else: + faction = "broken" + else: + faction = "Missing" + return faction + + def icon(self) -> QIcon: + return util.THEME.icon(f"replays/{self.faction_name()}.png") + + +class ScoreboardModel(QtListModel): + def __init__( + self, + spoiled: bool, + alignment: Qt.AlignmentFlag, + item_builder: Callable[[Any], QObject], + ) -> None: + QtListModel.__init__(self, item_builder) + self.spoiled = spoiled + self.alignment = alignment + + def get_alignment(self) -> Qt.AlignmentFlag: + return self.alignment + + def add_player(self, player: dict) -> None: + self._add_item(player, player["player"]["id"]) diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index 379c0c233..e9460fc60 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -3,480 +3,18 @@ import time from datetime import datetime from datetime import timezone -from enum import Enum -from typing import Any -from typing import Callable -from typing import Iterable from PyQt6 import QtCore from PyQt6 import QtGui from PyQt6 import QtWidgets -from PyQt6.QtCore import QModelIndex -from PyQt6.QtCore import QObject -from PyQt6.QtCore import QRect -from PyQt6.QtCore import QSize -from PyQt6.QtCore import Qt -from PyQt6.QtCore import pyqtSignal from PyQt6.QtGui import QAction -from PyQt6.QtGui import QIcon -from PyQt6.QtGui import QPainter -from PyQt6.QtGui import QPen -from PyQt6.QtWidgets import QHBoxLayout -from PyQt6.QtWidgets import QLabel -from PyQt6.QtWidgets import QLayout -from PyQt6.QtWidgets import QListView -from PyQt6.QtWidgets import QStyledItemDelegate -from PyQt6.QtWidgets import QStyleOptionViewItem -from PyQt6.QtWidgets import QVBoxLayout -from PyQt6.QtWidgets import QWidget import util from config import Settings from downloadManager import DownloadRequest from fa import maps from games.moditem import mods -from model.rating import Rating -from qt.itemviews.styleditemdelegate import StyledItemDelegate -from qt.models.qtlistmodel import QtListModel -from qt.utils import qpainter - - -class GameResult(Enum): - WIN = "Win" - LOSE = "Lose" - PLAYING = "Playing" - UNKNOWN = "???" - - -class ScoreboardModelItem(QObject): - updated = pyqtSignal() - - def __init__(self, player: dict, mod: str | None) -> None: - QObject.__init__(self) - self.player = player - self.mod = mod or "" - - if len(self.player["ratingChanges"]) > 0: - self.rating_stats = self.player["ratingChanges"][0] - else: - self.rating_stats = None - - @classmethod - def builder(cls, mod: str | None) -> Callable[[dict], ScoreboardModelItem]: - def build(data: dict) -> ScoreboardModelItem: - return cls(data, mod) - return build - - def score(self) -> int: - return self.player["score"] - - def login(self) -> str: - return self.player["player"]["login"] - - def rating_before(self) -> int: - # gamePlayerStats' fields 'before*' and 'after*' can be removed - # at any time and 'ratingChanges' can be absent if game result is - # undefined - if self.rating_stats is not None: - rating = Rating( - self.rating_stats["meanBefore"], - self.rating_stats["deviationBefore"], - ) - return round(rating.displayed()) - elif self.player.get("beforeMean") and self.player.get("beforeDeviation"): - rating = Rating( - self.player["beforeMean"], - self.player["beforeDeviation"], - ) - return round(rating.displayed()) - return 0 - - def rating_after(self) -> int: - if self.rating_stats is not None: - rating = Rating( - self.rating_stats["meanAfter"], - self.rating_stats["deviationAfter"], - ) - return round(rating.displayed()) - elif self.player.get("afterMean") and self.player.get("afterDeviation"): - rating = Rating( - self.player["afterMean"], - self.player["afterDeviation"], - ) - return round(rating.displayed()) - return 0 - - def rating(self) -> int | None: - if self.rating_stats is None and "beforeMean" not in self.player: - return None - return self.rating_before() - - def rating_change(self) -> int: - if self.rating_stats is None: - return 0 - return self.rating_after() - self.rating_before() - - def faction_name(self) -> str: - if "faction" in self.player: - if self.player["faction"] == 1: - faction = "UEF" - elif self.player["faction"] == 2: - faction = "Aeon" - elif self.player["faction"] == 3: - faction = "Cybran" - elif self.player["faction"] == 4: - faction = "Seraphim" - elif self.player["faction"] == 5: - if self.mod == "nomads": - faction = "Nomads" - else: - faction = "Random" - elif self.player["faction"] == 6: - if self.mod == "nomads": - faction = "Random" - else: - faction = "broken" - else: - faction = "broken" - else: - faction = "Missing" - return faction - - def icon(self) -> QIcon: - return util.THEME.icon(f"replays/{self.faction_name()}.png") - - -class ScoreboardModel(QtListModel): - def __init__( - self, - spoiled: bool, - alignment: Qt.AlignmentFlag, - item_builder: Callable[[Any], QObject], - ) -> None: - QtListModel.__init__(self, item_builder) - self.spoiled = spoiled - self.alignment = alignment - - def get_alignment(self) -> Qt.AlignmentFlag: - return self.alignment - - def add_player(self, player: dict) -> None: - self._add_item(player, player["player"]["id"]) - - -class ScoreboardItemDelegate(StyledItemDelegate): - def __init__(self) -> None: - StyledItemDelegate.__init__(self) - self._row_height = 22 - - def row_height(self) -> int: - return self._row_height - - def sizeHint(self, option, index) -> QSize: - size = QStyledItemDelegate.sizeHint(self, option, index) - return QSize(size.width(), self._row_height) - - def _draw_score( - self, - painter: QPainter, - rect: QRect, - player_data: ScoreboardModelItem, - alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, - ) -> QRect: - score = f"{player_data.score()}" - score_rect = QRect(rect) - score_rect.setWidth(20) - if alignment == Qt.AlignmentFlag.AlignRight: - score_rect.moveLeft(rect.width() - score_rect.width()) - painter.drawText(score_rect, Qt.AlignmentFlag.AlignCenter, score) - return score_rect - - def _draw_icon( - self, - painter: QPainter, - rect: QRect, - player_data: ScoreboardModelItem, - alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, - ) -> QRect: - icon = player_data.icon() - icon_rect = QRect(rect) - icon_rect.setWidth(40) - icon_rect.setHeight(20) - if alignment == Qt.AlignmentFlag.AlignRight: - icon_rect.moveLeft(rect.width() - icon_rect.width()) - icon.paint(painter, icon_rect) - return icon_rect - - def _draw_nick_and_rating( - self, - painter: QPainter, - rect: QRect, - player_data: ScoreboardModelItem, - alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, - ) -> QRect: - rating = player_data.rating() - rating_str = f"{rating}" if rating is not None else "???" - text = f"{player_data.login()} ({rating_str})" - elided = self._get_elided_text(painter, text, width=rect.width()) - painter.drawText(rect, alignment, elided) - return rect - - def _draw_rating_change( - self, - painter: QPainter, - rect: QRect, - player_data: ScoreboardModelItem, - alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, - ) -> QRect: - change = player_data.rating_change() - rating_change_rect = QRect(rect) - rating_change_rect.setWidth(30) - if alignment == Qt.AlignmentFlag.AlignRight: - rating_change_rect.moveLeft(rect.width() - rating_change_rect.width()) - color = painter.pen().color() - if change > 0: - color = Qt.GlobalColor.green - elif change < 0: - color = Qt.GlobalColor.red - with qpainter(painter): - painter.setPen(QPen(color)) - painter.drawText(rating_change_rect, Qt.AlignmentFlag.AlignCenter, f"{change:+}") - return rating_change_rect - - def _shrink_rect_along( - self, - rect: QRect, - adjustment: int, - alignment: Qt.AlignmentFlag, - ) -> QRect: - """ - Returns a new rect shrinked from left or right side - by given adjustment - """ - direction = 1 if alignment == Qt.AlignmentFlag.AlignLeft else -1 - index = 0 if alignment == Qt.AlignmentFlag.AlignLeft else 2 - adjustments = [0, 0, 0, 0] - adjustments[index] = adjustment * direction - return rect.adjusted(*adjustments) - - def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: - player_data: ScoreboardModelItem = index.data() - team_model: ScoreboardModel = index.model() - team_alignment = team_model.get_alignment() - rect = QRect(option.rect) - - with qpainter(painter): - self._draw_clear_option(painter, option) - - if team_model.spoiled: - score_rect = self._draw_score(painter, rect, player_data, team_alignment) - rect = self._shrink_rect_along(rect, score_rect.width(), team_alignment) - - diff_rect = self._draw_rating_change(painter, rect, player_data, team_alignment) - rect = self._shrink_rect_along(rect, diff_rect.width(), team_alignment) - - icon_rect = self._draw_icon(painter, rect, player_data, team_alignment) - rect = self._shrink_rect_along(rect, icon_rect.width() + 3, team_alignment) - - self._draw_nick_and_rating(painter, rect, player_data, team_alignment) - - -class Scoreboard(QWidget): - GAME_RESULT_RESERVED_HEIGHT = 30 - TITLE_RESERVED_HEIGHT = 30 - - def __init__( - self, - mod: str | None, - winner: dict | None, - spoiled: bool, - duration: str | None, - teamwin: dict | None, - uid: str, - teams: dict, - ) -> None: - super().__init__() - self.winner = winner - self.spoiled = spoiled - self.duration = duration or "" - self.teamwin = teamwin - self.uid = uid - self.teams = teams - self.num_teams = len(self.teams) - self.biggest_team = max(len(team) for team in self.teams.values()) if self.teams else 0 - - self.main_layout = QVBoxLayout() - if self.num_teams == 2: - self.teams_layout = QHBoxLayout() - else: - self.teams_layout = QVBoxLayout() - self.setLayout(self.main_layout) - self.mod = mod - self._height = 0 - self._team_heights = [] - - def create_teamlist_view(self) -> QListView: - team_view = QListView() - team_view.setObjectName("replayScoreTeamList") - return team_view - - def _create_team_result_label(self, text: str) -> QLabel: - result_label = QLabel(text) - result_label.setObjectName("replayGameResult") - result_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - font = result_label.font() - font.setPointSize(font.pointSize() + 4) - result_label.setFont(font) - - return result_label - - def add_result_label(self, text: str, layout: QLayout) -> None: - result_label = self._create_team_result_label(text) - layout.addWidget(result_label) - - def teamview_rows(self, view: QListView) -> int: - model: ScoreboardModel = view.model() - if self.num_teams == 2: - return self.biggest_team - return model.rowCount(QModelIndex()) - - def teamview_height(self, view: QListView) -> int: - row_count = self.teamview_rows(view) - delegate: ScoreboardItemDelegate = view.itemDelegate() - return row_count * delegate.row_height() - - def adjust_teamview_height(self, view: QListView, height: int) -> None: - view.setMinimumHeight(height) - view.setMaximumHeight(height) - - def add_team_score( - self, - alignment: Qt.AlignmentFlag, - team_result: GameResult, - players: Iterable[dict], - ) -> None: - team_layout = QVBoxLayout() - self.add_result_label(team_result.value, team_layout) - - model = ScoreboardModel(self.spoiled, alignment, ScoreboardModelItem.builder(self.mod)) - for player in players: - model.add_player(player) - - team_view = self.create_teamlist_view() - team_view.setModel(model) - team_view.setItemDelegate(ScoreboardItemDelegate()) - team_layout.addWidget(team_view) - - view_height = self.teamview_height(team_view) - self.adjust_teamview_height(team_view, view_height) - self._team_heights.append(self.GAME_RESULT_RESERVED_HEIGHT + view_height) - - self.teams_layout.addLayout(team_layout) - - def add_team_score_if_needed( - self, - alignment: Qt.AlignmentFlag, - team_result: GameResult, - players: Iterable[dict], - ) -> None: - if len(list(players)) == 0: - return - self.add_team_score(alignment, team_result, players) - - def height(self) -> int: - # there must be a way to dissect all of the layouts and widgets - # with all of their paddings, spacings, margins etc. to determine - # scoreboard's precise height, but this works good enough - magic = 40 - if len(self.teams) == 2: - return self._height + max(self._team_heights) + magic - return sum((self._height, *self._team_heights, magic)) - - def width(self) -> int: - if len(self.teams) == 2: - return 560 if self.spoiled else 500 - return 335 if self.spoiled else 275 - - def one_team_layout(self) -> None: - team = list(self.teams.values())[0] - alignment = Qt.AlignmentFlag.AlignLeft - if self.spoiled: - winners, losers = [], [] - for player in team: - if self.winner is not None and player["score"] == self.winner["score"]: - winners.append(player) - else: - losers.append(player) - self.add_team_score_if_needed(alignment, self.game_result(is_winner=True), winners) - self.add_team_score_if_needed(alignment, self.game_result(is_winner=False), losers) - else: - self.add_team_score_if_needed(alignment, self.game_result(is_winner=False), team) - self.main_layout.addLayout(self.teams_layout) - - def default_layout(self) -> None: - alignment = Qt.AlignmentFlag.AlignLeft - for team in self.teams: - game_result = self.game_result(is_winner=(team == self.teamwin)) - self.add_team_score(alignment, game_result, self.teams[team]) - self.main_layout.addLayout(self.teams_layout) - - def game_result(self, *, is_winner: bool) -> GameResult: - if not self.spoiled: - return GameResult.UNKNOWN - if "playing" in self.duration: - return GameResult.PLAYING - return (GameResult.LOSE, GameResult.WIN)[is_winner] - - def two_teams_layout(self) -> None: - for index, team_num in enumerate(self.teams): - alignment = (Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignRight)[index] - is_winner = team_num == self.teamwin - game_result = self.game_result(is_winner=is_winner) - self.add_team_score(alignment, game_result, self.teams[team_num]) - if index == 0: - self.add_vs_label() - self.main_layout.addLayout(self.teams_layout) - - def create_title_label(self) -> QLabel: - title_label = QLabel(f"Replay UID: {self.uid}") - title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - title_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) - - title_font = title_label.font() - title_font.setPointSize(title_font.pointSize() + 4) - title_font.setBold(True) - title_label.setFont(title_font) - - return title_label - - def add_title_label(self) -> None: - self.main_layout.addWidget(self.create_title_label()) - self._height += self.TITLE_RESERVED_HEIGHT - - def create_vs_label(self) -> QLabel: - vs_label = QLabel("VS") - vs_label.setObjectName("VSLabel") - vs_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - - font = vs_label.font() - font.setPointSize(font.pointSize() + 13) - vs_label.setFont(font) - - return vs_label - - def add_vs_label(self) -> None: - self.teams_layout.addWidget(self.create_vs_label()) - - def setup(self) -> None: - self.add_title_label() - - if self.num_teams == 1: - self.one_team_layout() - elif self.num_teams == 2: - self.two_teams_layout() - else: - self.default_layout() +from replays.scoreboard import Scoreboard class ReplayItemDelegate(QtWidgets.QStyledItemDelegate): diff --git a/src/replays/scoreboard.py b/src/replays/scoreboard.py new file mode 100644 index 000000000..5e03513ca --- /dev/null +++ b/src/replays/scoreboard.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +from enum import Enum +from typing import Iterable + +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QHBoxLayout +from PyQt6.QtWidgets import QLabel +from PyQt6.QtWidgets import QLayout +from PyQt6.QtWidgets import QListView +from PyQt6.QtWidgets import QVBoxLayout +from PyQt6.QtWidgets import QWidget + +from replays.models import ScoreboardModel +from replays.models import ScoreboardModelItem +from replays.scoreboarditemdelegate import ScoreboardItemDelegate + + +class GameResult(Enum): + WIN = "Win" + LOSE = "Lose" + PLAYING = "Playing" + UNKNOWN = "???" + + +class Scoreboard(QWidget): + GAME_RESULT_RESERVED_HEIGHT = 30 + TITLE_RESERVED_HEIGHT = 30 + + def __init__( + self, + mod: str | None, + winner: dict | None, + spoiled: bool, + duration: str | None, + teamwin: dict | None, + uid: str, + teams: dict, + ) -> None: + super().__init__() + self.winner = winner + self.spoiled = spoiled + self.duration = duration or "" + self.teamwin = teamwin + self.uid = uid + self.teams = teams + self.num_teams = len(self.teams) + self.biggest_team = max(len(team) for team in self.teams.values()) if self.teams else 0 + + self.main_layout = QVBoxLayout() + if self.num_teams == 2: + self.teams_layout = QHBoxLayout() + else: + self.teams_layout = QVBoxLayout() + self.setLayout(self.main_layout) + self.mod = mod + self._height = 0 + self._team_heights = [] + + def create_teamlist_view(self) -> QListView: + team_view = QListView() + team_view.setObjectName("replayScoreTeamList") + return team_view + + def _create_team_result_label(self, text: str) -> QLabel: + result_label = QLabel(text) + result_label.setObjectName("replayGameResult") + result_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + font = result_label.font() + font.setPointSize(font.pointSize() + 4) + result_label.setFont(font) + + return result_label + + def add_result_label(self, text: str, layout: QLayout) -> None: + result_label = self._create_team_result_label(text) + layout.addWidget(result_label) + + def teamview_rows(self, view: QListView) -> int: + model: ScoreboardModel = view.model() + if self.num_teams == 2: + return self.biggest_team + return model.rowCount(QModelIndex()) + + def teamview_height(self, view: QListView) -> int: + row_count = self.teamview_rows(view) + delegate: ScoreboardItemDelegate = view.itemDelegate() + return row_count * delegate.row_height() + + def adjust_teamview_height(self, view: QListView, height: int) -> None: + view.setMinimumHeight(height) + view.setMaximumHeight(height) + + def add_team_score( + self, + alignment: Qt.AlignmentFlag, + team_result: GameResult, + players: Iterable[dict], + ) -> None: + team_layout = QVBoxLayout() + self.add_result_label(team_result.value, team_layout) + + model = ScoreboardModel(self.spoiled, alignment, ScoreboardModelItem.builder(self.mod)) + for player in players: + model.add_player(player) + + team_view = self.create_teamlist_view() + team_view.setModel(model) + team_view.setItemDelegate(ScoreboardItemDelegate()) + team_layout.addWidget(team_view) + + view_height = self.teamview_height(team_view) + self.adjust_teamview_height(team_view, view_height) + self._team_heights.append(self.GAME_RESULT_RESERVED_HEIGHT + view_height) + + self.teams_layout.addLayout(team_layout) + + def add_team_score_if_needed( + self, + alignment: Qt.AlignmentFlag, + team_result: GameResult, + players: Iterable[dict], + ) -> None: + if len(list(players)) == 0: + return + self.add_team_score(alignment, team_result, players) + + def height(self) -> int: + # there must be a way to dissect all of the layouts and widgets + # with all of their paddings, spacings, margins etc. to determine + # scoreboard's precise height, but this works good enough + magic = 40 + if len(self.teams) == 2: + return self._height + max(self._team_heights) + magic + return sum((self._height, *self._team_heights, magic)) + + def width(self) -> int: + if len(self.teams) == 2: + return 560 if self.spoiled else 500 + return 335 if self.spoiled else 275 + + def one_team_layout(self) -> None: + team = list(self.teams.values())[0] + alignment = Qt.AlignmentFlag.AlignLeft + if self.spoiled: + winners, losers = [], [] + for player in team: + if self.winner is not None and player["score"] == self.winner["score"]: + winners.append(player) + else: + losers.append(player) + self.add_team_score_if_needed(alignment, self.game_result(is_winner=True), winners) + self.add_team_score_if_needed(alignment, self.game_result(is_winner=False), losers) + else: + self.add_team_score_if_needed(alignment, self.game_result(is_winner=False), team) + self.main_layout.addLayout(self.teams_layout) + + def default_layout(self) -> None: + alignment = Qt.AlignmentFlag.AlignLeft + for team in self.teams: + game_result = self.game_result(is_winner=(team == self.teamwin)) + self.add_team_score(alignment, game_result, self.teams[team]) + self.main_layout.addLayout(self.teams_layout) + + def game_result(self, *, is_winner: bool) -> GameResult: + if not self.spoiled: + return GameResult.UNKNOWN + if "playing" in self.duration: + return GameResult.PLAYING + return (GameResult.LOSE, GameResult.WIN)[is_winner] + + def two_teams_layout(self) -> None: + for index, team_num in enumerate(self.teams): + alignment = (Qt.AlignmentFlag.AlignLeft, Qt.AlignmentFlag.AlignRight)[index] + is_winner = team_num == self.teamwin + game_result = self.game_result(is_winner=is_winner) + self.add_team_score(alignment, game_result, self.teams[team_num]) + if index == 0: + self.add_vs_label() + self.main_layout.addLayout(self.teams_layout) + + def create_title_label(self) -> QLabel: + title_label = QLabel(f"Replay UID: {self.uid}") + title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + title_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + title_font = title_label.font() + title_font.setPointSize(title_font.pointSize() + 4) + title_font.setBold(True) + title_label.setFont(title_font) + + return title_label + + def add_title_label(self) -> None: + self.main_layout.addWidget(self.create_title_label()) + self._height += self.TITLE_RESERVED_HEIGHT + + def create_vs_label(self) -> QLabel: + vs_label = QLabel("VS") + vs_label.setObjectName("VSLabel") + vs_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + font = vs_label.font() + font.setPointSize(font.pointSize() + 13) + vs_label.setFont(font) + + return vs_label + + def add_vs_label(self) -> None: + self.teams_layout.addWidget(self.create_vs_label()) + + def setup(self) -> None: + self.add_title_label() + + if self.num_teams == 1: + self.one_team_layout() + elif self.num_teams == 2: + self.two_teams_layout() + else: + self.default_layout() diff --git a/src/replays/scoreboarditemdelegate.py b/src/replays/scoreboarditemdelegate.py new file mode 100644 index 000000000..8b7633542 --- /dev/null +++ b/src/replays/scoreboarditemdelegate.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from PyQt6.QtCore import QModelIndex +from PyQt6.QtCore import QRect +from PyQt6.QtCore import QSize +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPainter +from PyQt6.QtGui import QPen +from PyQt6.QtWidgets import QStyledItemDelegate +from PyQt6.QtWidgets import QStyleOptionViewItem + +from qt.itemviews.styleditemdelegate import StyledItemDelegate +from qt.utils import qpainter +from replays.models import ScoreboardModel +from replays.models import ScoreboardModelItem + + +class ScoreboardItemDelegate(StyledItemDelegate): + def __init__(self) -> None: + StyledItemDelegate.__init__(self) + self._row_height = 22 + + def row_height(self) -> int: + return self._row_height + + def sizeHint(self, option, index) -> QSize: + size = QStyledItemDelegate.sizeHint(self, option, index) + return QSize(size.width(), self._row_height) + + def _draw_score( + self, + painter: QPainter, + rect: QRect, + player_data: ScoreboardModelItem, + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, + ) -> QRect: + score = f"{player_data.score()}" + score_rect = QRect(rect) + score_rect.setWidth(20) + if alignment == Qt.AlignmentFlag.AlignRight: + score_rect.moveLeft(rect.width() - score_rect.width()) + painter.drawText(score_rect, Qt.AlignmentFlag.AlignCenter, score) + return score_rect + + def _draw_icon( + self, + painter: QPainter, + rect: QRect, + player_data: ScoreboardModelItem, + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, + ) -> QRect: + icon = player_data.icon() + icon_rect = QRect(rect) + icon_rect.setWidth(40) + icon_rect.setHeight(20) + if alignment == Qt.AlignmentFlag.AlignRight: + icon_rect.moveLeft(rect.width() - icon_rect.width()) + icon.paint(painter, icon_rect) + return icon_rect + + def _draw_nick_and_rating( + self, + painter: QPainter, + rect: QRect, + player_data: ScoreboardModelItem, + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, + ) -> QRect: + rating = player_data.rating() + rating_str = f"{rating}" if rating is not None else "???" + text = f"{player_data.login()} ({rating_str})" + elided = self._get_elided_text(painter, text, width=rect.width()) + painter.drawText(rect, alignment, elided) + return rect + + def _draw_rating_change( + self, + painter: QPainter, + rect: QRect, + player_data: ScoreboardModelItem, + alignment: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignLeft, + ) -> QRect: + change = player_data.rating_change() + rating_change_rect = QRect(rect) + rating_change_rect.setWidth(30) + if alignment == Qt.AlignmentFlag.AlignRight: + rating_change_rect.moveLeft(rect.width() - rating_change_rect.width()) + color = painter.pen().color() + if change > 0: + color = Qt.GlobalColor.green + elif change < 0: + color = Qt.GlobalColor.red + with qpainter(painter): + painter.setPen(QPen(color)) + painter.drawText(rating_change_rect, Qt.AlignmentFlag.AlignCenter, f"{change:+}") + return rating_change_rect + + def _shrink_rect_along( + self, + rect: QRect, + adjustment: int, + alignment: Qt.AlignmentFlag, + ) -> QRect: + """ + Returns a new rect shrinked from left or right side + by given adjustment + """ + direction = 1 if alignment == Qt.AlignmentFlag.AlignLeft else -1 + index = 0 if alignment == Qt.AlignmentFlag.AlignLeft else 2 + adjustments = [0, 0, 0, 0] + adjustments[index] = adjustment * direction + return rect.adjusted(*adjustments) + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None: + player_data: ScoreboardModelItem = index.data() + team_model: ScoreboardModel = index.model() + team_alignment = team_model.get_alignment() + rect = QRect(option.rect) + + with qpainter(painter): + self._draw_clear_option(painter, option) + + if team_model.spoiled: + score_rect = self._draw_score(painter, rect, player_data, team_alignment) + rect = self._shrink_rect_along(rect, score_rect.width(), team_alignment) + + diff_rect = self._draw_rating_change(painter, rect, player_data, team_alignment) + rect = self._shrink_rect_along(rect, diff_rect.width(), team_alignment) + + icon_rect = self._draw_icon(painter, rect, player_data, team_alignment) + rect = self._shrink_rect_along(rect, icon_rect.width() + 3, team_alignment) + + self._draw_nick_and_rating(painter, rect, player_data, team_alignment) From dac1695f85bde2b0dc6b3349882ce69044fe0e25 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 21 Jul 2024 21:07:37 +0300 Subject: [PATCH 44/73] replayitem: Remove html formatters not used anymore --- src/replays/replayitem.py | 41 ++------------------------------------- 1 file changed, 2 insertions(+), 39 deletions(-) diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index e9460fc60..c5dd16c0e 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -89,44 +89,7 @@ def sizeHint(self, option, index, *args, **kwargs): class ReplayItem(QtWidgets.QTreeWidgetItem): - # list element - FORMATTER_REPLAY = str( - util.THEME.readfile( - "replays/formatters/replay.qthtml", - ), - ) - # replay-info elements - FORMATTER_REPLAY_INFORMATION = ( - "

Replay UID : {uid}

" - "VSs - if len(self.teams) == 2: - teams = "
{teams}" - "
" - ) - FORMATTER_REPLAY_TEAM_SPOILED = ( - "" - "{title}{players}" - ) - FORMATTER_REPLAY_FFA_SPOILED = ( - "" - "Win{winner}Lose{players}" - ) - FORMATTER_REPLAY_TEAM2_SPOILED = ( - "{players}" - "
{title}
" - ) - FORMATTER_REPLAY_TEAM2 = "{players}
" - FORMATTER_REPLAY_PLAYER_SCORE = ( - "{player_score}" - ) - FORMATTER_REPLAY_PLAYER_ICON = ( - "" - "" - ) - FORMATTER_REPLAY_PLAYER_LABEL = ( - "{player_name} " - "({player_rating})" - ) + REPLAY_TREE_ITEM_FORMATTER = str(util.THEME.readfile("replays/formatters/replay.qthtml")) def __init__(self, uid, parent, *args, **kwargs): QtWidgets.QTreeWidgetItem.__init__(self, *args, **kwargs) @@ -240,7 +203,7 @@ def update(self, replay, client): else: self.moddisplayname = self.mod - self.viewtext = self.FORMATTER_REPLAY.format( + self.viewtext = self.REPLAY_TREE_ITEM_FORMATTER.format( time=self.startHour, name=self.name, map=self.mapdisplayname, duration=self.duration, mod=self.moddisplayname, ) From ba450227345b800b5d3d43dcdb5b8b22869d9fed Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 21 Jul 2024 21:07:37 +0300 Subject: [PATCH 45/73] replayitem: Delete some obsolete instance vars --- src/replays/replayitem.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index c5dd16c0e..9d18ec109 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -98,12 +98,9 @@ def __init__(self, uid, parent, *args, **kwargs): self.parent = parent self.height = 70 self.viewtext = None - self.viewtextPlayer = None self.mapname = None self.mapdisplayname = None self.client = None - self.title = None - self.host = None self.startDate = None self.duration = None @@ -114,20 +111,16 @@ def __init__(self, uid, parent, *args, **kwargs): self.url = "{}/{}".format(Settings.get('replay_vault/host'), self.uid) self.teams = {} - self.access = None self.mod = None self.moddisplayname = None self.options = [] self.players = [] self.numberplayers = 0 - self.biggestTeam = 0 self.winner = None self.teamWin = None self.setHidden(True) - self.extraInfoWidth = 0 # panel with more information - self.extraInfoHeight = 0 # panel with more information self._map_dl_request = DownloadRequest() self._map_dl_request.done.connect(self._on_map_preview_downloaded) From bde2d4a5e522b2efeb308646c2b0a222713d8d26 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 22 Jul 2024 22:06:10 +0300 Subject: [PATCH 46/73] Remove UserRelations from User class this fixes circular dependency me -> relations -> me although it sound logical that User has relations there is only one user, whose relations are known and managable -- the client user (me) so it is also logical for User class to not have those relations and maybe UserRelations should be the class, from which we should get 'me', not the other way around --- src/chat/chatter_menu.py | 37 ++++++++++++++++----- src/client/_clientwindow.py | 15 ++++----- src/client/gameannouncer.py | 9 +++-- src/client/user.py | 2 -- src/coop/_coopwidget.py | 5 +-- src/games/_gameswidget.py | 26 ++++++++++----- src/games/gamemodel.py | 29 +++++++++++----- src/games/gamemodelitem.py | 32 ++++++++++++++---- src/games/hostgamewidget.py | 21 ++++++++++-- src/notifications/__init__.py | 9 ++--- src/replays/_replayswidget.py | 4 +-- src/stats/itemviews/leaderboardtablemenu.py | 15 ++++++--- 12 files changed, 140 insertions(+), 64 deletions(-) diff --git a/src/chat/chatter_menu.py b/src/chat/chatter_menu.py index 3df9c7670..47429e35b 100644 --- a/src/chat/chatter_menu.py +++ b/src/chat/chatter_menu.py @@ -6,6 +6,8 @@ from PyQt6.QtWidgets import QApplication from PyQt6.QtWidgets import QMenu +from model.chat.channelchatter import ChannelChatter +from model.chat.chatter import Chatter from model.game import GameState from model.player import Player from playercard.playerinfodialog import PlayerInfoDialog @@ -107,25 +109,37 @@ def player_actions(self, player, game, is_me): yield ChatterMenuItems.VIEW_IN_LEADERBOARDS yield ChatterMenuItems.VIEW_REPLAYS - def friend_actions(self, player, chatter, cc, is_me): + def friend_actions( + self, + player: Player | None, + chatter: Chatter, + cc: ChannelChatter, + is_me: bool, + ) -> Generator[ChatterMenuItems, None, None]: if is_me: return id_ = -1 if player is None else player.id name = chatter.name - if self._me.relations.model.is_friend(id_, name): + if self._client_window.user_relations.model.is_friend(id_, name): yield ChatterMenuItems.REMOVE_FRIEND - elif self._me.relations.model.is_foe(id_, name): + elif self._client_window.user_relations.model.is_foe(id_, name): yield ChatterMenuItems.REMOVE_FOE else: yield ChatterMenuItems.ADD_FRIEND yield ChatterMenuItems.ADD_FOE - def ignore_actions(self, player, chatter, cc, is_me): + def ignore_actions( + self, + player: Player, + chatter: Chatter, + cc: ChannelChatter, + is_me: bool, + ) -> Generator[ChatterMenuItems, None, None]: if is_me: return id_ = -1 if player is None else player.id name = chatter.name - if self._me.relations.model.is_chatterbox(id_, name): + if self._client_window.user_relations.model.is_chatterbox(id_, name): yield ChatterMenuItems.REMOVE_CHATTERBOX else: if not cc.is_mod() and not chatter.is_base_channel_mod(): @@ -213,8 +227,8 @@ def _handle_action(self, chatter, player, game, kind): def _copy_username(self, chatter): QApplication.clipboard().setText(chatter.name) - def _handle_friends(self, chatter, player, kind): - ctl = self._me.relations.controller + def _handle_friends(self, chatter: Chatter, player: Player, kind: ChatterMenuItems) -> None: + ctl = self._client_window.user_relations.controller ctl = ctl.faf if player is not None else ctl.irc uid = player.id if player is not None else chatter.name @@ -228,8 +242,13 @@ def _handle_friends(self, chatter, player, kind): elif kind == Items.REMOVE_FOE: ctl.foes.remove(uid) - def _handle_chatterboxes(self, chatter, player, kind): - ctl = self._me.relations.controller + def _handle_chatterboxes( + self, + chatter: Chatter, + player: Player | None, + kind: ChatterMenuItems, + ) -> None: + ctl = self._client_window.user_relations.controller ctl = ctl.faf if player is not None else ctl.irc uid = player.id if player is not None else chatter.name diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 2c6245892..b01ce9d2f 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -232,7 +232,6 @@ def __init__(self, *args, **kwargs): self.user_relations = UserRelations( relation_model, relation_controller, relation_trackers, ) - self.me.relations = self.user_relations self.map_preview_downloader = MapSmallPreviewDownloader(util.MAP_PREVIEW_SMALL_DIR) self.avatar_downloader = AvatarDownloader() @@ -241,7 +240,10 @@ def __init__(self, *args, **kwargs): self.map_generator = MapGeneratorManager() # Qt model for displaying active games. - self.game_model = GameModel(self.me, self.map_preview_downloader, self.gameset) + self.game_model = GameModel( + self.user_relations, self.me, + self.map_preview_downloader, self.gameset, + ) self.gameset.added.connect(self.fill_in_session_info) @@ -367,9 +369,7 @@ def __init__(self, *args, **kwargs): self.me, self.user_relations.model, util.THEME, ) - self.game_announcer = GameAnnouncer( - self.gameset, self.me, self.player_colors, - ) + self.game_announcer = GameAnnouncer(self.gameset, self.user_relations, self.player_colors) self.power = 0 # current user power self.id = 0 @@ -784,10 +784,7 @@ def setup(self): # build main window with the now active client self.news = NewsWidget(self) - self.coop = CoopWidget( - self, self.game_model, self.me, - self.gameview_builder, self.game_launcher, - ) + self.coop = CoopWidget(self, self.game_model, self.gameview_builder, self.game_launcher) self.games = GamesWidget( self, self.game_model, self.me, self.gameview_builder, self.game_launcher, diff --git a/src/client/gameannouncer.py b/src/client/gameannouncer.py index 5c84f7d85..5215183da 100644 --- a/src/client/gameannouncer.py +++ b/src/client/gameannouncer.py @@ -2,8 +2,11 @@ from PyQt6.QtCore import QTimer from PyQt6.QtCore import pyqtSignal +from client.playercolors import PlayerColors +from client.user import UserRelations from fa import maps from model.game import GameState +from model.gameset import Gameset class GameAnnouncer(QObject): @@ -11,10 +14,10 @@ class GameAnnouncer(QObject): ANNOUNCE_DELAY_SECS = 35 - def __init__(self, gameset, me, colors): + def __init__(self, gameset: Gameset, relations: UserRelations, colors: PlayerColors) -> None: QObject.__init__(self) self._gameset = gameset - self._me = me + self.user_relations = relations self._colors = colors self._gameset.newLobby.connect(self._announce_hosting) @@ -27,7 +30,7 @@ def __init__(self, gameset, me, colors): def _is_friend_host(self, game): return ( game.host_player is not None - and self._me.relations.model.is_friend(game.host_player.id) + and self.user_relations.model.is_friend(game.host_player.id) ) def _announce_hosting(self, game): diff --git a/src/client/user.py b/src/client/user.py index ca0221061..29a3b1ae0 100644 --- a/src/client/user.py +++ b/src/client/user.py @@ -25,8 +25,6 @@ def __init__(self, playerset): self._players.added.connect(self._on_player_change) self._players.removed.connect(self._on_player_change) - self.relations = None # FIXME - circular me -> rels -> me dep - @property def player(self): return self._player diff --git a/src/coop/_coopwidget.py b/src/coop/_coopwidget.py index 8b06f07de..fe47bc4bc 100644 --- a/src/coop/_coopwidget.py +++ b/src/coop/_coopwidget.py @@ -16,7 +16,6 @@ from api.coop_api import CoopResultApiAccessor from api.models.CoopResult import CoopResult from api.models.CoopScenario import CoopScenario -from client.user import User from coop.coopmapitem import CoopMapItem from coop.coopmapitem import CoopMapItemDelegate from coop.coopmodel import CoopGameFilterModel @@ -43,7 +42,6 @@ def __init__( self, client: ClientWindow, game_model: GameModel, - me: User, gameview_builder: GameViewBuilder, game_launcher: GameLauncher, ) -> None: @@ -53,8 +51,7 @@ def __init__( self.setupUi(self) self.client = client # type - ClientWindow - self._me = me - self._game_model = CoopGameFilterModel(self._me, game_model) + self._game_model = CoopGameFilterModel(self.client.user_relations, game_model) self._game_launcher = game_launcher self._gameview_builder = gameview_builder diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py index 9d1a2b057..1e85e724c 100644 --- a/src/games/_gameswidget.py +++ b/src/games/_gameswidget.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import logging +from typing import TYPE_CHECKING from PyQt6 import QtWidgets from PyQt6.QtCore import Qt @@ -10,13 +13,20 @@ import fa import util from api.featured_mod_api import FeaturedModApiConnector +from client.user import User from config import Settings from games.automatchframe import MatchmakerQueue +from games.gameitem import GameViewBuilder from games.gamemodel import CustomGameFilterModel +from games.gamemodel import GameModel +from games.hostgamewidget import GameLauncher from games.moditem import ModItem from games.moditem import mod_invisible from model.chat.channel import PARTY_CHANNEL_SUFFIX +if TYPE_CHECKING: + from client._clientwindow import ClientWindow + logger = logging.getLogger(__name__) FormClass, BaseClass = util.THEME.loadUiType("games/games.ui") @@ -81,20 +91,20 @@ class GamesWidget(FormClass, BaseClass): party_updated = pyqtSignal() def __init__( - self, - client, - game_model, - me, - gameview_builder, - game_launcher, - ): + self, + client: ClientWindow, + game_model: GameModel, + me: User, + gameview_builder: GameViewBuilder, + game_launcher: GameLauncher, + ) -> None: BaseClass.__init__(self, client) self.setupUi(self) self._me = me self.client = client # type - ClientWindow self.mods = {} - self._game_model = CustomGameFilterModel(self._me, game_model) + self._game_model = CustomGameFilterModel(self.client.user_relations, game_model) self._game_launcher = game_launcher self.apiConnector = FeaturedModApiConnector() diff --git a/src/games/gamemodel.py b/src/games/gamemodel.py index eefe78f8b..1c6d74dfa 100644 --- a/src/games/gamemodel.py +++ b/src/games/gamemodel.py @@ -3,16 +3,27 @@ from PyQt6.QtCore import QSortFilterProxyModel from PyQt6.QtCore import Qt +from client.user import User +from client.user import UserRelations +from downloadManager import MapPreviewDownloader from games.moditem import mod_invisible +from model.game import Game from model.game import GameState +from model.gameset import Gameset from qt.models.qtlistmodel import QtListModel from .gamemodelitem import GameModelItem class GameModel(QtListModel): - def __init__(self, me, preview_dler, gameset=None): - builder = GameModelItem.builder(me, preview_dler) + def __init__( + self, + relations: UserRelations, + me: User, + preview_dler: MapPreviewDownloader, + gameset: Gameset | None = None, + ) -> None: + builder = GameModelItem.builder(relations, me, preview_dler) QtListModel.__init__(self, builder) self._gameset = gameset @@ -40,10 +51,10 @@ class SortType(Enum): HOSTNAME = 3 AGE = 4 - def __init__(self, me, model): + def __init__(self, relations: UserRelations, model: GameModel) -> None: QSortFilterProxyModel.__init__(self) self._sort_type = self.SortType.AGE - self._me = me + self.user_relations = relations self.setSourceModel(model) self.sort(0) @@ -60,12 +71,12 @@ def lessThan(self, leftIndex, rightIndex): return False return False - def _lt_friend(self, left, right): + def _lt_friend(self, left: Game, right: Game) -> bool: hostl = -1 if left.host_player is None else left.host_player.id hostr = -1 if right.host_player is None else right.host_player.id return ( - self._me.relations.model.is_friend(hostl) - and not self._me.relations.model.is_friend(hostr) + self.user_relations.model.is_friend(hostl) + and not self.user_relations.model.is_friend(hostr) ) def _lt_type(self, left, right): @@ -108,8 +119,8 @@ def filter_accepts_game(self, game): class CustomGameFilterModel(GameSortModel): - def __init__(self, me, model): - GameSortModel.__init__(self, me, model) + def __init__(self, relations: UserRelations, model: GameModel) -> None: + GameSortModel.__init__(self, relations, model) self._hide_private_games = False def filter_accepts_game(self, game): diff --git a/src/games/gamemodelitem.py b/src/games/gamemodelitem.py index 5eab67fa3..1975d8c49 100644 --- a/src/games/gamemodelitem.py +++ b/src/games/gamemodelitem.py @@ -1,8 +1,16 @@ +from __future__ import annotations + +from typing import Callable + from PyQt6.QtCore import QObject from PyQt6.QtCore import pyqtSignal +from client.user import User +from client.user import UserRelations from downloadManager import DownloadRequest +from downloadManager import MapPreviewDownloader from fa import maps +from model.game import Game class GameModelItem(QObject): @@ -12,24 +20,34 @@ class GameModelItem(QObject): """ updated = pyqtSignal(object) - def __init__(self, game, me, preview_dler): + def __init__( + self, + game: Game, + relations: UserRelations, + me: User, + preview_dler: MapPreviewDownloader, + ) -> None: QObject.__init__(self) self.game = game self.game.updated.connect(self._game_updated) + self.user_relations = relations + self.user_relations.trackers.players.updated.connect(self._host_relation_changed) self._me = me - self._me.relations.trackers.players.updated.connect( - self._host_relation_changed, - ) self._me.clan_changed.connect(self._host_relation_changed) self._preview_dler = preview_dler self._preview_dl_request = DownloadRequest() self._preview_dl_request.done.connect(self._at_preview_downloaded) @classmethod - def builder(cls, me, preview_dler): - def build(game): - return cls(game, me, preview_dler) + def builder( + cls, + relations: UserRelations, + me: User, + preview_dler: MapPreviewDownloader, + ) -> Callable[[Game], GameModelItem]: + def build(game: Game) -> GameModelItem: + return cls(game, relations, me, preview_dler) return build def _game_updated(self): diff --git a/src/games/hostgamewidget.py b/src/games/hostgamewidget.py index 3b36fa850..107eefc72 100644 --- a/src/games/hostgamewidget.py +++ b/src/games/hostgamewidget.py @@ -1,16 +1,27 @@ +from __future__ import annotations + import logging +from typing import TYPE_CHECKING from PyQt6 import QtCore import fa.check import util import vaults.modvault.utils +from client.user import User +from downloadManager import MapPreviewDownloader from fa import maps +from games.gameitem import GameViewBuilder from games.gamemodel import GameModel from games.mapgenoptionsdialog import MapGenDialog from model.game import Game from model.game import GameState from model.game import GameVisibility +from model.playerset import Playerset + +if TYPE_CHECKING: + from client._clientwindow import ClientWindow + logger = logging.getLogger(__name__) @@ -263,8 +274,14 @@ def on_map_generated(self, mapname: str) -> None: self.set_map(mapname) -def build_launcher(playerset, me, client, view_builder, map_preview_dler): - model = GameModel(me, map_preview_dler) +def build_launcher( + playerset: Playerset, + me: User, + client: ClientWindow, + view_builder: GameViewBuilder, + map_preview_dler: MapPreviewDownloader, +) -> GameLauncher: + model = GameModel(client.user_relations, me, map_preview_dler) widget = HostGameWidget(client, view_builder, model) launcher = GameLauncher(playerset, me, client, widget) return launcher diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py index f5a4b74db..590b936cc 100644 --- a/src/notifications/__init__.py +++ b/src/notifications/__init__.py @@ -7,6 +7,7 @@ import util from config import Settings from fa import maps +from model.player import Player from notifications.ns_dialog import NotificationDialog from notifications.ns_settings import IngameNotification from notifications.ns_settings import NsSettingsDialog @@ -42,7 +43,7 @@ def __init__(self, client, gameset, playerset, me): self.user = util.THEME.icon("client/user.png", pix=True) - def _newPlayer(self, player): + def _newPlayer(self, player: Player) -> None: if ( self.isDisabled() or not self.settings.popupEnabled(self.USER_ONLINE) @@ -55,7 +56,7 @@ def _newPlayer(self, player): notify_mode = self.settings.getCustomSetting(self.USER_ONLINE, 'mode') if ( notify_mode != 'all' - and not self.me.relations.model.is_friend(player.id) + and not self.client.user_relations.model.is_friend(player.id) ): return @@ -69,7 +70,7 @@ def _newLobby(self, game): host = game.host_player notify_mode = self.settings.getCustomSetting(self.NEW_GAME, 'mode') if notify_mode != 'all': - if host is None or not self.me.relations.model.is_friend(host): + if host is None or not self.client.user_relations.model.is_friend(host): return self.events.append((self.NEW_GAME, game.copy())) @@ -98,7 +99,7 @@ def partyInvite(self, message): notify_mode = self.settings.getCustomSetting(self.PARTY_INVITE, 'mode') if ( notify_mode != 'all' - and not self.me.relations.model.is_friend(message["sender"]) + and not self.client.user_relations.model.is_friend(message["sender"]) ): return self.events.append((self.PARTY_INVITE, message)) diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index fc9533858..953ae93e2 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -121,9 +121,9 @@ def _set_misc_formatting(self, game): def _is_me(self, name): return client.instance.login == name - def _is_friend(self, name): + def _is_friend(self, name: str) -> bool: playerid = client.instance.players.getID(name) - return client.instance.me.relations.model.is_friend(playerid) + return client.instance.user_relations.model.is_friend(playerid) def _is_online(self, name): return name in client.instance.players diff --git a/src/stats/itemviews/leaderboardtablemenu.py b/src/stats/itemviews/leaderboardtablemenu.py index 10da8876b..94755d997 100644 --- a/src/stats/itemviews/leaderboardtablemenu.py +++ b/src/stats/itemviews/leaderboardtablemenu.py @@ -50,13 +50,18 @@ def player_actions(self) -> Generator[LeaderboardTableMenuItems, None, None]: yield LeaderboardTableMenuItems.SHOW_USER_INFO yield LeaderboardTableMenuItems.VIEW_REPLAYS - def friendActions(self, name, uid, is_me): + def friendActions( + self, + name: str, + uid: int, + is_me: bool, + ) -> Generator[LeaderboardTableMenuItems, None, None]: if is_me: return - if self.client.me.relations.model.is_friend(uid, name): + if self.client.user_relations.model.is_friend(uid, name): yield LeaderboardTableMenuItems.REMOVE_FRIEND - elif self.client.me.relations.model.is_foe(uid, name): + elif self.client.user_relations.model.is_foe(uid, name): yield LeaderboardTableMenuItems.REMOVE_FOE else: yield LeaderboardTableMenuItems.ADD_FRIEND @@ -106,8 +111,8 @@ def viewAliases(self, name): def viewReplays(self, name): self.client.view_replays(name, self.leaderboardName) - def handleFriends(self, uid, kind): - ctl = self.client.me.relations.controller.faf + def handleFriends(self, uid: int, kind: LeaderboardTableMenuItems) -> None: + ctl = self.client.user_relations.controller.faf Items = LeaderboardTableMenuItems if kind == Items.ADD_FRIEND: From a7197c7e9a8cdc0bb5f0120bd0597a35263d64b9 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 23 Jul 2024 23:37:05 +0300 Subject: [PATCH 47/73] Merge LeaderboardTableMenu and ChatterMenu into superclass --- src/chat/chatter_menu.py | 290 +++--------------- src/chat/chatter_model.py | 5 +- src/client/_clientwindow.py | 5 + src/contextmenu/playercontextmenu.py | 308 ++++++++++++++++++++ src/stats/itemviews/leaderboardtablemenu.py | 129 -------- src/stats/itemviews/leaderboardtableview.py | 5 +- 6 files changed, 355 insertions(+), 387 deletions(-) create mode 100644 src/contextmenu/playercontextmenu.py delete mode 100644 src/stats/itemviews/leaderboardtablemenu.py diff --git a/src/chat/chatter_menu.py b/src/chat/chatter_menu.py index 47429e35b..5645ea671 100644 --- a/src/chat/chatter_menu.py +++ b/src/chat/chatter_menu.py @@ -1,266 +1,52 @@ -import logging -from collections.abc import Generator -from enum import Enum - -from PyQt6.QtGui import QAction -from PyQt6.QtWidgets import QApplication -from PyQt6.QtWidgets import QMenu +from __future__ import annotations -from model.chat.channelchatter import ChannelChatter -from model.chat.chatter import Chatter -from model.game import GameState -from model.player import Player -from playercard.playerinfodialog import PlayerInfoDialog +import logging +from typing import TYPE_CHECKING +from typing import Callable -logger = logging.getLogger(__name__) +from PyQt6.QtWidgets import QWidget +from chat._avatarWidget import AvatarWidget +from client.aliasviewer import AliasWindow +from client.user import User +from contextmenu.playercontextmenu import PlayerContextMenu +from fa.game_runner import GameRunner +from power import PowerTools -class ChatterMenuItems(Enum): - SELECT_AVATAR = "Select avatar" - SEND_ORCS = "Send the Orcs" - CLOSE_GAME = "Close Game" - KICK_PLAYER = "Close FAF Client" - VIEW_ALIASES = "View aliases" - VIEW_IN_LEADERBOARDS = "View in Leaderboards" - JOIN_GAME = "Join hosted Game" - VIEW_LIVEREPLAY = "View live replay" - VIEW_REPLAYS = "View Replays in Vault" - ADD_FRIEND = "Add friend" - ADD_FOE = "Add foe" - REMOVE_FRIEND = "Remove friend" - REMOVE_FOE = "Remove foe" - ADD_CHATTERBOX = "Ignore" - REMOVE_CHATTERBOX = "Unignore" - COPY_USERNAME = "Copy username" - INVITE_TO_PARTY = "Invite to party" - KICK_FROM_PARTY = "Kick from party" - SHOW_USER_INFO = "Show user info" +if TYPE_CHECKING: + from client._clientwindow import ClientWindow +logger = logging.getLogger(__name__) -class ChatterMenu: - def __init__( - self, me, power_tools, parent_widget, avatar_widget_builder, - alias_viewer, client_window, game_runner, - ): - self._me = me - self._power_tools = power_tools - self._parent_widget = parent_widget - self._avatar_widget_builder = avatar_widget_builder - self._alias_viewer = alias_viewer - self._client_window = client_window - self._game_runner = game_runner +class ChatterMenu(PlayerContextMenu): + # FIXME: ChatterMenu is built in ChattersView's built method + # by passing both necessary and unnecessary kwargs to the method below + # (notice **kwargs). But the ChattersView itself is built in this way + # and the its 'parent's' class and so on. As a result it is hard to + # extract any of them from the chain of 'builds' without modifying + # lots of chat-related files. + # It's better to avoid passing the whole bunch of dependencies arguments + # into a single build and wonder which of them are needed for this + # particular class and which are for its dependencies. + # Many of those build methods (if not all) were created just for the + # purpose of passing additional unexpected arguments, so that __init__ + # doesn't complain about them. (see 9b14d4e7) + # Maybe it's fine in some cases, but chat's build chain is very convoluted + # and hard to grasp @classmethod def build( - cls, me, power_tools, parent_widget, avatar_widget_builder, - alias_viewer, client_window, game_runner, **kwargs, + cls, + me: User, + power_tools: PowerTools, + parent_widget: QWidget, + avatar_widget_builder: Callable[..., AvatarWidget], + alias_viewer: AliasWindow, + client_window: ClientWindow, + game_runner: GameRunner, + **kwargs, ): return cls( me, power_tools, parent_widget, avatar_widget_builder, alias_viewer, client_window, game_runner, ) - - def actions(self, cc): - chatter = cc.chatter - player = chatter.player - game = None if player is None else player.currentGame - - if player is None or self._me.player is None: - is_me = False - else: - is_me = player.id == self._me.player.id - - yield list(self.user_actions(player)) - yield list(self.me_actions(is_me)) - yield list(self.power_actions(self._power_tools.power)) - yield list(self.chatter_actions()) - yield list(self.player_actions(player, game, is_me)) - yield list(self.friend_actions(player, chatter, cc, is_me)) - yield list(self.ignore_actions(player, chatter, cc, is_me)) - yield list(self.party_actions(player, is_me)) - - def user_actions(self, player: Player | None) -> Generator[ChatterMenuItems, None, None]: - if player is not None: - yield ChatterMenuItems.SHOW_USER_INFO - - def chatter_actions(self): - yield ChatterMenuItems.COPY_USERNAME - yield ChatterMenuItems.VIEW_ALIASES - - def me_actions(self, is_me): - if is_me: - yield ChatterMenuItems.SELECT_AVATAR - - def power_actions(self, power): - if power == 2: - yield ChatterMenuItems.SEND_ORCS - yield ChatterMenuItems.CLOSE_GAME - yield ChatterMenuItems.KICK_PLAYER - - def player_actions(self, player, game, is_me): - if game is not None and not is_me: - if game.state == GameState.OPEN: - yield ChatterMenuItems.JOIN_GAME - elif game.state == GameState.PLAYING: - yield ChatterMenuItems.VIEW_LIVEREPLAY - - if player is not None: - if player.ladder_estimate != 0: - yield ChatterMenuItems.VIEW_IN_LEADERBOARDS - yield ChatterMenuItems.VIEW_REPLAYS - - def friend_actions( - self, - player: Player | None, - chatter: Chatter, - cc: ChannelChatter, - is_me: bool, - ) -> Generator[ChatterMenuItems, None, None]: - if is_me: - return - id_ = -1 if player is None else player.id - name = chatter.name - if self._client_window.user_relations.model.is_friend(id_, name): - yield ChatterMenuItems.REMOVE_FRIEND - elif self._client_window.user_relations.model.is_foe(id_, name): - yield ChatterMenuItems.REMOVE_FOE - else: - yield ChatterMenuItems.ADD_FRIEND - yield ChatterMenuItems.ADD_FOE - - def ignore_actions( - self, - player: Player, - chatter: Chatter, - cc: ChannelChatter, - is_me: bool, - ) -> Generator[ChatterMenuItems, None, None]: - if is_me: - return - id_ = -1 if player is None else player.id - name = chatter.name - if self._client_window.user_relations.model.is_chatterbox(id_, name): - yield ChatterMenuItems.REMOVE_CHATTERBOX - else: - if not cc.is_mod() and not chatter.is_base_channel_mod(): - yield ChatterMenuItems.ADD_CHATTERBOX - - def party_actions(self, player, is_me): - if is_me: - return - if player is None: - return - else: - if player.id in self._client_window.games.party.memberIds: - if ( - self._me.player.id - == self._client_window.games.party.owner_id - ): - yield ChatterMenuItems.KICK_FROM_PARTY - elif player.currentGame is not None: - return - else: - yield ChatterMenuItems.INVITE_TO_PARTY - - def get_context_menu(self, data, point): - return self.menu(data.cc) - - def menu(self, cc): - menu = QMenu(self._parent_widget) - - def add_entry(item): - action = QAction(item.value, menu) - action.triggered.connect(self.handler(cc, item)) - menu.addAction(action) - - first = True - for category in self.actions(cc): - if not category: - continue - if not first: - menu.addSeparator() - for item in category: - add_entry(item) - first = False - return menu - - def handler(self, cc, kind): - chatter = cc.chatter - player = chatter.player - game = None if player is None else player.currentGame - return lambda: self._handle_action(chatter, player, game, kind) - - def _handle_action(self, chatter, player, game, kind): - Items = ChatterMenuItems - if kind == Items.COPY_USERNAME: - self._copy_username(chatter) - elif kind == Items.SEND_ORCS: - self._power_tools.actions.send_the_orcs(chatter.name) - elif kind == Items.CLOSE_GAME: - self._power_tools.view.close_game_dialog.show(chatter.name) - elif kind == Items.KICK_PLAYER: - self._power_tools.view.kick_dialog(chatter.name) - elif kind == Items.SELECT_AVATAR: - self._avatar_widget_builder().show() - elif kind in [ - Items.ADD_FRIEND, Items.ADD_FOE, Items.REMOVE_FRIEND, - Items.REMOVE_FOE, - ]: - self._handle_friends(chatter, player, kind) - elif kind in [Items.ADD_CHATTERBOX, Items.REMOVE_CHATTERBOX]: - self._handle_chatterboxes(chatter, player, kind) - elif kind == Items.VIEW_ALIASES: - self._view_aliases(chatter) - elif kind == Items.SHOW_USER_INFO: - self._show_user_info(player) - elif kind == Items.VIEW_REPLAYS: - self._client_window.view_replays(player.login) - elif kind == Items.VIEW_IN_LEADERBOARDS: - self._client_window.view_in_leaderboards(player) - elif kind in [Items.JOIN_GAME, Items.VIEW_LIVEREPLAY]: - self._game_runner.run_game_with_url(game, player.id) - elif kind == Items.INVITE_TO_PARTY: - self._client_window.invite_to_party(player.id) - elif kind == Items.KICK_FROM_PARTY: - self._client_window.games.kickPlayerFromParty(player.id) - - def _copy_username(self, chatter): - QApplication.clipboard().setText(chatter.name) - - def _handle_friends(self, chatter: Chatter, player: Player, kind: ChatterMenuItems) -> None: - ctl = self._client_window.user_relations.controller - ctl = ctl.faf if player is not None else ctl.irc - uid = player.id if player is not None else chatter.name - - Items = ChatterMenuItems - if kind == Items.ADD_FRIEND: - ctl.friends.add(uid) - elif kind == Items.REMOVE_FRIEND: - ctl.friends.remove(uid) - if kind == Items.ADD_FOE: - ctl.foes.add(uid) - elif kind == Items.REMOVE_FOE: - ctl.foes.remove(uid) - - def _handle_chatterboxes( - self, - chatter: Chatter, - player: Player | None, - kind: ChatterMenuItems, - ) -> None: - ctl = self._client_window.user_relations.controller - ctl = ctl.faf if player is not None else ctl.irc - uid = player.id if player is not None else chatter.name - - Items = ChatterMenuItems - if kind == Items.ADD_CHATTERBOX: - ctl.chatterboxes.add(uid) - elif kind == Items.REMOVE_CHATTERBOX: - ctl.chatterboxes.remove(uid) - - def _view_aliases(self, chatter): - self._alias_viewer.view_aliases(chatter.name) - - def _show_user_info(self, player: Player) -> None: - dialog = PlayerInfoDialog(self._client_window, str(player.id)) - dialog.run() diff --git a/src/chat/chatter_model.py b/src/chat/chatter_model.py index 031505443..2f436af75 100644 --- a/src/chat/chatter_model.py +++ b/src/chat/chatter_model.py @@ -591,12 +591,13 @@ def _handle_tooltip(self, widget: QtWidgets.QWidget, event: QtGui.QMouseEvent) - QtWidgets.QToolTip.showText(event.globalPos(), tooltip_text, widget) return True - def _handle_context_menu(self, widget, event): + def _handle_context_menu(self, widget: QtWidgets.QWidget, event: QtGui.QMouseEvent) -> bool: data, elem = self._get_data_and_elem(widget, event) if data is None: return False - menu = self._menu_handler.get_context_menu(data, elem) + player_id = data.player.id if data.player is not None else -1 + menu = self._menu_handler.get_context_menu(data.chatter.name, player_id) menu.popup(QtGui.QCursor.pos()) return True diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index b01ce9d2f..14dd63e0a 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -42,6 +42,7 @@ from client.user import UserRelations from client.user import UserRelationTrackers from connectivity.ConnectivityDialog import ConnectivityDialog +from contextmenu.playercontextmenu import PlayerContextMenu from coop import CoopWidget from downloadManager import AvatarDownloader from downloadManager import MapSmallPreviewDownloader @@ -704,6 +705,10 @@ def setup(self): avatar_dler=self.avatar_downloader, theme=util.THEME, ) + self.player_ctx_menu = PlayerContextMenu( + self.me, self.power_tools, self, self._avatar_widget_builder, + self._alias_viewer, self, self._game_runner, + ) chat_connection = IrcConnection.build(settings=config.Settings) line_metadata_builder = ChatLineMetadataBuilder.build( diff --git a/src/contextmenu/playercontextmenu.py b/src/contextmenu/playercontextmenu.py new file mode 100644 index 000000000..4d7a1ee75 --- /dev/null +++ b/src/contextmenu/playercontextmenu.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import logging +from collections.abc import Generator +from enum import Enum +from typing import TYPE_CHECKING +from typing import Callable + +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import QApplication +from PyQt6.QtWidgets import QMenu +from PyQt6.QtWidgets import QWidget + +from chat._avatarWidget import AvatarWidget +from client.aliasviewer import AliasWindow +from client.user import User +from fa.game_runner import GameRunner +from model.game import GameState +from model.player import Player +from playercard.playerinfodialog import PlayerInfoDialog +from power import PowerTools + +if TYPE_CHECKING: + from client._clientwindow import ClientWindow + +logger = logging.getLogger(__name__) + + +class PlayerMenuItem(Enum): + SELECT_AVATAR = "Select avatar" + SEND_ORCS = "Send the Orcs" + CLOSE_GAME = "Close Game" + KICK_PLAYER = "Close FAF Client" + VIEW_ALIASES = "View aliases" + VIEW_IN_LEADERBOARDS = "View in Leaderboards" + JOIN_GAME = "Join hosted Game" + VIEW_LIVEREPLAY = "View live replay" + VIEW_REPLAYS = "View Replays in Vault" + ADD_FRIEND = "Add friend" + ADD_FOE = "Add foe" + REMOVE_FRIEND = "Remove friend" + REMOVE_FOE = "Remove foe" + ADD_CHATTERBOX = "Ignore" + REMOVE_CHATTERBOX = "Unignore" + COPY_USERNAME = "Copy username" + INVITE_TO_PARTY = "Invite to party" + KICK_FROM_PARTY = "Kick from party" + SHOW_USER_INFO = "Show user info" + + +class PlayerContextMenu: + def __init__( + self, + me: User, + power_tools: PowerTools, + parent_widget: QWidget, + avatar_widget_builder: Callable[..., AvatarWidget], + alias_viewer: AliasWindow, + client_window: ClientWindow, + game_runner: GameRunner, + ) -> None: + self._me = me + self._power_tools = power_tools + self._parent_widget = parent_widget + self._avatar_widget_builder = avatar_widget_builder + self._alias_viewer = alias_viewer + self._client_window = client_window + self._game_runner = game_runner + + def actions(self, login: str, player_id: int) -> Generator[list[PlayerMenuItem], None, None]: + if player_id == -1 or self._me.player is None: + is_me = False + else: + is_me = player_id == self._me.player.id + + yield list(self.user_actions(player_id)) + yield list(self.me_actions(is_me=is_me)) + yield list(self.power_actions(self._power_tools.power)) + yield list(self.chatter_actions(player_id)) + yield list(self.player_actions(player_id, is_me=is_me)) + yield list(self.friend_actions(player_id, login, is_me=is_me)) + yield list(self.ignore_actions(player_id, login, is_me=is_me)) + yield list(self.party_actions(player_id, is_me=is_me)) + + def user_actions(self, player_id: int) -> Generator[PlayerMenuItem, None, None]: + if player_id != -1: + yield PlayerMenuItem.SHOW_USER_INFO + + def chatter_actions(self, player_id: int) -> Generator[PlayerMenuItem, None, None]: + yield PlayerMenuItem.COPY_USERNAME + if player_id != -1: + yield PlayerMenuItem.VIEW_ALIASES + + def me_actions(self, *, is_me: bool) -> Generator[PlayerMenuItem, None, None]: + if is_me: + yield PlayerMenuItem.SELECT_AVATAR + + def power_actions(self, power: int) -> Generator[PlayerMenuItem, None, None]: + if power == 2: + yield PlayerMenuItem.SEND_ORCS + yield PlayerMenuItem.CLOSE_GAME + yield PlayerMenuItem.KICK_PLAYER + + def _get_online_player_by_id(self, player_id: int) -> Player | None: + try: + return self._client_window.players[player_id] + except KeyError: + return None + + def player_actions( + self, + player_id: int, + *, + is_me: bool, + ) -> Generator[PlayerMenuItem, None, None]: + if player_id != -1: + yield PlayerMenuItem.VIEW_REPLAYS + + online_player = self._get_online_player_by_id(player_id) + + if online_player is None: + return + + game = online_player.currentGame + + if game is not None and not is_me: + if game.state == GameState.OPEN: + yield PlayerMenuItem.JOIN_GAME + elif game.state == GameState.PLAYING: + yield PlayerMenuItem.VIEW_LIVEREPLAY + + if online_player.ladder_estimate != 0: + yield PlayerMenuItem.VIEW_IN_LEADERBOARDS + + def friend_actions( + self, + player_id: int, + login: str, + *, + is_me: bool, + ) -> Generator[PlayerMenuItem, None, None]: + if is_me: + return + if self._client_window.user_relations.model.is_friend(player_id, login): + yield PlayerMenuItem.REMOVE_FRIEND + elif self._client_window.user_relations.model.is_foe(player_id, login): + yield PlayerMenuItem.REMOVE_FOE + else: + yield PlayerMenuItem.ADD_FRIEND + yield PlayerMenuItem.ADD_FOE + + def ignore_actions( + self, + player_id: int, + login: str, + *, + is_me: bool, + ) -> Generator[PlayerMenuItem, None, None]: + if is_me: + return + if self._client_window.user_relations.model.is_chatterbox(player_id, login): + yield PlayerMenuItem.REMOVE_CHATTERBOX + else: + yield PlayerMenuItem.ADD_CHATTERBOX + + def party_actions( + self, + player_id: int, + *, + is_me: bool, + ) -> Generator[PlayerMenuItem, None, None]: + if is_me: + return + + online_player = self._get_online_player_by_id(player_id) + if online_player is None: + return + + if online_player.id in self._client_window.games.party.memberIds: + if ( + self._me.player.id + == self._client_window.games.party.owner_id + ): + yield PlayerMenuItem.KICK_FROM_PARTY + elif online_player.currentGame is not None: + return + else: + yield PlayerMenuItem.INVITE_TO_PARTY + + def get_context_menu(self, login: str, player_id: int) -> QMenu: + return self.menu(login, player_id) + + def menu(self, login: str, player_id: int) -> QMenu: + menu = QMenu(self._parent_widget) + + def add_entry(item): + action = QAction(item.value, menu) + action.triggered.connect(self.handler(login, player_id, item)) + menu.addAction(action) + + for category in self.actions(login, player_id): + if not category: + continue + for item in category: + add_entry(item) + menu.addSeparator() + return menu + + def handler(self, login: str, player_id: int, kind: PlayerMenuItem) -> Callable[[], None]: + return lambda: self._handle_action(login, player_id, kind) + + def _handle_action(self, login: str, player_id: int, kind: PlayerMenuItem) -> None: + Items = PlayerMenuItem + if kind == Items.COPY_USERNAME: + self._copy_username(login) + elif kind == Items.SEND_ORCS: + self._power_tools.actions.send_the_orcs(login) + elif kind == Items.CLOSE_GAME: + self._power_tools.view.close_game_dialog.show(login) + elif kind == Items.KICK_PLAYER: + self._power_tools.view.kick_dialog(login) + elif kind == Items.SELECT_AVATAR: + self._avatar_widget_builder().show() + elif kind in [ + Items.ADD_FRIEND, Items.ADD_FOE, Items.REMOVE_FRIEND, + Items.REMOVE_FOE, + ]: + self._handle_friends(login, player_id, kind) + elif kind in [Items.ADD_CHATTERBOX, Items.REMOVE_CHATTERBOX]: + self._handle_chatterboxes(login, player_id, kind) + elif kind == Items.VIEW_ALIASES: + self._view_aliases(login) + elif kind == Items.SHOW_USER_INFO: + self._show_user_info(player_id) + elif kind == Items.VIEW_REPLAYS: + self._client_window.view_replays(login) + elif kind == Items.VIEW_IN_LEADERBOARDS: + self._client_window.view_in_leaderboards(player_id) + elif kind in [Items.JOIN_GAME, Items.VIEW_LIVEREPLAY]: + self._handle_game(player_id) + elif kind == Items.INVITE_TO_PARTY: + self._client_window.invite_to_party(player_id) + elif kind == Items.KICK_FROM_PARTY: + self._client_window.games.kickPlayerFromParty(player_id) + + def _handle_game(self, player_id: int) -> None: + online_player = self._get_online_player_by_id(player_id) + if online_player is None: + return + + game = online_player.currentGame + if game is None: + return + + self._game_runner.run_game_with_url(game, player_id) + + def _copy_username(self, login: str) -> None: + clip = QApplication.clipboard() + if clip is not None: + clip.setText(login) + + def _handle_friends(self, login: str, player_id: int, kind: PlayerMenuItem) -> None: + ctl = self._client_window.user_relations.controller + + if player_id == -1: + ctl = ctl.irc + uid = login + else: + ctl = ctl.faf + uid = player_id + + Items = PlayerMenuItem + if kind == Items.ADD_FRIEND: + ctl.friends.add(uid) + elif kind == Items.REMOVE_FRIEND: + ctl.friends.remove(uid) + if kind == Items.ADD_FOE: + ctl.foes.add(uid) + elif kind == Items.REMOVE_FOE: + ctl.foes.remove(uid) + + def _handle_chatterboxes( + self, + login: str, + player_id: int, + kind: PlayerMenuItem, + ) -> None: + ctl = self._client_window.user_relations.controller + + if player_id == -1: + ctl = ctl.irc + uid = login + else: + ctl = ctl.faf + uid = player_id + + Items = PlayerMenuItem + if kind == Items.ADD_CHATTERBOX: + ctl.chatterboxes.add(uid) + elif kind == Items.REMOVE_CHATTERBOX: + ctl.chatterboxes.remove(uid) + + def _view_aliases(self, login: str) -> None: + self._alias_viewer.view_aliases(login) + + def _show_user_info(self, player_id: int) -> None: + dialog = PlayerInfoDialog(self._client_window, str(player_id)) + dialog.run() diff --git a/src/stats/itemviews/leaderboardtablemenu.py b/src/stats/itemviews/leaderboardtablemenu.py deleted file mode 100644 index 94755d997..000000000 --- a/src/stats/itemviews/leaderboardtablemenu.py +++ /dev/null @@ -1,129 +0,0 @@ -from collections.abc import Generator -from enum import Enum - -from PyQt6 import QtWidgets -from PyQt6.QtGui import QAction - -from playercard.playerinfodialog import PlayerInfoDialog - - -class LeaderboardTableMenuItems(Enum): - VIEW_ALIASES = "View aliases" - VIEW_REPLAYS = "View Replays in Vault" - ADD_FRIEND = "Add friend" - ADD_FOE = "Add foe" - REMOVE_FRIEND = "Remove friend" - REMOVE_FOE = "Remove foe" - COPY_USERNAME = "Copy username" - SHOW_USER_INFO = "Show user info" - - -class LeaderboardTableMenu: - def __init__(self, parent, client, leaderboardName): - self.parent = parent - self.client = client - self.leaderboardName = leaderboardName - - @classmethod - def build(cls, parent, client, leaderboardName): - return cls(parent, client, leaderboardName) - - def actions( - self, - name: str, - uid: str, - ) -> Generator[list[LeaderboardTableMenuItems], None, None]: - yield list(self.player_actions()) - yield list(self.usernameActions()) - - if self.client.me.player is None: - return - - is_me = self.client.me.id == uid - yield list(self.friendActions(name, uid, is_me)) - - def usernameActions(self): - yield LeaderboardTableMenuItems.COPY_USERNAME - yield LeaderboardTableMenuItems.VIEW_ALIASES - - def player_actions(self) -> Generator[LeaderboardTableMenuItems, None, None]: - yield LeaderboardTableMenuItems.SHOW_USER_INFO - yield LeaderboardTableMenuItems.VIEW_REPLAYS - - def friendActions( - self, - name: str, - uid: int, - is_me: bool, - ) -> Generator[LeaderboardTableMenuItems, None, None]: - if is_me: - return - - if self.client.user_relations.model.is_friend(uid, name): - yield LeaderboardTableMenuItems.REMOVE_FRIEND - elif self.client.user_relations.model.is_foe(uid, name): - yield LeaderboardTableMenuItems.REMOVE_FOE - else: - yield LeaderboardTableMenuItems.ADD_FRIEND - yield LeaderboardTableMenuItems.ADD_FOE - - def getMenu(self, name: str, uid: int) -> QtWidgets.QMenu: - menu = QtWidgets.QMenu(self.parent) - - def addEntry(item): - action = QAction(item.value, menu) - action.triggered.connect(self.handler(name, uid, item)) - menu.addAction(action) - - first = True - for category in self.actions(name, uid): - if not category: - continue - if not first: - menu.addSeparator() - for item in category: - addEntry(item) - first = False - return menu - - def handler(self, name, uid, kind): - Items = LeaderboardTableMenuItems - if kind == Items.COPY_USERNAME: - return lambda: self.copyUsername(name) - elif kind == Items.VIEW_ALIASES: - return lambda: self.viewAliases(name) - elif kind == Items.VIEW_REPLAYS: - return lambda: self.viewReplays(name) - elif kind == Items.SHOW_USER_INFO: - return lambda: self.show_user_info(uid) - elif kind in [ - Items.ADD_FRIEND, Items.ADD_FOE, Items.REMOVE_FRIEND, - Items.REMOVE_FOE, - ]: - return lambda: self.handleFriends(uid, kind) - - def copyUsername(self, name): - QtWidgets.QApplication.clipboard().setText(name) - - def viewAliases(self, name): - self.client._alias_viewer.view_aliases(name) - - def viewReplays(self, name): - self.client.view_replays(name, self.leaderboardName) - - def handleFriends(self, uid: int, kind: LeaderboardTableMenuItems) -> None: - ctl = self.client.user_relations.controller.faf - - Items = LeaderboardTableMenuItems - if kind == Items.ADD_FRIEND: - ctl.friends.add(uid) - elif kind == Items.REMOVE_FRIEND: - ctl.friends.remove(uid) - if kind == Items.ADD_FOE: - ctl.foes.add(uid) - elif kind == Items.REMOVE_FOE: - ctl.foes.remove(uid) - - def show_user_info(self, uid: str) -> None: - dialog = PlayerInfoDialog(self.client, uid) - dialog.run() diff --git a/src/stats/itemviews/leaderboardtableview.py b/src/stats/itemviews/leaderboardtableview.py index 69d78bd63..61782c8f8 100644 --- a/src/stats/itemviews/leaderboardtableview.py +++ b/src/stats/itemviews/leaderboardtableview.py @@ -3,7 +3,6 @@ from PyQt6.QtGui import QMouseEvent from qt.itemviews.tableview import TableView -from stats.itemviews.leaderboardtablemenu import LeaderboardTableMenu class LeaderboardTableView(TableView): @@ -24,7 +23,5 @@ def mousePressEvent(self, event: QMouseEvent) -> None: def context_menu(self, name: str, uid: int) -> None: client = self.parent().parent().client - leaderboardName = self.parent().parent().leaderboardName - menuHandler = LeaderboardTableMenu.build(self, client, leaderboardName) - menu = menuHandler.getMenu(name, uid) + menu = client.player_ctx_menu.get_context_menu(name, uid) menu.popup(QCursor.pos()) From 4dd20d38ec28e113949019e46e555d1b8c206298 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 24 Jul 2024 00:12:40 +0300 Subject: [PATCH 48/73] PlayerContextMenu: Put social handlers into one method --- src/contextmenu/playercontextmenu.py | 49 ++++++++-------------------- 1 file changed, 14 insertions(+), 35 deletions(-) diff --git a/src/contextmenu/playercontextmenu.py b/src/contextmenu/playercontextmenu.py index 4d7a1ee75..4096fac4a 100644 --- a/src/contextmenu/playercontextmenu.py +++ b/src/contextmenu/playercontextmenu.py @@ -223,11 +223,9 @@ def _handle_action(self, login: str, player_id: int, kind: PlayerMenuItem) -> No self._avatar_widget_builder().show() elif kind in [ Items.ADD_FRIEND, Items.ADD_FOE, Items.REMOVE_FRIEND, - Items.REMOVE_FOE, + Items.REMOVE_FOE, Items.ADD_CHATTERBOX, Items.REMOVE_CHATTERBOX, ]: - self._handle_friends(login, player_id, kind) - elif kind in [Items.ADD_CHATTERBOX, Items.REMOVE_CHATTERBOX]: - self._handle_chatterboxes(login, player_id, kind) + self._handle_social(login, player_id, kind) elif kind == Items.VIEW_ALIASES: self._view_aliases(login) elif kind == Items.SHOW_USER_INFO: @@ -259,7 +257,7 @@ def _copy_username(self, login: str) -> None: if clip is not None: clip.setText(login) - def _handle_friends(self, login: str, player_id: int, kind: PlayerMenuItem) -> None: + def _handle_social(self, login: str, player_id: int, kind: PlayerMenuItem) -> None: ctl = self._client_window.user_relations.controller if player_id == -1: @@ -269,36 +267,17 @@ def _handle_friends(self, login: str, player_id: int, kind: PlayerMenuItem) -> N ctl = ctl.faf uid = player_id - Items = PlayerMenuItem - if kind == Items.ADD_FRIEND: - ctl.friends.add(uid) - elif kind == Items.REMOVE_FRIEND: - ctl.friends.remove(uid) - if kind == Items.ADD_FOE: - ctl.foes.add(uid) - elif kind == Items.REMOVE_FOE: - ctl.foes.remove(uid) - - def _handle_chatterboxes( - self, - login: str, - player_id: int, - kind: PlayerMenuItem, - ) -> None: - ctl = self._client_window.user_relations.controller - - if player_id == -1: - ctl = ctl.irc - uid = login - else: - ctl = ctl.faf - uid = player_id - - Items = PlayerMenuItem - if kind == Items.ADD_CHATTERBOX: - ctl.chatterboxes.add(uid) - elif kind == Items.REMOVE_CHATTERBOX: - ctl.chatterboxes.remove(uid) + social_handlers = { + PlayerMenuItem.ADD_FRIEND: ctl.friends.add, + PlayerMenuItem.REMOVE_FRIEND: ctl.friends.remove, + PlayerMenuItem.ADD_FOE: ctl.foes.add, + PlayerMenuItem.REMOVE_FOE: ctl.foes.remove, + PlayerMenuItem.ADD_CHATTERBOX: ctl.chatterboxes.add, + PlayerMenuItem.REMOVE_CHATTERBOX: ctl.chatterboxes.remove, + } + handler = social_handlers.get(kind) + if handler is not None: + handler(uid) def _view_aliases(self, login: str) -> None: self._alias_viewer.view_aliases(login) From d4d7442c0e5281b5cb5047c26254377fc0f3a392 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 24 Jul 2024 00:17:18 +0300 Subject: [PATCH 49/73] Remove 'Veiw in Leaderboards' action from context 1. there are multiple leaderboards and their count can change 2. its behaviour is very inconvenient --- src/client/_clientwindow.py | 10 ---------- src/contextmenu/playercontextmenu.py | 6 ------ 2 files changed, 16 deletions(-) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 14dd63e0a..a03a44a6d 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -1671,16 +1671,6 @@ def view_replays(self, name, leaderboardName=None): self.replays.set_player(name, leaderboardName) self.mainTabs.setCurrentIndex(self.mainTabs.indexOf(self.replaysTab)) - def view_in_leaderboards(self, user): - self.ladder.setCurrentIndex( - self.ladder.indexOf(self.ladder.leaderboardsTab), - ) - self.ladder.leaderboards.widget(0).searchPlayerInLeaderboard(user) - self.ladder.leaderboards.widget(1).searchPlayerInLeaderboard(user) - self.ladder.leaderboards.widget(2).searchPlayerInLeaderboard(user) - self.ladder.leaderboards.setCurrentIndex(1) - self.mainTabs.setCurrentIndex(self.mainTabs.indexOf(self.ladderTab)) - def manage_power(self): """ update the interface accordingly to the power of the user """ if self.power_tools.power >= 1: diff --git a/src/contextmenu/playercontextmenu.py b/src/contextmenu/playercontextmenu.py index 4096fac4a..a5ee3e39c 100644 --- a/src/contextmenu/playercontextmenu.py +++ b/src/contextmenu/playercontextmenu.py @@ -32,7 +32,6 @@ class PlayerMenuItem(Enum): CLOSE_GAME = "Close Game" KICK_PLAYER = "Close FAF Client" VIEW_ALIASES = "View aliases" - VIEW_IN_LEADERBOARDS = "View in Leaderboards" JOIN_GAME = "Join hosted Game" VIEW_LIVEREPLAY = "View live replay" VIEW_REPLAYS = "View Replays in Vault" @@ -129,9 +128,6 @@ def player_actions( elif game.state == GameState.PLAYING: yield PlayerMenuItem.VIEW_LIVEREPLAY - if online_player.ladder_estimate != 0: - yield PlayerMenuItem.VIEW_IN_LEADERBOARDS - def friend_actions( self, player_id: int, @@ -232,8 +228,6 @@ def _handle_action(self, login: str, player_id: int, kind: PlayerMenuItem) -> No self._show_user_info(player_id) elif kind == Items.VIEW_REPLAYS: self._client_window.view_replays(login) - elif kind == Items.VIEW_IN_LEADERBOARDS: - self._client_window.view_in_leaderboards(player_id) elif kind in [Items.JOIN_GAME, Items.VIEW_LIVEREPLAY]: self._handle_game(player_id) elif kind == Items.INVITE_TO_PARTY: From c07fd4a7417bb659f2ff3e31db500fbb47b23604 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 25 Jul 2024 20:35:35 +0300 Subject: [PATCH 50/73] Add context menu to players in replay scoreboard --- src/api/models/ConfiguredModel.py | 15 ++++++++--- src/replays/models.py | 43 ++++++++++++++++--------------- src/replays/replayitem.py | 2 ++ src/replays/scoreboard.py | 9 +++++-- src/replays/scoreboardlistview.py | 30 +++++++++++++++++++++ 5 files changed, 73 insertions(+), 26 deletions(-) create mode 100644 src/replays/scoreboardlistview.py diff --git a/src/api/models/ConfiguredModel.py b/src/api/models/ConfiguredModel.py index 1154f55ce..166f32d33 100644 --- a/src/api/models/ConfiguredModel.py +++ b/src/api/models/ConfiguredModel.py @@ -5,6 +5,11 @@ from pydantic import field_validator +def api_response_empty(resp: dict) -> bool: + wasnt_included = ("id" in resp and "type" in resp and len(resp) == 2) + return not resp or wasnt_included + + class ConfiguredModel(BaseModel): model_config = ConfigDict(populate_by_name=True) @@ -12,8 +17,12 @@ class ConfiguredModel(BaseModel): @classmethod def ensure_included_and_not_empty_or_none(cls, v: Any) -> Any: if isinstance(v, dict): - if not v or ("id" in v and "type" in v and len(v) == 2): + if api_response_empty(v): + return None + elif isinstance(v, list): + if ( + not v + or len(list(filter(api_response_empty, v))) == len(v) + ): return None - elif isinstance(v, list) and not v: - return None return v diff --git a/src/replays/models.py b/src/replays/models.py index 6ab949989..0ccb3fc07 100644 --- a/src/replays/models.py +++ b/src/replays/models.py @@ -9,6 +9,7 @@ from PyQt6.QtGui import QIcon import util +from api.models.Player import Player from model.rating import Rating from pydantic import BaseModel from pydantic import Field @@ -31,13 +32,13 @@ class MetadataModel(BaseModel): class ScoreboardModelItem(QObject): updated = pyqtSignal() - def __init__(self, player: dict, mod: str | None) -> None: + def __init__(self, replay_data: dict, mod: str | None) -> None: QObject.__init__(self) - self.player = player + self.replay_data = replay_data self.mod = mod or "" - - if len(self.player["ratingChanges"]) > 0: - self.rating_stats = self.player["ratingChanges"][0] + self.player = Player(**replay_data["player"]) + if len(self.replay_data["ratingChanges"]) > 0: + self.rating_stats = self.replay_data["ratingChanges"][0] else: self.rating_stats = None @@ -48,10 +49,10 @@ def build(data: dict) -> ScoreboardModelItem: return build def score(self) -> int: - return self.player["score"] + return self.replay_data["score"] def login(self) -> str: - return self.player["player"]["login"] + return self.player.login def rating_before(self) -> int: # gamePlayerStats' fields 'before*' and 'after*' can be removed @@ -63,10 +64,10 @@ def rating_before(self) -> int: self.rating_stats["deviationBefore"], ) return round(rating.displayed()) - elif self.player.get("beforeMean") and self.player.get("beforeDeviation"): + elif self.replay_data.get("beforeMean") and self.replay_data.get("beforeDeviation"): rating = Rating( - self.player["beforeMean"], - self.player["beforeDeviation"], + self.replay_data["beforeMean"], + self.replay_data["beforeDeviation"], ) return round(rating.displayed()) return 0 @@ -78,16 +79,16 @@ def rating_after(self) -> int: self.rating_stats["deviationAfter"], ) return round(rating.displayed()) - elif self.player.get("afterMean") and self.player.get("afterDeviation"): + elif self.replay_data.get("afterMean") and self.replay_data.get("afterDeviation"): rating = Rating( - self.player["afterMean"], - self.player["afterDeviation"], + self.replay_data["afterMean"], + self.replay_data["afterDeviation"], ) return round(rating.displayed()) return 0 def rating(self) -> int | None: - if self.rating_stats is None and "beforeMean" not in self.player: + if self.rating_stats is None and "beforeMean" not in self.replay_data: return None return self.rating_before() @@ -97,21 +98,21 @@ def rating_change(self) -> int: return self.rating_after() - self.rating_before() def faction_name(self) -> str: - if "faction" in self.player: - if self.player["faction"] == 1: + if "faction" in self.replay_data: + if self.replay_data["faction"] == 1: faction = "UEF" - elif self.player["faction"] == 2: + elif self.replay_data["faction"] == 2: faction = "Aeon" - elif self.player["faction"] == 3: + elif self.replay_data["faction"] == 3: faction = "Cybran" - elif self.player["faction"] == 4: + elif self.replay_data["faction"] == 4: faction = "Seraphim" - elif self.player["faction"] == 5: + elif self.replay_data["faction"] == 5: if self.mod == "nomads": faction = "Nomads" else: faction = "Random" - elif self.player["faction"] == 6: + elif self.replay_data["faction"] == 6: if self.mod == "nomads": faction = "Random" else: diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py index 9d18ec109..4e6966b9d 100644 --- a/src/replays/replayitem.py +++ b/src/replays/replayitem.py @@ -271,9 +271,11 @@ def generate_scoreboard(self) -> Scoreboard: if not self.extra_info_loaded: self.load_extra_info() self.spoiled = not self.parent.spoilerCheckbox.isChecked() + assert self.client is not None scoreboard = Scoreboard( self.mod, self.winner, self.spoiled, self.duration, self.teamWin, self.uid, self.teams, + self.client.player_ctx_menu, ) scoreboard.setup() return scoreboard diff --git a/src/replays/scoreboard.py b/src/replays/scoreboard.py index 5e03513ca..8a24019dc 100644 --- a/src/replays/scoreboard.py +++ b/src/replays/scoreboard.py @@ -12,9 +12,11 @@ from PyQt6.QtWidgets import QVBoxLayout from PyQt6.QtWidgets import QWidget +from contextmenu.playercontextmenu import PlayerContextMenu from replays.models import ScoreboardModel from replays.models import ScoreboardModelItem from replays.scoreboarditemdelegate import ScoreboardItemDelegate +from replays.scoreboardlistview import ScoreboardListView class GameResult(Enum): @@ -37,6 +39,7 @@ def __init__( teamwin: dict | None, uid: str, teams: dict, + player_menu_handler: PlayerContextMenu, ) -> None: super().__init__() self.winner = winner @@ -47,6 +50,7 @@ def __init__( self.teams = teams self.num_teams = len(self.teams) self.biggest_team = max(len(team) for team in self.teams.values()) if self.teams else 0 + self.player_menu_handler = player_menu_handler self.main_layout = QVBoxLayout() if self.num_teams == 2: @@ -58,8 +62,8 @@ def __init__( self._height = 0 self._team_heights = [] - def create_teamlist_view(self) -> QListView: - team_view = QListView() + def create_teamlist_view(self) -> ScoreboardListView: + team_view = ScoreboardListView() team_view.setObjectName("replayScoreTeamList") return team_view @@ -109,6 +113,7 @@ def add_team_score( team_view = self.create_teamlist_view() team_view.setModel(model) team_view.setItemDelegate(ScoreboardItemDelegate()) + team_view.set_menu_handler(self.player_menu_handler) team_layout.addWidget(team_view) view_height = self.teamview_height(team_view) diff --git a/src/replays/scoreboardlistview.py b/src/replays/scoreboardlistview.py new file mode 100644 index 000000000..ac10cff99 --- /dev/null +++ b/src/replays/scoreboardlistview.py @@ -0,0 +1,30 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QCursor +from PyQt6.QtGui import QMouseEvent +from PyQt6.QtWidgets import QListView + +from contextmenu.playercontextmenu import PlayerContextMenu +from replays.scoreboarditemdelegate import ScoreboardModelItem + + +class ScoreboardListView(QListView): + def __init__(self) -> None: + QListView.__init__(self) + self.menu_handler = None + + def set_menu_handler(self, menu_handler: PlayerContextMenu) -> None: + self.menu_handler = menu_handler + + def mousePressEvent(self, event: QMouseEvent) -> None: + QListView.mousePressEvent(self, event) + if event.button() == Qt.MouseButton.RightButton: + row = self.indexAt(event.pos()).row() + if row != -1: + index = self.model().index(row, 0) + self.context_menu(index.data()) + + def context_menu(self, item: ScoreboardModelItem) -> None: + if self.menu_handler is None: + return + menu = self.menu_handler.get_context_menu(item.login(), int(item.player.xd)) + menu.popup(QCursor.pos()) From 8bca2b1d89006b474ec339cb3039ac4d9a21eb98 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 25 Jul 2024 20:39:31 +0300 Subject: [PATCH 51/73] Do not pass the whole client window to PlayerInfoDialog it only needs avatar downloader --- src/contextmenu/playercontextmenu.py | 2 +- src/playercard/playerinfodialog.py | 10 +++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/contextmenu/playercontextmenu.py b/src/contextmenu/playercontextmenu.py index a5ee3e39c..df6397593 100644 --- a/src/contextmenu/playercontextmenu.py +++ b/src/contextmenu/playercontextmenu.py @@ -277,5 +277,5 @@ def _view_aliases(self, login: str) -> None: self._alias_viewer.view_aliases(login) def _show_user_info(self, player_id: int) -> None: - dialog = PlayerInfoDialog(self._client_window, str(player_id)) + dialog = PlayerInfoDialog(self._client_window.avatar_downloader, str(player_id)) dialog.run() diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 5e1a5805e..516ba80cf 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -2,7 +2,6 @@ from bisect import bisect_left from collections.abc import Sequence -from typing import TYPE_CHECKING import pyqtgraph as pg from PyQt6.QtCore import QDateTime @@ -37,11 +36,8 @@ from downloadManager import AvatarDownloader from downloadManager import DownloadRequest from model.rating import Rating +from playercard.leagueformatter import LegueFormatter from playercard.statistics import StatsCharts -from src.playercard.leagueformatter import LegueFormatter - -if TYPE_CHECKING: - from client._clientwindow import ClientWindow FormClass, BaseClass = util.THEME.loadUiType("player_card/playercard.ui") @@ -174,13 +170,13 @@ def show_at_pos(self, pos: QPointF) -> None: class PlayerInfoDialog(FormClass, BaseClass): - def __init__(self, client_window: ClientWindow, player_id: str) -> None: + def __init__(self, avatar_dler: AvatarDownloader, player_id: str) -> None: BaseClass.__init__(self) self.setupUi(self) self.load_stylesheet() self.tab_widget_ctrl = RatingTabWidgetController(player_id, self.tabWidget) - self.avatar_handler = AvatarHandler(self.avatarList, client_window.avatar_downloader) + self.avatar_handler = AvatarHandler(self.avatarList, avatar_dler) self.player_id = player_id From 95397d17de89e351f5046e63a782f7a06d99fc01 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Fri, 26 Jul 2024 04:52:33 +0300 Subject: [PATCH 52/73] Bring back original rankedFrame style --- res/client/client.css | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/res/client/client.css b/res/client/client.css index 0fe93270a..a3213e7b6 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -393,6 +393,18 @@ QTextEdit, QPlainTextEdit, QLineEdit, QListWidget, QListView, QTableWidget, QTre border-bottom-right-radius : 5px; } +QFrame#rankedFrame +{ + border-style:solid; + border-width:1px; + border-color:#353535; + color:silver; + padding:5px; + background-color:#303038; + border-bottom-left-radius : 5px; + border-bottom-right-radius : 5px; +} + QListView#replayScoreTeamList { border-width:0px; @@ -848,14 +860,6 @@ QToolButton#rankedPlay::disabled border: 1px solid darkslategrey; } -QProgressBar#searchProgress -{ - background-color: silver; - padding: 2px; - border-radius: 5px; - border: 1px solid grey; -} - QToolButton#mapsPool { color: silver; @@ -884,7 +888,7 @@ QToolButton::hover color:silver; padding:5px; - background-color:#606060; + background-color:#808080; border-radius : 5px; } From 5f1c96a90979f280682b1881c0477214f7bfe7b6 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 27 Jul 2024 17:11:35 +0300 Subject: [PATCH 53/73] Playercard: Format global rating in a special way (special icon and no league name) --- res/player_card/global.png | Bin 0 -> 3447 bytes src/playercard/leagueformatter.py | 77 ++++++++++++++++++++--------- src/playercard/playerinfodialog.py | 5 +- 3 files changed, 58 insertions(+), 24 deletions(-) create mode 100644 res/player_card/global.png diff --git a/res/player_card/global.png b/res/player_card/global.png new file mode 100644 index 0000000000000000000000000000000000000000..007706e2e8c80ad1beebabe5a1244fa985bde9a7 GIT binary patch literal 3447 zcmV--4T$oIP)^@RCt{2T??>QRTch__e;2xq12ich zACzV)Nr7dOq{TzPGPDq|!s2l=A5mbXh=QR-`JgfdQ!GUTe08|6G1;7^ooB^O?V}DFB;wS)p0DOr%bT5D)r9wj60^o-L z#slco*dLo^3&EVYET9X3>jC_;fIXak@*fC>N~1aJy~K>#KL_&$JpS+5-N_^k$> zHKcP&9`EO6=^aO>>;kYFz!d;aO8NK40=NUfzdVFPgJ1wk5X}Hg0#IZ5_(K5Ju(EAc zpmve*FbBX+OS@=RE$5k75;zUOX3Gcv2f$QbZW`0H9?j;oshw{Cn1~{u6;s)g(LRuT zh$#tXLr(xR0dyDte^ev2&TFi&1D+x_ZvQFKMRURr45ya|L@E?Ag7?o zX$&K0v2C(gOvkYiV8bWb5YWD1dok&j)7V?qK4b-eZVoee8i2n6_?+_hQ+bIC*J%<6 zm3Mg-O3Mbk2E7 zCqJh=yV^2{&SgC)-&vIvp^O$IJm678x}iNuh^vreW0Y zx9Es|%pS;hexAU(O9ev|ue;8mkl`?c{|lglcz%jQ7*Pd6<+n<^erkE1XqTgvcY3ku z?xU{?g1!nhPD{=x<=OT;rEMK`wPJDY$%8fuAX~%Ygz2gvvPa?ik1O z^{nJ+uSLUm3$0Hc5NFK-%HKs z*yCV0n=y%~StP5NUWpTC)1LPoCa_sOi}&Pz!83HTvabIMz{C9ST>$1XZ$_Q=DGNvC z!iRY_?KB9O7~wEJs#sk%M%f$R5S7*_OVVVIQAym;FF6J(%9`Am=*33HQy$;gzsJyLBNP%+F1v|z$z;X+ll#fviP7*t(Lr-~ zd6~c;lJ7{uZtTo`omtvW39Y?=S#0SDJ|Lcd*am5|MM8#cf}lNlDPbs_K!o~O`$qrT$urZ zJft|Waoa=|Rv6R8-=B;lkeoN2D_<6WKQBRp$ECD91StxH0^!1FZP=FfDa%A%$15hV zi-!3>bQRXYVI{J#IzcDO&7Mse5J;e`?JvfxXTM^UtQXH*7DXTziI>R%lZ2rbtK?G- zD~;Wg5rGVeLC2d41w(}KE%A49ZoTni@iK+WKhElK2<7Sd3;I$=e_SU3f z_Ld#P!a0W0Pss#zvb2P$ll#-4P9z;(CKr(!xi}>bx!BJ^zjr8M`u&kS)9>>g(KoR- zsGd%7gKBIO-k`b=DQ)5A4z97m85^1g+39ZU5%!e)=-2$T#u79Mt3Tb)uou(Mww-nQ|H+@%~`aSf+_I)^jA z*$(Us4;|RKi7m%9EHEwe-SCu1R9eby)>Z5SJGo48@<Swc(BG7(iE!xcRw z4}B*7?rBPA%45+JJsSHepA#IMmE435G~o_ zA?An9QgWL3pn})nNqk!Ndz=AuC0j*LIuZvg=F`1&r0h~obVJNQ9DIc9dQNqzSrE1uMo>4N4RW-&Qv7v}$RsBb2+jH^XD92~ znlddvw@|9Oyru^ull#^gCEPz0^DSE;uki1Swp8>X@q*e7`p)y_HqcoJ38jTP0 zp|FX_45LScc&NAnk%`Mr@rV^TsN|2&nZ%O%c}3Z>$Q#-#AKsvZ_<$R=DMljY`FRw` z^GZaaHps%I*5GCLRG5l_CsNLlc(tCX#@i{$yj-cqD?E1+;|SzjrBe?(1bA|URO52* zs9+duNXAe8h=yw-CgZ;}4+4>yh`?s4>G{=$J9JIS_$7-`aEvlsS3y*VX=a(OtC+7m ze{YHc*`?&r9b!nfxzCaybAW+KAeJE$m}uDUTwuA-WrmW$`ml15S6784h$E0A%2L%* zN#)nTv18=mX&7TL%U5QNG#z8u_<2IP+Ai#?EMeUfgsvTuLhNU_+bW7cXt{L7F>h3k zON0l}Lc?rY6q7v7JBAu|5O*s(X;P^!4rLfxXsCN%Oo7PlS#ZkOdhXyq^8LCdR9P~U zdWC~FCWtirx1sUXmZc!)rB7w=|6;=>GCLw(n@5HL6hG$~7Q8Kbuob6O(sDE&j1$V! zSm?|YgC>*%yy+$V011=eI&n6ldWS$Bv)oa5oW~M#w&eoj7ugvsI$Uk>xL;Kf8>eauJsTz_$d2Lo*< zH#t;rL`a;(_d&{KHw)Z$dvue+0N&5Gt> None: self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + def default_pixmap(self) -> QPixmap: + return util.THEME.pixmap("player_card/unlisted.png") + + def default_league(self) -> str: + return "Unlisted" + + def rating_text(self) -> str: + # chr(0xB1) = +- + return f"{self.rating.rating:.0f} [{self.rating.mean:.0f}\xb1{self.rating.deviation:.0f}]" + + def fill_ui(self) -> None: + self.divisionLabel.setText(self.default_league()) + self.set_league_icon() + self.gamesLabel.setText(f"{self.rating.total_games:.0f} Games") + self.ratingLabel.setText(self.rating_text()) + self.leaderboardLabel.setText(self.leaderboard.pretty_name) + + def fetch_league_score(self) -> None: + self.league_score_api.get_player_score_in_leaderboard( + self.player_id, self.leaderboard.technical_name, + ) + def on_league_score_ready(self, score: LeagueSeasonScore) -> None: if score.season.leaderboard.technical_name != self.leaderboard.technical_name: return if score.score is None: - self.divisionLabel.setText("Unlisted") return subdivision = score.subdivision @@ -71,8 +80,11 @@ def on_league_score_ready(self, score: LeagueSeasonScore) -> None: else: self.download_league_icon(subdivision.image_url) - def set_league_icon(self, image_path: str) -> None: - self.iconLabel.setPixmap(QPixmap(image_path).scaled(160, 80)) + def set_league_icon(self, image_path: str = "") -> None: + if os.path.isfile(image_path): + self.iconLabel.setPixmap(QPixmap(image_path).scaled(160, 80)) + else: + self.iconLabel.setPixmap(self.default_pixmap().scaled(80, 80)) def download_league_icon(self, url: str) -> None: name = os.path.basename(url) @@ -82,3 +94,24 @@ def on_image_downloaded(self, _: str, result: tuple[str, bool]) -> None: image_path, download_failed = result if not download_failed: self.set_league_icon(image_path) + + +class GlobalLeagueFormatter(LeagueFormatter): + def default_pixmap(self) -> QPixmap: + return util.THEME.pixmap("player_card/global.png") + + def default_league(self) -> str: + return "" + + def fetch_league_score(self) -> None: + return + + +def league_formatter_factory( + player_id: str, + rating: LeaderboardRating, + api: LeagueSeasonScoreApiConnector, +) -> LeagueFormatter | GlobalLeagueFormatter: + if rating.leaderboard.technical_name == "global": + return GlobalLeagueFormatter(player_id, rating, api) + return LeagueFormatter(player_id, rating, api) diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 516ba80cf..e554748bb 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -36,7 +36,7 @@ from downloadManager import AvatarDownloader from downloadManager import DownloadRequest from model.rating import Rating -from playercard.leagueformatter import LegueFormatter +from playercard.leagueformatter import league_formatter_factory from playercard.statistics import StatsCharts FormClass, BaseClass = util.THEME.loadUiType("player_card/playercard.ui") @@ -205,7 +205,8 @@ def run(self) -> None: def process_player_ratings(self, ratings: dict[str, list[LeaderboardRating]]) -> None: for rating in ratings["values"]: - self.leaguesLayout.addWidget(LegueFormatter(self.player_id, rating, self.leagues_api)) + widget = league_formatter_factory(self.player_id, rating, self.leagues_api) + self.leaguesLayout.addWidget(widget) pie_chart = self.stats_charts.game_types_played(ratings["values"]) self.statsChartsLayout.addWidget(pie_chart) From feb578488df51a3bfb8d869a0a6ed4809c61e15f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 27 Jul 2024 17:14:43 +0300 Subject: [PATCH 54/73] Playercard: Scale blank avatar icon --- src/playercard/playerinfodialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index e554748bb..9c0911636 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -270,7 +270,7 @@ def _add_avatar(self, avatar: Avatar) -> None: def _add_avatar_item(self, pixmap: QPixmap, description: str) -> None: if pixmap.isNull(): - icon = util.THEME.icon("chat/avatar/avatar_blank.png") + icon = QIcon(util.THEME.pixmap("chat/avatar/avatar_blank.png").scaled(40, 20)) else: icon = QIcon(pixmap.scaled(40, 20)) avatar_item = QListWidgetItem(icon, description) From a49a8f625b5d71965919edab9281c39651842b36 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 28 Jul 2024 04:24:10 +0300 Subject: [PATCH 55/73] Improve computing and memory efficiency of rating plots do not 'pre-parse' API response -- extract data directly from the dictionary json.load provided rating history responses can be very large -- tens of thousands of records, and skipping default API response parsing and convertion parsed data into pydantic models (especially skipping pydantic) saves lots of time additionally, using numpy arrays over python lists and deleting unused data structures saves memory --- src/api/stats_api.py | 16 ++-- src/playercard/playerinfodialog.py | 137 +++++++++++++++++++++++------ 2 files changed, 116 insertions(+), 37 deletions(-) diff --git a/src/api/stats_api.py b/src/api/stats_api.py index 018f42794..42e6a3528 100644 --- a/src/api/stats_api.py +++ b/src/api/stats_api.py @@ -4,13 +4,12 @@ from PyQt6.QtCore import Qt from PyQt6.QtCore import pyqtSignal +from api.ApiAccessors import ApiAccessor from api.ApiAccessors import DataApiAccessor from api.models.Leaderboard import Leaderboard -from api.models.LeaderboardRatingJournal import LeaderboardRatingJournal from api.models.LeagueSeasonScore import LeagueSeasonScore from api.models.PlayerEvent import PlayerEvent from api.parsers.LeaderboardParser import LeaderboardParser -from api.parsers.LeaderboardRatingJournalParser import LeaderboardRatingJournalParser from api.parsers.LeaderboardRatingParser import LeaderboardRatingParser logger = logging.getLogger(__name__) @@ -42,22 +41,22 @@ def prepare_data(self, message: dict) -> dict[str, list[Leaderboard]]: return {"values": LeaderboardParser.parse_many(message)} -class LeaderboardRatingJournalApiConnector(DataApiAccessor): - ratings_ready = pyqtSignal(dict) +class LeaderboardRatingJournalApiConnector(ApiAccessor): + ratings_ready = pyqtSignal() + ratings_chunk_ready = pyqtSignal(dict) def __init__(self) -> None: super().__init__("/data/leaderboardRatingJournal") - self._result: list[LeaderboardRatingJournal] = [] self.query = {} def handle_page(self, message: dict) -> None: total_pages = message["meta"]["page"]["totalPages"] current_page = message["meta"]["page"]["number"] - self._result.extend(LeaderboardRatingJournalParser.parse_many(message)) + self.ratings_chunk_ready.emit(message) if current_page < total_pages: self.get_history_page(current_page + 1) else: - self.ratings_ready.emit({"values": self._result}) + self.ratings_ready.emit() def get_history_page(self, page: int) -> None: self.query.update({ @@ -68,9 +67,8 @@ def get_history_page(self, page: int) -> None: self.get_by_query(self.query, self.handle_page) def get_full_history(self, pid: str, leaderboard: str) -> None: - self._result.clear() self.query.update({ - "include": "gamePlayerStats,leaderboard", + "include": "gamePlayerStats", "filter": ( f"gamePlayerStats.player.id=={pid!r};" f"leaderboard.technicalName=={leaderboard!r};" diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 9c0911636..f19e26578 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -3,11 +3,13 @@ from bisect import bisect_left from collections.abc import Sequence +import numpy as np import pyqtgraph as pg from PyQt6.QtCore import QDateTime from PyQt6.QtCore import QObject from PyQt6.QtCore import QPointF from PyQt6.QtCore import Qt +from PyQt6.QtCore import QThread from PyQt6.QtCore import pyqtSignal from PyQt6.QtGui import QColor from PyQt6.QtGui import QIcon @@ -23,7 +25,6 @@ from api.models.AvatarAssignment import AvatarAssignment from api.models.Leaderboard import Leaderboard from api.models.LeaderboardRating import LeaderboardRating -from api.models.LeaderboardRatingJournal import LeaderboardRatingJournal from api.models.NameRecord import NameRecord from api.models.Player import Player from api.models.PlayerEvent import PlayerEvent @@ -137,7 +138,7 @@ def get_ytext_pos(self, scene_point: QPointF) -> tuple[float, float]: return x, y def update_lines_and_text(self, pos: QPointF) -> None: - if not self.series.x(): + if len(self.series.x()) == 0: return data_point = self.map_to_data(pos) @@ -283,26 +284,84 @@ def _handle_avatar_download(self, url: str, pixmap: QPixmap) -> None: class LineSeries: - def __init__(self) -> None: - self._x: list[Numeric] = [] - self._y: list[Numeric] = [] + def __init__(self, size: int = 0) -> None: + self._x: np.ndarray = np.zeros(size) + self._y: np.ndarray = np.zeros(size) - def x(self) -> list[Numeric]: + def x(self) -> np.ndarray: return self._x - def y(self) -> list[Numeric]: + def y(self) -> np.ndarray: return self._y - def append(self, x: Numeric, y: Numeric) -> None: - self._x.append(x) - self._y.append(y) + def set_point(self, index: int, point: QPointF) -> None: + self._x[index] = point.x() + self._y[index] = point.y() + + def extend(self, series: LineSeries) -> None: + self._x = np.append(self._x, series.x()) + self._y = np.append(self._y, series.y()) def point_at(self, index: int) -> QPointF: return QPointF(self._x[index], self._y[index]) +class LineSeriesParser(QObject): + result_ready = pyqtSignal(LineSeries) + + def __init__(self, identifier: int, unparsed_api_response: dict) -> None: + QObject.__init__(self) + self.id = identifier + self.data = unparsed_api_response + + def parse(self, index: int) -> None: + if index != self.id: + return + + journal = self.data["data"] + journal_leng = len(journal) + if journal_leng == 0: + self.result_ready.emit(LineSeries()) + return + + stats = self.data["included"] + stats_leng = len(stats) + + series = LineSeries(stats_leng) + + stats_index = journal_index = 0 + while stats_index < stats_leng and journal_index < journal_leng: + if ( + stats[stats_index]["id"] + != journal[journal_index]["relationships"]["gamePlayerStats"]["data"]["id"] + ): + journal_index += 1 + continue + + score_time_str = stats[stats_index]["attributes"]["scoreTime"] + score_time = QDateTime.fromString(score_time_str, Qt.DateFormat.ISODate) + # not creating additional objects (like Rating and QPointF) + # and not accessing their attributes in a loop will also give small + # improvement, but not quite noticeable (a few hundreds of a second + # per 10000 loop cycles -- ~10x less than API call deviation) + rating = Rating( + journal[journal_index]["attributes"]["meanAfter"], + journal[journal_index]["attributes"]["deviationAfter"], + ) + point = QPointF( + score_time.toSecsSinceEpoch(), + rating.displayed(), + ) + series.set_point(stats_index, point) + stats_index += 1 + journal_index += 1 + + self.result_ready.emit(series) + + class RatingsPlotTab(QObject): name_changed = pyqtSignal(int, str) + parse_ratings_chunk = pyqtSignal(int) def __init__( self, @@ -316,32 +375,51 @@ def __init__( self.player_id = player_id self.leaderboard = leaderboard self.ratings_history_api = LeaderboardRatingJournalApiConnector() - self.ratings_history_api.ratings_ready.connect(self.process_rating_history) + self.ratings_history_api.ratings_ready.connect(self.on_ratings_loaded) + self.ratings_history_api.ratings_chunk_ready.connect(self.process_rating_history) self.plot = plot self._loaded = False + self.worker_thread = QThread() + self.workers = [] + + def __del__(self) -> None: + self.clear_threads() def enter(self) -> None: if self._loaded: return self.name_changed.emit(self.index, "Loading...") self.ratings_history_api.get_full_history(self.player_id, self.leaderboard.technical_name) + self.worker_thread.start() - def get_plot_series(self, ratings: list[LeaderboardRatingJournal]) -> LineSeries: - series = LineSeries() - for entry in ratings: - assert entry.player_stats is not None - score_time = QDateTime.fromString(entry.player_stats.score_time, Qt.DateFormat.ISODate) - series.append( - score_time.toSecsSinceEpoch(), - Rating(entry.mean_after, entry.deviation_after).displayed(), - ) - return series - - def process_rating_history(self, ratings: dict[str, list[LeaderboardRatingJournal]]) -> None: - self.plot.draw_series(self.get_plot_series(ratings["values"])) + def on_ratings_loaded(self) -> None: self._loaded = True + + def clear_threads(self) -> None: + self.worker_thread.quit() + self.workers.clear() + + def finish(self) -> None: + self.clear_threads() + self.plot.draw_series() self.name_changed.emit(self.index, self.leaderboard.pretty_name) + def process_rating_history(self, message: dict) -> None: + index = len(self.workers) + worker = LineSeriesParser(index, message) + self.workers.append(worker) + + worker.result_ready.connect(self.data_parsed) + worker.moveToThread(self.worker_thread) + + self.parse_ratings_chunk.connect(worker.parse) + self.parse_ratings_chunk.emit(index) + + def data_parsed(self, series: LineSeries) -> None: + self.plot.add_data(series) + if self._loaded: + self.finish() + class RatingTabWidgetController: def __init__(self, player_id: str, tab_widget: QTabWidget) -> None: @@ -373,18 +451,21 @@ def __init__(self, widget: pg.PlotWidget) -> None: self.widget = widget self.widget.setBackground("#202025") self.widget.setAxisItems({"bottom": DateAxisItem()}) - self.crosshairs = Crosshairs(self.widget, LineSeries()) + self.series = LineSeries() + self.crosshairs = Crosshairs(self.widget, self.series) self.hide_irrelevant_plot_actions() self.add_custom_menu_actions() def clear(self) -> None: self.widget.clear() - def draw_series(self, series: LineSeries) -> None: - self.widget.plot(series.x(), series.y(), pen=pg.mkPen("orange")) - self.crosshairs.set_series(series) + def draw_series(self) -> None: + self.widget.plot(self.series.x(), self.series.y(), pen=pg.mkPen("orange")) self.widget.autoRange() + def add_data(self, series: LineSeries) -> None: + self.series.extend(series) + def hide_irrelevant_plot_actions(self) -> None: for action in ("Transforms", "Downsample", "Average", "Alpha", "Points"): self.widget.plotItem.setContextMenuActionVisible(action, visible=False) From 3417953775fd77dd6e32facec96b8979c711b1c3 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 28 Jul 2024 05:39:40 +0300 Subject: [PATCH 56/73] Process data for rating plots more straightforwardly don't create overhead signals and inherit LineSeriesParser directly from QThread --- src/api/stats_api.py | 7 ++--- src/playercard/playerinfodialog.py | 45 ++++++++++++++---------------- 2 files changed, 23 insertions(+), 29 deletions(-) diff --git a/src/api/stats_api.py b/src/api/stats_api.py index 42e6a3528..23057ecdb 100644 --- a/src/api/stats_api.py +++ b/src/api/stats_api.py @@ -42,8 +42,7 @@ def prepare_data(self, message: dict) -> dict[str, list[Leaderboard]]: class LeaderboardRatingJournalApiConnector(ApiAccessor): - ratings_ready = pyqtSignal() - ratings_chunk_ready = pyqtSignal(dict) + ratings_ready = pyqtSignal(dict) def __init__(self) -> None: super().__init__("/data/leaderboardRatingJournal") @@ -52,11 +51,9 @@ def __init__(self) -> None: def handle_page(self, message: dict) -> None: total_pages = message["meta"]["page"]["totalPages"] current_page = message["meta"]["page"]["number"] - self.ratings_chunk_ready.emit(message) + self.ratings_ready.emit(message) if current_page < total_pages: self.get_history_page(current_page + 1) - else: - self.ratings_ready.emit() def get_history_page(self, page: int) -> None: self.query.update({ diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index f19e26578..e5b01f711 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -306,20 +306,20 @@ def point_at(self, index: int) -> QPointF: return QPointF(self._x[index], self._y[index]) -class LineSeriesParser(QObject): +class LineSeriesParser(QThread): result_ready = pyqtSignal(LineSeries) - def __init__(self, identifier: int, unparsed_api_response: dict) -> None: - QObject.__init__(self) - self.id = identifier + def __init__(self, unparsed_api_response: dict) -> None: + QThread.__init__(self) self.data = unparsed_api_response - def parse(self, index: int) -> None: - if index != self.id: - return + def run(self) -> None: + self.parse() + def parse(self) -> None: journal = self.data["data"] journal_leng = len(journal) + if journal_leng == 0: self.result_ready.emit(LineSeries()) return @@ -361,7 +361,6 @@ def parse(self, index: int) -> None: class RatingsPlotTab(QObject): name_changed = pyqtSignal(int, str) - parse_ratings_chunk = pyqtSignal(int) def __init__( self, @@ -375,28 +374,27 @@ def __init__( self.player_id = player_id self.leaderboard = leaderboard self.ratings_history_api = LeaderboardRatingJournalApiConnector() - self.ratings_history_api.ratings_ready.connect(self.on_ratings_loaded) - self.ratings_history_api.ratings_chunk_ready.connect(self.process_rating_history) + self.ratings_history_api.ratings_ready.connect(self.process_rating_history) self.plot = plot self._loaded = False - self.worker_thread = QThread() self.workers = [] def __del__(self) -> None: - self.clear_threads() + try: + self.clear_threads() + except RuntimeError: + pass def enter(self) -> None: if self._loaded: return self.name_changed.emit(self.index, "Loading...") self.ratings_history_api.get_full_history(self.player_id, self.leaderboard.technical_name) - self.worker_thread.start() - - def on_ratings_loaded(self) -> None: - self._loaded = True def clear_threads(self) -> None: - self.worker_thread.quit() + for worker in self.workers: + if worker.isRunning(): + worker.quit() self.workers.clear() def finish(self) -> None: @@ -405,15 +403,14 @@ def finish(self) -> None: self.name_changed.emit(self.index, self.leaderboard.pretty_name) def process_rating_history(self, message: dict) -> None: - index = len(self.workers) - worker = LineSeriesParser(index, message) - self.workers.append(worker) + total_pages = message["meta"]["page"]["totalPages"] + current_page = message["meta"]["page"]["number"] + self._loaded = current_page == total_pages + worker = LineSeriesParser(message) + self.workers.append(worker) worker.result_ready.connect(self.data_parsed) - worker.moveToThread(self.worker_thread) - - self.parse_ratings_chunk.connect(worker.parse) - self.parse_ratings_chunk.emit(index) + worker.start() def data_parsed(self, series: LineSeries) -> None: self.plot.add_data(series) From 8179a2cde75dca541c754a1f50355af488876b46 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 28 Jul 2024 05:59:07 +0300 Subject: [PATCH 57/73] Split playerinfodialog's mess into separate smaller files --- src/playercard/avatarhandler.py | 55 ++++ src/playercard/playerinfodialog.py | 390 +---------------------------- src/playercard/plot.py | 197 +++++++++++++++ src/playercard/ratingtabwidget.py | 154 ++++++++++++ 4 files changed, 408 insertions(+), 388 deletions(-) create mode 100644 src/playercard/avatarhandler.py create mode 100644 src/playercard/plot.py create mode 100644 src/playercard/ratingtabwidget.py diff --git a/src/playercard/avatarhandler.py b/src/playercard/avatarhandler.py new file mode 100644 index 000000000..ac33ce667 --- /dev/null +++ b/src/playercard/avatarhandler.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from PyQt6.QtGui import QIcon +from PyQt6.QtGui import QPixmap +from PyQt6.QtWidgets import QListWidget +from PyQt6.QtWidgets import QListWidgetItem + +import util +from api.models.Avatar import Avatar +from api.models.AvatarAssignment import AvatarAssignment +from downloadManager import AvatarDownloader +from downloadManager import DownloadRequest + + +class AvatarHandler: + def __init__(self, avatar_list: QListWidget, avatar_downloader: AvatarDownloader) -> None: + self.avatar_list = avatar_list + self.avatar_dler = avatar_downloader + self.requests = {} + + def populate_avatars(self, avatar_assignments: list[AvatarAssignment] | None) -> None: + if avatar_assignments is None: + return + + for assignment in avatar_assignments: + if self.avatar_dler.has_avatar(assignment.avatar.filename): + self._add_avatar(assignment.avatar) + else: + self._download_avatar(assignment.avatar) + + def _prepare_avatar_dl_request(self, avatar: Avatar) -> DownloadRequest: + req = DownloadRequest() + req.done.connect(self._handle_avatar_download) + self.requests[avatar.url] = (req, avatar.tooltip) + return req + + def _download_avatar(self, avatar: Avatar) -> None: + req = self._prepare_avatar_dl_request(avatar) + self.avatar_dler.download_avatar(avatar.url, req) + + def _add_avatar(self, avatar: Avatar) -> None: + self._add_avatar_item(self.avatar_dler.get_avatar(avatar.filename), avatar.tooltip) + + def _add_avatar_item(self, pixmap: QPixmap, description: str) -> None: + if pixmap.isNull(): + icon = QIcon(util.THEME.pixmap("chat/avatar/avatar_blank.png").scaled(40, 20)) + else: + icon = QIcon(pixmap.scaled(40, 20)) + avatar_item = QListWidgetItem(icon, description) + self.avatar_list.addItem(avatar_item) + + def _handle_avatar_download(self, url: str, pixmap: QPixmap) -> None: + _, tooltip = self.requests[url] + self._add_avatar_item(pixmap, tooltip) + del self.requests[url] diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index e5b01f711..a3f2e65fc 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -1,174 +1,27 @@ from __future__ import annotations -from bisect import bisect_left -from collections.abc import Sequence - -import numpy as np -import pyqtgraph as pg from PyQt6.QtCore import QDateTime -from PyQt6.QtCore import QObject -from PyQt6.QtCore import QPointF from PyQt6.QtCore import Qt -from PyQt6.QtCore import QThread -from PyQt6.QtCore import pyqtSignal -from PyQt6.QtGui import QColor -from PyQt6.QtGui import QIcon -from PyQt6.QtGui import QPixmap -from PyQt6.QtWidgets import QListWidget -from PyQt6.QtWidgets import QListWidgetItem from PyQt6.QtWidgets import QTableWidgetItem -from PyQt6.QtWidgets import QTabWidget -from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem import util -from api.models.Avatar import Avatar from api.models.AvatarAssignment import AvatarAssignment -from api.models.Leaderboard import Leaderboard from api.models.LeaderboardRating import LeaderboardRating from api.models.NameRecord import NameRecord from api.models.Player import Player from api.models.PlayerEvent import PlayerEvent from api.player_api import PlayerApiConnector -from api.stats_api import LeaderboardApiConnector from api.stats_api import LeaderboardRatingApiConnector -from api.stats_api import LeaderboardRatingJournalApiConnector from api.stats_api import LeagueSeasonScoreApiConnector from api.stats_api import PlayerEventApiAccessor from downloadManager import AvatarDownloader -from downloadManager import DownloadRequest -from model.rating import Rating +from playercard.avatarhandler import AvatarHandler from playercard.leagueformatter import league_formatter_factory +from playercard.ratingtabwidget import RatingTabWidgetController from playercard.statistics import StatsCharts FormClass, BaseClass = util.THEME.loadUiType("player_card/playercard.ui") -Numeric = float | int - - -class Crosshairs: - def __init__(self, plotwidget: pg.PlotWidget, series: LineSeries) -> None: - self.plotwidget = plotwidget - self.plotwidget.scene().sigMouseMoved.connect(self.update_lines_and_text) - - self.series = series - - pen = pg.mkPen("green", width=3) - self.xLine = pg.InfiniteLine(angle=90, pen=pen) - self.yLine = pg.InfiniteLine(angle=0, pen=pen) - - self.plotwidget.addItem(self.xLine, ignoreBounds=True) - self.plotwidget.addItem(self.yLine, ignoreBounds=True) - - color = QColor("black") - self.xText = pg.TextItem(color=color) - self.yText = pg.TextItem(color=color) - - self.plotwidget.scene().addItem(self.xText) - self.plotwidget.scene().addItem(self.yText) - - self.plotwidget.plotItem.getAxis("left").setWidth(40) - self._visible = True - - def set_series(self, series: LineSeries) -> None: - self.series = series - - def set_visible(self, visible: bool) -> None: - self.xLine.setVisible(visible) - self.yLine.setVisible(visible) - self.xText.setVisible(visible) - self.yText.setVisible(visible) - - def display(self, *, seen: bool) -> None: - if not self._visible: - return - self.set_visible(seen) - - def hide(self) -> None: - self.set_visible(False) - - def show(self) -> None: - self.set_visible(True) - - def change_visibility(self) -> None: - new_state = not self._visible - self.set_visible(new_state) - self._visible = new_state - - def is_visible(self) -> bool: - return self._visible - - def _closest_index(self, lst: Sequence[Numeric], value: Numeric) -> int: - pos = bisect_left(lst, value) - if pos == 0: - return pos - if pos == len(lst): - return pos - 1 - - before = lst[pos - 1] - after = lst[pos] - if after - value < value - before: - return pos - else: - return pos - 1 - - def map_to_data(self, pos: QPointF) -> QPointF: - view = self.plotwidget.plotItem.getViewBox() - value_pos = view.mapSceneToView(pos) - point_index = self._closest_index(self.series.x(), value_pos.x()) - return self.series.point_at(point_index) - - def get_xtext_pos(self, scene_point: QPointF) -> tuple[float, float]: - scene_width = self.plotwidget.sceneBoundingRect().width() - text_width = self.xText.boundingRect().width() - text_height = self.xText.boundingRect().height() - padding = 3 - - left_margin = self.plotwidget.plotItem.getAxis("left").width() - padding - right_margin = scene_width - text_width + padding - - x = max(left_margin, scene_point.x() - text_width / 2) - x = min(x, right_margin) - y = self.plotwidget.sceneBoundingRect().bottom() - text_height + padding - return x, y - - def get_ytext_pos(self, scene_point: QPointF) -> tuple[float, float]: - padding = 3 - x = self.plotwidget.sceneBoundingRect().left() - padding - y = scene_point.y() - self.yText.boundingRect().height() / 2 - return x, y - - def update_lines_and_text(self, pos: QPointF) -> None: - if len(self.series.x()) == 0: - return - - data_point = self.map_to_data(pos) - view = self.plotwidget.plotItem.getViewBox() - scene_point = view.mapViewToScene(data_point) - - left_margin = self.plotwidget.plotItem.getAxis("left").width() - if scene_point.x() < left_margin or scene_point.y() < 0: - return - - self.update_lines(pos, data_point) - self.update_text(scene_point, data_point) - self.show_at_pos(scene_point) - - def update_text(self, scene_point: QPointF, data_point: QPointF) -> None: - date = QDateTime.fromSecsSinceEpoch(round(data_point.x())).toString("dd-MM-yyyy hh:mm") - self.xText.setHtml(f"
{date}
") - self.xText.setPos(*self.get_xtext_pos(scene_point)) - self.yText.setHtml(f"
{data_point.y():.2f}
") - self.yText.setPos(*self.get_ytext_pos(scene_point)) - - def update_lines(self, pos: QPointF, data_point: QPointF) -> None: - if self.plotwidget.sceneBoundingRect().contains(pos): - self.xLine.setPos(data_point.x()) - self.yLine.setPos(data_point.y()) - - def show_at_pos(self, pos: QPointF) -> None: - seen = self.plotwidget.sceneBoundingRect().contains(pos) - self.display(seen=seen) - class PlayerInfoDialog(FormClass, BaseClass): def __init__(self, avatar_dler: AvatarDownloader, player_id: str) -> None: @@ -238,242 +91,3 @@ def add_avatars(self, avatar_assignments: list[AvatarAssignment] | None) -> None def process_player_events(self, events: list[PlayerEvent]) -> None: for chartview in self.stats_charts.player_events_charts(events): self.statsChartsLayout.addWidget(chartview) - - -class AvatarHandler: - def __init__(self, avatar_list: QListWidget, avatar_downloader: AvatarDownloader) -> None: - self.avatar_list = avatar_list - self.avatar_dler = avatar_downloader - self.requests = {} - - def populate_avatars(self, avatar_assignments: list[AvatarAssignment] | None) -> None: - if avatar_assignments is None: - return - - for assignment in avatar_assignments: - if self.avatar_dler.has_avatar(assignment.avatar.filename): - self._add_avatar(assignment.avatar) - else: - self._download_avatar(assignment.avatar) - - def _prepare_avatar_dl_request(self, avatar: Avatar) -> DownloadRequest: - req = DownloadRequest() - req.done.connect(self._handle_avatar_download) - self.requests[avatar.url] = (req, avatar.tooltip) - return req - - def _download_avatar(self, avatar: Avatar) -> None: - req = self._prepare_avatar_dl_request(avatar) - self.avatar_dler.download_avatar(avatar.url, req) - - def _add_avatar(self, avatar: Avatar) -> None: - self._add_avatar_item(self.avatar_dler.get_avatar(avatar.filename), avatar.tooltip) - - def _add_avatar_item(self, pixmap: QPixmap, description: str) -> None: - if pixmap.isNull(): - icon = QIcon(util.THEME.pixmap("chat/avatar/avatar_blank.png").scaled(40, 20)) - else: - icon = QIcon(pixmap.scaled(40, 20)) - avatar_item = QListWidgetItem(icon, description) - self.avatar_list.addItem(avatar_item) - - def _handle_avatar_download(self, url: str, pixmap: QPixmap) -> None: - _, tooltip = self.requests[url] - self._add_avatar_item(pixmap, tooltip) - del self.requests[url] - - -class LineSeries: - def __init__(self, size: int = 0) -> None: - self._x: np.ndarray = np.zeros(size) - self._y: np.ndarray = np.zeros(size) - - def x(self) -> np.ndarray: - return self._x - - def y(self) -> np.ndarray: - return self._y - - def set_point(self, index: int, point: QPointF) -> None: - self._x[index] = point.x() - self._y[index] = point.y() - - def extend(self, series: LineSeries) -> None: - self._x = np.append(self._x, series.x()) - self._y = np.append(self._y, series.y()) - - def point_at(self, index: int) -> QPointF: - return QPointF(self._x[index], self._y[index]) - - -class LineSeriesParser(QThread): - result_ready = pyqtSignal(LineSeries) - - def __init__(self, unparsed_api_response: dict) -> None: - QThread.__init__(self) - self.data = unparsed_api_response - - def run(self) -> None: - self.parse() - - def parse(self) -> None: - journal = self.data["data"] - journal_leng = len(journal) - - if journal_leng == 0: - self.result_ready.emit(LineSeries()) - return - - stats = self.data["included"] - stats_leng = len(stats) - - series = LineSeries(stats_leng) - - stats_index = journal_index = 0 - while stats_index < stats_leng and journal_index < journal_leng: - if ( - stats[stats_index]["id"] - != journal[journal_index]["relationships"]["gamePlayerStats"]["data"]["id"] - ): - journal_index += 1 - continue - - score_time_str = stats[stats_index]["attributes"]["scoreTime"] - score_time = QDateTime.fromString(score_time_str, Qt.DateFormat.ISODate) - # not creating additional objects (like Rating and QPointF) - # and not accessing their attributes in a loop will also give small - # improvement, but not quite noticeable (a few hundreds of a second - # per 10000 loop cycles -- ~10x less than API call deviation) - rating = Rating( - journal[journal_index]["attributes"]["meanAfter"], - journal[journal_index]["attributes"]["deviationAfter"], - ) - point = QPointF( - score_time.toSecsSinceEpoch(), - rating.displayed(), - ) - series.set_point(stats_index, point) - stats_index += 1 - journal_index += 1 - - self.result_ready.emit(series) - - -class RatingsPlotTab(QObject): - name_changed = pyqtSignal(int, str) - - def __init__( - self, - index: int, - player_id: str, - leaderboard: Leaderboard, - plot: PlotController, - ) -> None: - super().__init__() - self.index = index - self.player_id = player_id - self.leaderboard = leaderboard - self.ratings_history_api = LeaderboardRatingJournalApiConnector() - self.ratings_history_api.ratings_ready.connect(self.process_rating_history) - self.plot = plot - self._loaded = False - self.workers = [] - - def __del__(self) -> None: - try: - self.clear_threads() - except RuntimeError: - pass - - def enter(self) -> None: - if self._loaded: - return - self.name_changed.emit(self.index, "Loading...") - self.ratings_history_api.get_full_history(self.player_id, self.leaderboard.technical_name) - - def clear_threads(self) -> None: - for worker in self.workers: - if worker.isRunning(): - worker.quit() - self.workers.clear() - - def finish(self) -> None: - self.clear_threads() - self.plot.draw_series() - self.name_changed.emit(self.index, self.leaderboard.pretty_name) - - def process_rating_history(self, message: dict) -> None: - total_pages = message["meta"]["page"]["totalPages"] - current_page = message["meta"]["page"]["number"] - self._loaded = current_page == total_pages - - worker = LineSeriesParser(message) - self.workers.append(worker) - worker.result_ready.connect(self.data_parsed) - worker.start() - - def data_parsed(self, series: LineSeries) -> None: - self.plot.add_data(series) - if self._loaded: - self.finish() - - -class RatingTabWidgetController: - def __init__(self, player_id: str, tab_widget: QTabWidget) -> None: - self.player_id = player_id - self.widget = tab_widget - self.widget.currentChanged.connect(self.on_tab_changed) - - self.leaderboards_api = LeaderboardApiConnector() - self.leaderboards_api.data_ready.connect(self.populate_leaderboards) - self.tabs: dict[int, RatingsPlotTab] = {} - - def run(self) -> None: - self.leaderboards_api.requestData() - - def populate_leaderboards(self, message: dict[str, list[Leaderboard]]) -> None: - for index, leaderboard in enumerate(message["values"]): - widget = pg.PlotWidget() - tab = RatingsPlotTab(index, self.player_id, leaderboard, PlotController(widget)) - tab.name_changed.connect(self.widget.setTabText) - self.tabs[index] = tab - self.widget.insertTab(index, widget, leaderboard.pretty_name) - - def on_tab_changed(self, index: int) -> None: - self.tabs[index].enter() - - -class PlotController: - def __init__(self, widget: pg.PlotWidget) -> None: - self.widget = widget - self.widget.setBackground("#202025") - self.widget.setAxisItems({"bottom": DateAxisItem()}) - self.series = LineSeries() - self.crosshairs = Crosshairs(self.widget, self.series) - self.hide_irrelevant_plot_actions() - self.add_custom_menu_actions() - - def clear(self) -> None: - self.widget.clear() - - def draw_series(self) -> None: - self.widget.plot(self.series.x(), self.series.y(), pen=pg.mkPen("orange")) - self.widget.autoRange() - - def add_data(self, series: LineSeries) -> None: - self.series.extend(series) - - def hide_irrelevant_plot_actions(self) -> None: - for action in ("Transforms", "Downsample", "Average", "Alpha", "Points"): - self.widget.plotItem.setContextMenuActionVisible(action, visible=False) - - def add_custom_menu_actions(self) -> None: - viewbox = self.widget.plotItem.getViewBox() - if viewbox is None: - return - - menu = viewbox.getMenu(ev=None) - if menu is None: - return - - menu.addAction("Show/Hide crosshair", self.crosshairs.change_visibility) diff --git a/src/playercard/plot.py b/src/playercard/plot.py new file mode 100644 index 000000000..d612ee8d0 --- /dev/null +++ b/src/playercard/plot.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from bisect import bisect_left +from collections.abc import Sequence + +import numpy as np +import pyqtgraph as pg +from PyQt6.QtCore import QDateTime +from PyQt6.QtCore import QPointF +from PyQt6.QtGui import QColor +from pyqtgraph.graphicsItems.DateAxisItem import DateAxisItem + +Numeric = float | int + + +class Crosshairs: + def __init__(self, plotwidget: pg.PlotWidget, series: LineSeries) -> None: + self.plotwidget = plotwidget + self.plotwidget.scene().sigMouseMoved.connect(self.update_lines_and_text) + + self.series = series + + pen = pg.mkPen("green", width=3) + self.xLine = pg.InfiniteLine(angle=90, pen=pen) + self.yLine = pg.InfiniteLine(angle=0, pen=pen) + + self.plotwidget.addItem(self.xLine, ignoreBounds=True) + self.plotwidget.addItem(self.yLine, ignoreBounds=True) + + color = QColor("black") + self.xText = pg.TextItem(color=color) + self.yText = pg.TextItem(color=color) + + self.plotwidget.scene().addItem(self.xText) + self.plotwidget.scene().addItem(self.yText) + + self.plotwidget.plotItem.getAxis("left").setWidth(40) + self._visible = True + + def set_series(self, series: LineSeries) -> None: + self.series = series + + def set_visible(self, visible: bool) -> None: + self.xLine.setVisible(visible) + self.yLine.setVisible(visible) + self.xText.setVisible(visible) + self.yText.setVisible(visible) + + def display(self, *, seen: bool) -> None: + if not self._visible: + return + self.set_visible(seen) + + def hide(self) -> None: + self.set_visible(False) + + def show(self) -> None: + self.set_visible(True) + + def change_visibility(self) -> None: + new_state = not self._visible + self.set_visible(new_state) + self._visible = new_state + + def is_visible(self) -> bool: + return self._visible + + def _closest_index(self, lst: Sequence[Numeric], value: Numeric) -> int: + pos = bisect_left(lst, value) + if pos == 0: + return pos + if pos == len(lst): + return pos - 1 + + before = lst[pos - 1] + after = lst[pos] + if after - value < value - before: + return pos + else: + return pos - 1 + + def map_to_data(self, pos: QPointF) -> QPointF: + view = self.plotwidget.plotItem.getViewBox() + value_pos = view.mapSceneToView(pos) + point_index = self._closest_index(self.series.x(), value_pos.x()) + return self.series.point_at(point_index) + + def get_xtext_pos(self, scene_point: QPointF) -> tuple[float, float]: + scene_width = self.plotwidget.sceneBoundingRect().width() + text_width = self.xText.boundingRect().width() + text_height = self.xText.boundingRect().height() + padding = 3 + + left_margin = self.plotwidget.plotItem.getAxis("left").width() - padding + right_margin = scene_width - text_width + padding + + x = max(left_margin, scene_point.x() - text_width / 2) + x = min(x, right_margin) + y = self.plotwidget.sceneBoundingRect().bottom() - text_height + padding + return x, y + + def get_ytext_pos(self, scene_point: QPointF) -> tuple[float, float]: + padding = 3 + x = self.plotwidget.sceneBoundingRect().left() - padding + y = scene_point.y() - self.yText.boundingRect().height() / 2 + return x, y + + def update_lines_and_text(self, pos: QPointF) -> None: + if len(self.series.x()) == 0: + return + + data_point = self.map_to_data(pos) + view = self.plotwidget.plotItem.getViewBox() + scene_point = view.mapViewToScene(data_point) + + left_margin = self.plotwidget.plotItem.getAxis("left").width() + if scene_point.x() < left_margin or scene_point.y() < 0: + return + + self.update_lines(pos, data_point) + self.update_text(scene_point, data_point) + self.show_at_pos(scene_point) + + def update_text(self, scene_point: QPointF, data_point: QPointF) -> None: + date = QDateTime.fromSecsSinceEpoch(round(data_point.x())).toString("dd-MM-yyyy hh:mm") + self.xText.setHtml(f"
{date}
") + self.xText.setPos(*self.get_xtext_pos(scene_point)) + self.yText.setHtml(f"
{data_point.y():.2f}
") + self.yText.setPos(*self.get_ytext_pos(scene_point)) + + def update_lines(self, pos: QPointF, data_point: QPointF) -> None: + if self.plotwidget.sceneBoundingRect().contains(pos): + self.xLine.setPos(data_point.x()) + self.yLine.setPos(data_point.y()) + + def show_at_pos(self, pos: QPointF) -> None: + seen = self.plotwidget.sceneBoundingRect().contains(pos) + self.display(seen=seen) + + +class LineSeries: + def __init__(self, size: int = 0) -> None: + self._x: np.ndarray = np.zeros(size) + self._y: np.ndarray = np.zeros(size) + + def x(self) -> np.ndarray: + return self._x + + def y(self) -> np.ndarray: + return self._y + + def set_point(self, index: int, point: QPointF) -> None: + self._x[index] = point.x() + self._y[index] = point.y() + + def extend(self, series: LineSeries) -> None: + self._x = np.append(self._x, series.x()) + self._y = np.append(self._y, series.y()) + + def point_at(self, index: int) -> QPointF: + return QPointF(self._x[index], self._y[index]) + + +class PlotController: + def __init__(self, widget: pg.PlotWidget) -> None: + self.widget = widget + self.widget.setBackground("#202025") + self.widget.setAxisItems({"bottom": DateAxisItem()}) + self.series = LineSeries() + self.crosshairs = Crosshairs(self.widget, self.series) + self.hide_irrelevant_plot_actions() + self.add_custom_menu_actions() + + def clear(self) -> None: + self.widget.clear() + + def draw_series(self) -> None: + self.widget.plot(self.series.x(), self.series.y(), pen=pg.mkPen("orange")) + self.widget.autoRange() + + def add_data(self, series: LineSeries) -> None: + self.series.extend(series) + + def hide_irrelevant_plot_actions(self) -> None: + for action in ("Transforms", "Downsample", "Average", "Alpha", "Points"): + self.widget.plotItem.setContextMenuActionVisible(action, visible=False) + + def add_custom_menu_actions(self) -> None: + viewbox = self.widget.plotItem.getViewBox() + if viewbox is None: + return + + menu = viewbox.getMenu(ev=None) + if menu is None: + return + + menu.addAction("Show/Hide crosshair", self.crosshairs.change_visibility) diff --git a/src/playercard/ratingtabwidget.py b/src/playercard/ratingtabwidget.py new file mode 100644 index 000000000..0e79db2b7 --- /dev/null +++ b/src/playercard/ratingtabwidget.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import pyqtgraph as pg +from PyQt6.QtCore import QDateTime +from PyQt6.QtCore import QObject +from PyQt6.QtCore import QPointF +from PyQt6.QtCore import Qt +from PyQt6.QtCore import QThread +from PyQt6.QtCore import pyqtSignal +from PyQt6.QtWidgets import QTabWidget + +from api.models.Leaderboard import Leaderboard +from api.stats_api import LeaderboardApiConnector +from api.stats_api import LeaderboardRatingJournalApiConnector +from model.rating import Rating +from playercard.plot import LineSeries +from playercard.plot import PlotController + + +class LineSeriesParser(QThread): + result_ready = pyqtSignal(LineSeries) + + def __init__(self, unparsed_api_response: dict) -> None: + QThread.__init__(self) + self.data = unparsed_api_response + + def run(self) -> None: + self.parse() + + def parse(self) -> None: + journal = self.data["data"] + journal_leng = len(journal) + + if journal_leng == 0: + self.result_ready.emit(LineSeries()) + return + + stats = self.data["included"] + stats_leng = len(stats) + + series = LineSeries(stats_leng) + + stats_index = journal_index = 0 + while stats_index < stats_leng and journal_index < journal_leng: + if ( + stats[stats_index]["id"] + != journal[journal_index]["relationships"]["gamePlayerStats"]["data"]["id"] + ): + journal_index += 1 + continue + + score_time_str = stats[stats_index]["attributes"]["scoreTime"] + score_time = QDateTime.fromString(score_time_str, Qt.DateFormat.ISODate) + # not creating additional objects (like Rating and QPointF) + # and not accessing their attributes in a loop will also give small + # improvement, but not quite noticeable (a few hundreds of a second + # per 10000 loop cycles -- ~10x less than API call deviation) + rating = Rating( + journal[journal_index]["attributes"]["meanAfter"], + journal[journal_index]["attributes"]["deviationAfter"], + ) + point = QPointF( + score_time.toSecsSinceEpoch(), + rating.displayed(), + ) + series.set_point(stats_index, point) + stats_index += 1 + journal_index += 1 + + self.result_ready.emit(series) + + +class RatingsPlotTab(QObject): + name_changed = pyqtSignal(int, str) + + def __init__( + self, + index: int, + player_id: str, + leaderboard: Leaderboard, + plot: PlotController, + ) -> None: + super().__init__() + self.index = index + self.player_id = player_id + self.leaderboard = leaderboard + self.ratings_history_api = LeaderboardRatingJournalApiConnector() + self.ratings_history_api.ratings_ready.connect(self.process_rating_history) + self.plot = plot + self._loaded = False + self.workers = [] + + def __del__(self) -> None: + try: + self.clear_threads() + except RuntimeError: + pass + + def enter(self) -> None: + if self._loaded: + return + self.name_changed.emit(self.index, "Loading...") + self.ratings_history_api.get_full_history(self.player_id, self.leaderboard.technical_name) + + def clear_threads(self) -> None: + for worker in self.workers: + if worker.isRunning(): + worker.quit() + self.workers.clear() + + def finish(self) -> None: + self.clear_threads() + self.plot.draw_series() + self.name_changed.emit(self.index, self.leaderboard.pretty_name) + + def process_rating_history(self, message: dict) -> None: + total_pages = message["meta"]["page"]["totalPages"] + current_page = message["meta"]["page"]["number"] + self._loaded = current_page == total_pages + + worker = LineSeriesParser(message) + self.workers.append(worker) + worker.result_ready.connect(self.data_parsed) + worker.start() + + def data_parsed(self, series: LineSeries) -> None: + self.plot.add_data(series) + if self._loaded: + self.finish() + + +class RatingTabWidgetController: + def __init__(self, player_id: str, tab_widget: QTabWidget) -> None: + self.player_id = player_id + self.widget = tab_widget + self.widget.currentChanged.connect(self.on_tab_changed) + + self.leaderboards_api = LeaderboardApiConnector() + self.leaderboards_api.data_ready.connect(self.populate_leaderboards) + self.tabs: dict[int, RatingsPlotTab] = {} + + def run(self) -> None: + self.leaderboards_api.requestData() + + def populate_leaderboards(self, message: dict[str, list[Leaderboard]]) -> None: + for index, leaderboard in enumerate(message["values"]): + widget = pg.PlotWidget() + tab = RatingsPlotTab(index, self.player_id, leaderboard, PlotController(widget)) + tab.name_changed.connect(self.widget.setTabText) + self.tabs[index] = tab + self.widget.insertTab(index, widget, leaderboard.pretty_name) + + def on_tab_changed(self, index: int) -> None: + self.tabs[index].enter() From 6e84baaf99b4c1fd6a565387fc29db7fa21b071b Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sun, 28 Jul 2024 06:01:52 +0300 Subject: [PATCH 58/73] Fix loading empty rating plots --- src/playercard/ratingtabwidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/playercard/ratingtabwidget.py b/src/playercard/ratingtabwidget.py index 0e79db2b7..070a23dd0 100644 --- a/src/playercard/ratingtabwidget.py +++ b/src/playercard/ratingtabwidget.py @@ -116,7 +116,7 @@ def finish(self) -> None: def process_rating_history(self, message: dict) -> None: total_pages = message["meta"]["page"]["totalPages"] current_page = message["meta"]["page"]["number"] - self._loaded = current_page == total_pages + self._loaded = current_page >= total_pages worker = LineSeriesParser(message) self.workers.append(worker) From 183d5fed9a3fddaf057aa215770e4a29044e536c Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 29 Jul 2024 04:08:07 +0300 Subject: [PATCH 59/73] Hide 'Export' action from the plots it requires additional libraries to be installed to work properly --- src/playercard/plot.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/playercard/plot.py b/src/playercard/plot.py index d612ee8d0..d05dd1584 100644 --- a/src/playercard/plot.py +++ b/src/playercard/plot.py @@ -168,6 +168,7 @@ def __init__(self, widget: pg.PlotWidget) -> None: self.widget.setAxisItems({"bottom": DateAxisItem()}) self.series = LineSeries() self.crosshairs = Crosshairs(self.widget, self.series) + self.hide_scene_actions() self.hide_irrelevant_plot_actions() self.add_custom_menu_actions() @@ -181,6 +182,12 @@ def draw_series(self) -> None: def add_data(self, series: LineSeries) -> None: self.series.extend(series) + def hide_scene_actions(self) -> None: + # hide the 'Export...' action + scene_menu = self.widget.scene().contextMenu + for action in scene_menu: + action.setVisible(False) + def hide_irrelevant_plot_actions(self) -> None: for action in ("Transforms", "Downsample", "Average", "Alpha", "Points"): self.widget.plotItem.setContextMenuActionVisible(action, visible=False) From e8f2bea333fa6d9d05407b28c10b8d10b7618845 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Mon, 29 Jul 2024 22:35:43 +0300 Subject: [PATCH 60/73] Fix utctolocal func and spread its usage a little --- src/playercard/playerinfodialog.py | 11 +++-------- src/stats/models/leaderboardtablemodel.py | 9 +++------ src/util/__init__.py | 2 +- 3 files changed, 7 insertions(+), 15 deletions(-) diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index a3f2e65fc..117433bbe 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -1,7 +1,5 @@ from __future__ import annotations -from PyQt6.QtCore import QDateTime -from PyQt6.QtCore import Qt from PyQt6.QtWidgets import QTableWidgetItem import util @@ -67,10 +65,8 @@ def process_player_ratings(self, ratings: dict[str, list[LeaderboardRating]]) -> def process_player(self, player: Player) -> None: self.nicknameLabel.setText(player.login) self.idLabel.setText(player.xd) - registered = QDateTime.fromString(player.create_time, Qt.DateFormat.ISODate).toLocalTime() - self.registeredLabel.setText(registered.toString("yyyy-MM-dd hh:mm")) - last_login = QDateTime.fromString(player.update_time, Qt.DateFormat.ISODate).toLocalTime() - self.lastLoginLabel.setText(last_login.toString("yyyy-MM-dd hh:mm")) + self.registeredLabel.setText(util.utctolocal(player.create_time)) + self.lastLoginLabel.setText(util.utctolocal(player.update_time)) self.add_avatars(player.avatar_assignments) self.add_names(player.names) @@ -80,8 +76,7 @@ def add_names(self, names: list[NameRecord] | None) -> None: self.nameHistoryTableWidget.setRowCount(len(names)) for row, name_record in enumerate(names): name = QTableWidgetItem(name_record.name) - change_time = QDateTime.fromString(name_record.change_time, Qt.DateFormat.ISODate) - used_until = QTableWidgetItem(change_time.toString("yyyy-MM-dd hh:mm")) + used_until = QTableWidgetItem(util.utctolocal(name_record.change_time)) self.nameHistoryTableWidget.setItem(row, 0, name) self.nameHistoryTableWidget.setItem(row, 1, used_until) diff --git a/src/stats/models/leaderboardtablemodel.py b/src/stats/models/leaderboardtablemodel.py index c01c95d06..968eeb563 100644 --- a/src/stats/models/leaderboardtablemodel.py +++ b/src/stats/models/leaderboardtablemodel.py @@ -1,8 +1,9 @@ from PyQt6.QtCore import QAbstractTableModel -from PyQt6.QtCore import QDateTime from PyQt6.QtCore import QModelIndex from PyQt6.QtCore import Qt +from util import utctolocal + class LeaderboardTableModel(QAbstractTableModel): def __init__(self, data=None): @@ -68,11 +69,7 @@ def data(self, index, role=Qt.ItemDataRole.DisplayRole): / self.values[row]["totalGames"], ) elif column == 7: - dateUTC = QDateTime.fromString( - self.values[row]["updateTime"], Qt.DateFormat.ISODate, - ) - dateLocal = dateUTC.toLocalTime().toString("yyyy-MM-dd") - return "{}".format(dateLocal) + return utctolocal(self.values[row]["updateTime"]) elif column == 8: return "{}".format(self.values[row]["player"]["id"]) diff --git a/src/util/__init__.py b/src/util/__init__.py index 62549a053..6745554c6 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -513,7 +513,7 @@ def strtodate(s: str) -> QDateTime: def datetostr(d: QDateTime) -> str: - return d.toString("yyyy-mm-dd HH:MM:ss") + return d.toString("yyyy-MM-dd hh:mm") def utctolocal(s: str) -> str: From 6116906cc012ba6acae06844bf0bceec24af5681 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Tue, 30 Jul 2024 21:49:36 +0300 Subject: [PATCH 61/73] Add achievements to player card --- res/client/client.css | 26 +++- res/player_card/achievement.png | Bin 0 -> 777 bytes res/player_card/achievement.ui | 147 +++++++++++++++++++++ res/player_card/playercard.ui | 139 +++++++++++++------- src/api/models/Achievement.py | 40 ++++++ src/api/models/PlayerAchievement.py | 17 +++ src/api/stats_api.py | 29 +++++ src/playercard/achievements.py | 191 ++++++++++++++++++++++++++++ src/playercard/playerinfodialog.py | 10 +- src/util/__init__.py | 12 ++ 10 files changed, 560 insertions(+), 51 deletions(-) create mode 100644 res/player_card/achievement.png create mode 100644 res/player_card/achievement.ui create mode 100644 src/api/models/Achievement.py create mode 100644 src/api/models/PlayerAchievement.py create mode 100644 src/playercard/achievements.py diff --git a/res/client/client.css b/res/client/client.css index a3213e7b6..58be94c25 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -177,7 +177,17 @@ QWidget#replayScore padding: 0px; } -QWidget#scrollAreaWidgetContents +QWidget#achievementWidget +{ + background-color: #202025; +} + +QWidget#hline +{ + background-color: #202025; +} + +QWidget#statisticsChartsScrollArea,#achievementsScrollArea { background-color: #383838; } @@ -1044,3 +1054,17 @@ QFrame#settingsFrame { { background-color: #202025; } + +QProgressBar#achievementBar +{ + background-color: black; + text-align: center; + max-height: 12px; + border: 1px solid black; +} + +QProgressBar#achievementBar::chunk +{ + background-color: orange; + +} diff --git a/res/player_card/achievement.png b/res/player_card/achievement.png new file mode 100644 index 0000000000000000000000000000000000000000..1d44cba1f7e0c5142c8a8641e9e8b0d568ad96d5 GIT binary patch literal 777 zcmV+k1NQuhP)rrMUCw1stD1 ze1|Bg8y`Rv72Nu-2+oKbgJhp84dueYYNyTg)UDg+gR?8rQ|DCO+chB-5JCtcL`X)! zZD0miWtsw$G>2-LI55%s017}o0QCS=f;!g7+LS{*00p2P zfO-HbK^<#kZOWk@fC5ktKs^AJppG@NHo55mdzlBWcl?00>2GXg#{Q&7-ew+pIx{|U z0B15|-}LA|_cITDkQpC2fJd3JJ3ac(rIrtQeLw-+Xc>E<&j5}ByDblYn6~Eco)9U3 z^)$EK%5U@oI6}|dbYO@%)iqYY7y3|jME!ITVsx@-Sp9m*%#U=Gyz)&azmmv|r4C!vx`~ z14FH&t`LZ3C4F>Ysx7DwUTXU?X-<**4$QR$v`tX%IxyG%p*sZT5b(r-$(GTx^wJQT z<^cEJf!Q{vcfckC;iR*s`~}!zU~Hx>U)ubqLt6Rsj8kJS@piPI8gBTSzX;pF=M|d& z2{>DN(%u*Wm(!&eeHs5f{n8}dA#0he0q23oOEkXuS?5%4x?@X{_)km$l*JT)0vI%= z9Uz1dLd3g=bQ2un@>d1D&!gzVwO zYePOE*Rb*}@(I~0RTxSeX$qjcnWi03-cVBss!L%gZKNrH@@ATLKzTz=C8#cip|p`U z^Z@$&s~77@%>^T2sPW6VTLfa1La1RUOe20BErbw42>#?3o@OFhx%?=T00000NkvXX Hu0mjf2OU_D literal 0 HcmV?d00001 diff --git a/res/player_card/achievement.ui b/res/player_card/achievement.ui new file mode 100644 index 000000000..890719d3e --- /dev/null +++ b/res/player_card/achievement.ui @@ -0,0 +1,147 @@ + + + Form + + + + 0 + 0 + 400 + 300 + + + + Form + + + + + + + 375 + 0 + + + + + + + + + 6 + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + 120 + 16777215 + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + + + 16777215 + 40 + + + + + 12 + true + + + + + + + Qt::AlignmentFlag::AlignCenter + + + true + + + + + + + + 16777215 + 40 + + + + + 10 + + + + + + + Qt::AlignmentFlag::AlignCenter + + + true + + + + + + + Qt::AlignmentFlag::AlignLeading|Qt::AlignmentFlag::AlignLeft|Qt::AlignmentFlag::AlignVCenter + + + + + + + + + + + + + + + + + diff --git a/res/player_card/playercard.ui b/res/player_card/playercard.ui index d7caae5b2..59b79c721 100644 --- a/res/player_card/playercard.ui +++ b/res/player_card/playercard.ui @@ -29,11 +29,11 @@ - + 0 - + 0 @@ -198,7 +198,7 @@ - + -1 @@ -216,7 +216,93 @@ - + + + Statistics + + + + + + true + + + + + 0 + 0 + 817 + 648 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + 10 + + + + + + + + + + + + Achievements + + + + + + true + + + + + 0 + 0 + 817 + 648 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + + + + + + Previous Names @@ -264,51 +350,6 @@ - - - Statistics - - - - - - true - - - - - 0 - 0 - 817 - 648 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - - - 10 - - - - - - - - - diff --git a/src/api/models/Achievement.py b/src/api/models/Achievement.py new file mode 100644 index 000000000..3b34ab374 --- /dev/null +++ b/src/api/models/Achievement.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from api.models.AbstractEntity import AbstractEntity +from pydantic import Field +from util import StringValuedEnum + + +class State(StringValuedEnum): + REVEALED = "REVEALED" + UNLOCKED = "UNLOCKED" + + +class ProgressType(StringValuedEnum): + STANDARD = "STANDARD" + INCREMENTAL = "INCREMENTAL" + + +class Achievement(AbstractEntity): + description: str + experience_points: int = Field(alias="experiencePoints") + initial_state: str = Field(alias="initialState") + name: str + order: int + revealed_icon_url: str = Field(alias="revealedIconUrl") + total_steps: int | None = Field(alias="totalSteps") + typ: str = Field(alias="type") + unlocked_icon_url: str = Field(alias="unlockedIconUrl") + unlockers_avg_duration: int | None = Field(alias="unlockersAvgDuration") + unlockers_count: int = Field(alias="unlockersCount") + unlockers_max_duration: int | None = Field(alias="unlockersMaxDuration") + unlockers_min_duration: int | None = Field(alias="unlockersMinDuration") + unlockers_percent: float = Field(alias="unlockersPercent") + + @property + def init_state(self) -> State: + return State.from_string(self.initial_state) + + @property + def progress_type(self) -> ProgressType: + return ProgressType.from_string(self.typ) diff --git a/src/api/models/PlayerAchievement.py b/src/api/models/PlayerAchievement.py new file mode 100644 index 000000000..479ea3fe4 --- /dev/null +++ b/src/api/models/PlayerAchievement.py @@ -0,0 +1,17 @@ +from api.models.AbstractEntity import AbstractEntity +from api.models.Achievement import Achievement +from api.models.Achievement import State +from api.models.Player import Player +from pydantic import Field + + +class PlayerAchievement(AbstractEntity): + current_steps: int | None = Field(alias="currentSteps") + state: str + + achievement: Achievement | None = Field(None) + player: Player | None = Field(None) + + @property + def current_state(self) -> State: + return State.from_string(self.state) diff --git a/src/api/stats_api.py b/src/api/stats_api.py index 23057ecdb..d0a2bd6db 100644 --- a/src/api/stats_api.py +++ b/src/api/stats_api.py @@ -1,4 +1,5 @@ import logging +from typing import Iterator from PyQt6.QtCore import QDateTime from PyQt6.QtCore import Qt @@ -6,8 +7,10 @@ from api.ApiAccessors import ApiAccessor from api.ApiAccessors import DataApiAccessor +from api.models.Achievement import Achievement from api.models.Leaderboard import Leaderboard from api.models.LeagueSeasonScore import LeagueSeasonScore +from api.models.PlayerAchievement import PlayerAchievement from api.models.PlayerEvent import PlayerEvent from api.parsers.LeaderboardParser import LeaderboardParser from api.parsers.LeaderboardRatingParser import LeaderboardRatingParser @@ -122,3 +125,29 @@ def get_player_events(self, player_id: str) -> None: def handle_player_events(self, message: dict) -> None: self.events_ready.emit([PlayerEvent(**entry) for entry in message["data"]]) + + +class PlayerAchievementApiAccessor(DataApiAccessor): + achievments_ready = pyqtSignal(object) + + def __init__(self) -> None: + super().__init__("/data/playerAchievement") + + def get_achievements(self, player_id: str | int) -> None: + query = { + "include": "achievement", + "filter": f"player.id=={player_id}", + "sort": "achievement.order", + } + self.get_by_query(query, self.handle_achievements) + + def handle_achievements(self, message: dict) -> None: + self.achievments_ready.emit((PlayerAchievement(**entry) for entry in message["data"])) + + +class AchievementsApiAccessor(DataApiAccessor): + def __init__(self) -> None: + super().__init__("/data/achievement") + + def prepare_data(self, message: dict) -> dict[str, Iterator[Achievement]]: + return {"values": (Achievement(**entry) for entry in message["data"])} diff --git a/src/playercard/achievements.py b/src/playercard/achievements.py new file mode 100644 index 000000000..b9613fa03 --- /dev/null +++ b/src/playercard/achievements.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import os +from itertools import batched +from typing import Iterator +from typing import NamedTuple + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPixmap +from PyQt6.QtWidgets import QGridLayout +from PyQt6.QtWidgets import QLabel +from PyQt6.QtWidgets import QLayout +from PyQt6.QtWidgets import QProgressBar +from PyQt6.QtWidgets import QSizePolicy +from PyQt6.QtWidgets import QWidget + +from api.models.Achievement import Achievement +from api.models.Achievement import ProgressType +from api.models.Achievement import State +from api.models.PlayerAchievement import PlayerAchievement +from api.stats_api import AchievementsApiAccessor +from api.stats_api import PlayerAchievementApiAccessor +from downloadManager import Downloader +from downloadManager import DownloadRequest +from util import CACHE_DIR +from util import THEME + +FormClass, BaseClass = THEME.loadUiType("player_card/achievement.ui") + + +class AchievementWidget(FormClass, BaseClass): + def __init__(self, player_achievement: PlayerAchievement, img_dler: Downloader) -> None: + BaseClass.__init__(self) + self.setupUi(self) + + self.player_achievement = player_achievement + self.achievement = player_achievement.achievement + self.img_dler = img_dler + self.img_dl_request = DownloadRequest() + self.img_dl_request.done.connect(self.on_icon_downloaded) + + def setup(self) -> None: + self.achievementNameLabel.setText(self.achievement.name) + self.achievementDescLabel.setText(self.achievement.description) + + self.add_progress_if_present() + + self.detailsLayout.addRow( + QLabel("Unlockers:"), + QLabel(f"{self.achievement.unlockers_count} ({self.achievement.unlockers_percent}%)"), + ) + self.detailsLayout.addRow( + QLabel("Experience points"), + QLabel(str(self.achievement.experience_points)), + ) + + self.add_achievement_image() + + def add_progress_if_present(self) -> None: + if self.achievement.progress_type != ProgressType.INCREMENTAL: + return + + bar = QProgressBar() + bar.setObjectName("achievementBar") + bar.setMaximum(self.achievement.total_steps) + bar.setValue(self.player_achievement.current_steps) + bar.setFormat("%v/%m") + self.detailsLayout.addRow(bar) + + def add_achievement_image(self) -> None: + image_name = os.path.basename(self.achievement.revealed_icon_url) + image_path = os.path.join(CACHE_DIR, "achievements", "revealed", image_name) + if os.path.isfile(image_path): + self.set_icon(image_path) + else: + self.download_icon(self.achievement.revealed_icon_url) + + def icon(self, icon_path: str = "") -> QPixmap: + if os.path.isfile(icon_path): + return QPixmap(icon_path) + else: + return THEME.pixmap("player_card/achievement.png") + + def set_icon(self, icon_path: str) -> None: + self.iconLabel.setPixmap(self.icon(icon_path).scaled(128, 128)) + + def on_icon_downloaded(self, _: str, result: tuple[str, bool]) -> None: + icon_path, download_failed = result + if not download_failed: + self.set_icon(icon_path) + + def download_icon(self, url: str) -> None: + name = os.path.basename(url) + self.img_dler.download(name, self.img_dl_request, url) + + +class AchievementsHandler: + def __init__(self, layout: QLayout, player_id: str) -> None: + self.player_id = player_id + self.layout = layout + self.player_achievements_api = PlayerAchievementApiAccessor() + self.player_achievements_api.achievments_ready.connect(self.on_player_achievements_ready) + + self.achievements_api = AchievementsApiAccessor() + self.achievements_api.data_ready.connect(self.on_achievements_ready) + self.img_dler = Downloader(os.path.join(CACHE_DIR, "achievements", "revealed")) + self.all_achievements = [] + self._loaded = False + + def run(self) -> None: + if not self._loaded: + self.achievements_api.requestData() + + def on_achievements_ready(self, achievements: dict[str, Iterator[Achievement]]) -> None: + for achievement in achievements["values"]: + self.all_achievements.append(achievement) + self.player_achievements_api.get_achievements(self.player_id) + + def mock_player_achievement(self, achievement: Achievement) -> PlayerAchievement: + return PlayerAchievement( + id="0", + create_time="", + update_time="", + current_steps=(None, 0)[achievement.progress_type == ProgressType.INCREMENTAL], + state=State.REVEALED, + achievement=achievement, + ) + + def create_group_title_label(self, text: str) -> QLabel: + label = QLabel(text) + font = label.font() + font.setPointSize(font.pointSize() + 8) + font.setBold(True) + label.setFont(font) + label.setAlignment(Qt.AlignmentFlag.AlignCenter) + return label + + def create_hline(self) -> QWidget: + hline = QWidget() + hline.setObjectName("hline") + hline.setFixedHeight(2) + hline.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + return hline + + def create_achievement_group_layout(self, group: list[PlayerAchievement]) -> QGridLayout: + layout = QGridLayout() + for row, batch in enumerate(batched(group, 2)): + for column, player_achievement in enumerate(batch): + widget = AchievementWidget(player_achievement, self.img_dler) + widget.setup() + layout.addWidget(widget, row, column) + return layout + + def add_achievement_group(self, name: str, group: list[PlayerAchievement]) -> None: + label = self.create_group_title_label(f"{name} ({len(group)})") + self.layout.addWidget(label) + self.layout.addWidget(self.create_hline()) + group_layout = self.create_achievement_group_layout(group) + self.layout.addLayout(group_layout) + + def group_achievements( + self, + player_achievements: Iterator[PlayerAchievement], + ) -> AchievementGroup: + unlocked, locked, included_ids = [], [], [] + for player_achievement in player_achievements: + included_ids.append(player_achievement.achievement.xd) + if player_achievement.current_state == State.UNLOCKED: + unlocked.append(player_achievement) + else: + locked.append(player_achievement) + locked.extend(( + self.mock_player_achievement(entry) + for entry in self.all_achievements + if entry.xd not in included_ids + )) + return AchievementGroup(locked, unlocked) + + def on_player_achievements_ready( + self, + player_achievements: Iterator[PlayerAchievement], + ) -> None: + locked, unlocked = self.group_achievements(player_achievements) + self.add_achievement_group("Unlocked", unlocked) + self.add_achievement_group("Locked", locked) + self._loaded = True + + +class AchievementGroup(NamedTuple): + locked: list[PlayerAchievement] + unlocked: list[PlayerAchievement] diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 117433bbe..814970705 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -13,6 +13,7 @@ from api.stats_api import LeagueSeasonScoreApiConnector from api.stats_api import PlayerEventApiAccessor from downloadManager import AvatarDownloader +from playercard.achievements import AchievementsHandler from playercard.avatarhandler import AvatarHandler from playercard.leagueformatter import league_formatter_factory from playercard.ratingtabwidget import RatingTabWidgetController @@ -27,7 +28,8 @@ def __init__(self, avatar_dler: AvatarDownloader, player_id: str) -> None: self.setupUi(self) self.load_stylesheet() - self.tab_widget_ctrl = RatingTabWidgetController(player_id, self.tabWidget) + self.mainTabWidget.currentChanged.connect(self.on_tab_changed) + self.tab_widget_ctrl = RatingTabWidgetController(player_id, self.ratingsTabWidget) self.avatar_handler = AvatarHandler(self.avatarList, avatar_dler) self.player_id = player_id @@ -45,6 +47,8 @@ def __init__(self, avatar_dler: AvatarDownloader, player_id: str) -> None: self.stats_charts = StatsCharts() + self.achievements_handler = AchievementsHandler(self.verticalLayout_2, self.player_id) + def load_stylesheet(self) -> None: self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) @@ -55,6 +59,10 @@ def run(self) -> None: self.tab_widget_ctrl.run() self.exec() + def on_tab_changed(self, index: int) -> None: + if self.mainTabWidget.currentWidget() == self.achievementsTab: + self.achievements_handler.run() + def process_player_ratings(self, ratings: dict[str, list[LeaderboardRating]]) -> None: for rating in ratings["values"]: widget = league_formatter_factory(self.player_id, rating, self.leagues_api) diff --git a/src/util/__init__.py b/src/util/__init__.py index 6745554c6..1daf13549 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -7,6 +7,8 @@ import shutil import subprocess import sys +from enum import Enum +from typing import Self from PyQt6 import QtWidgets from PyQt6.QtCore import QDateTime @@ -525,3 +527,13 @@ def capitalize(string: str) -> str: Capitalize the first letter only, leave the rest as it is """ return f"{string[0].upper()}{string[1:]}" + + +class StringValuedEnum(Enum): + + @classmethod + def from_string(cls, string: str) -> Self: + for member in iter(cls): + if member.value == string: + return member + raise ValueError("Unsupported value") From 29c2be8e908939362245cf5e8774103ad88e1842 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 31 Jul 2024 04:00:17 +0300 Subject: [PATCH 62/73] Playercard: Set player's login as window title --- src/playercard/playerinfodialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 814970705..5ec6076e2 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -71,6 +71,7 @@ def process_player_ratings(self, ratings: dict[str, list[LeaderboardRating]]) -> self.statsChartsLayout.addWidget(pie_chart) def process_player(self, player: Player) -> None: + self.setWindowTitle(player.login) self.nicknameLabel.setText(player.login) self.idLabel.setText(player.xd) self.registeredLabel.setText(util.utctolocal(player.create_time)) From 9b0ddf56b2874f9a8372e2a45c6b668116fbb5b2 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Wed, 31 Jul 2024 23:49:01 +0300 Subject: [PATCH 63/73] Turn AvatarDownloader into ImageDownloader --- src/chat/_avatarWidget.py | 4 +-- src/chat/channel_view.py | 4 +-- src/chat/chatter_model.py | 7 ++-- src/client/_clientwindow.py | 4 +-- src/downloadManager/__init__.py | 54 +++++++++++++++++------------- src/playercard/achievements.py | 39 ++++++++++----------- src/playercard/avatarhandler.py | 10 +++--- src/playercard/leagueformatter.py | 45 ++++++++++++------------- src/playercard/playerinfodialog.py | 4 +-- 9 files changed, 84 insertions(+), 87 deletions(-) diff --git a/src/chat/_avatarWidget.py b/src/chat/_avatarWidget.py index 486339d34..6efe0d306 100644 --- a/src/chat/_avatarWidget.py +++ b/src/chat/_avatarWidget.py @@ -62,7 +62,7 @@ def select_avatar(self, val): }) self.base.close() - def set_avatar_list(self, avatars): + def set_avatar_list(self, avatars: list[dict]) -> None: self.avatar_list.clear() self._add_avatar_item(None) @@ -70,7 +70,7 @@ def set_avatar_list(self, avatars): self._add_avatar_item(avatar) url = avatar["url"] avatar_name = QUrl(url).fileName() - icon = self._avatar_dler.avatars.get(avatar_name, None) + icon = self._avatar_dler.get_image(avatar_name) if icon is not None: self._set_avatar_icon(url, icon) else: diff --git a/src/chat/channel_view.py b/src/chat/channel_view.py index 02b3eccdd..e1e1e85dc 100644 --- a/src/chat/channel_view.py +++ b/src/chat/channel_view.py @@ -175,9 +175,9 @@ def __init__(self, widget, avatar_dler): def build(cls, widget, avatar_dler, **kwargs): return cls(widget, avatar_dler) - def add_avatar(self, url): + def add_avatar(self, url: str) -> None: avatar_name = QUrl(url).fileName() - avatar_pix = self._avatar_dler.avatars.get(avatar_name, None) + avatar_pix = self._avatar_dler.get_image(avatar_name) if avatar_pix is not None: self._add_avatar_resource(url, avatar_pix) elif url not in self._requests: diff --git a/src/chat/chatter_model.py b/src/chat/chatter_model.py index 2f436af75..3732febab 100644 --- a/src/chat/chatter_model.py +++ b/src/chat/chatter_model.py @@ -219,14 +219,13 @@ def chatter_rank(self, data): return "newplayer" return league["league"] - def chatter_avatar_icon(self, data): + def chatter_avatar_icon(self, data: ChatterModelItem) -> QIcon | None: avatar_url = data.avatar_url() avatar_name = QtCore.QUrl(avatar_url).fileName() if avatar_url is None: return None - if avatar_name not in self._avatars.avatars: - return - return QIcon(self._avatars.avatars[avatar_name]) + if (pixmap := self._avatars.get_image(avatar_name)) is not None: + return QIcon(pixmap) def chatter_country(self, data): if data.player is None: diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index a03a44a6d..02f9cbcc2 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -44,7 +44,7 @@ from connectivity.ConnectivityDialog import ConnectivityDialog from contextmenu.playercontextmenu import PlayerContextMenu from coop import CoopWidget -from downloadManager import AvatarDownloader +from downloadManager import ImageDownloader from downloadManager import MapSmallPreviewDownloader from fa.factions import Factions from fa.game_runner import GameRunner @@ -235,7 +235,7 @@ def __init__(self, *args, **kwargs): ) self.map_preview_downloader = MapSmallPreviewDownloader(util.MAP_PREVIEW_SMALL_DIR) - self.avatar_downloader = AvatarDownloader() + self.avatar_downloader = ImageDownloader() # Map generator self.map_generator = MapGeneratorManager() diff --git a/src/downloadManager/__init__.py b/src/downloadManager/__init__.py index 7e3387e53..a9975567f 100644 --- a/src/downloadManager/__init__.py +++ b/src/downloadManager/__init__.py @@ -10,6 +10,7 @@ from PyQt6.QtCore import QFile from PyQt6.QtCore import QIODevice from PyQt6.QtCore import QObject +from PyQt6.QtCore import QSize from PyQt6.QtCore import QTimer from PyQt6.QtCore import QUrl from PyQt6.QtCore import pyqtSignal @@ -19,7 +20,6 @@ from PyQt6.QtNetwork import QNetworkRequest from config import Settings -from qt.utils import qopen from util import AVATARS_CACHE_DIR logger = logging.getLogger(__name__) @@ -417,35 +417,37 @@ def _clear_timeouts(self): self._timed_out_items.clear() -class AvatarDownloader: - def __init__(self): +class ImageDownloader: + def __init__(self, cache_dir: str = AVATARS_CACHE_DIR, size: QSize | None = None) -> None: + self._size = size self._nam = QNetworkAccessManager() self._requests = {} - self.avatars = {} - self._nam.finished.connect(self._avatar_download_finished) - self.cache_dir = AVATARS_CACHE_DIR + self.images = {} + self._nam.finished.connect(self._image_download_finished) + self.cache_dir = cache_dir self.load_cache() def load_cache(self) -> None: for filename in os.listdir(self.cache_dir): filepath = os.path.join(self.cache_dir, filename) - self.avatars[filename] = QPixmap(filepath) + pix = QPixmap(filepath) + self.images[filename] = pix if self._size is None else pix.scaled(self._size) - def avatar_name(self, url: QUrl | str) -> str: + def image_name(self, url: QUrl | str) -> str: return QUrl(url).fileName(QUrl.ComponentFormattingOption.EncodeSpaces) - def has_avatar(self, name_or_url: str) -> bool: - return self.get_avatar(name_or_url) is not None + def has_image(self, name_or_url: QUrl | str) -> bool: + return self.get_image(name_or_url) is not None - def get_avatar(self, name_or_url: str) -> QPixmap: - return self.avatars.get(self.avatar_name(name_or_url), None) + def get_image(self, name_or_url: QUrl | str) -> QPixmap | None: + return self.images.get(self.image_name(name_or_url)) def download_if_needed(self, url: str | None, req: DownloadRequest) -> None: - if url is None or self.has_avatar(url): + if url is None or self.has_image(url): return - self.download_avatar(url, req) + self.download_image(url, req) - def download_avatar(self, url, req): + def download_image(self, url: str, req: DownloadRequest) -> None: self._add_request(url, req) def _add_request(self, url, req): @@ -454,20 +456,24 @@ def _add_request(self, url, req): if should_download: self._nam.get(QNetworkRequest(QUrl(url))) - def _avatar_download_finished(self, reply: QNetworkReply) -> None: + def _image_download_finished(self, reply: QNetworkReply) -> None: url_str = reply.url().toString() - avatar_name = self.avatar_name(reply.url()) - avatar_path = self._save_avatar_to_cache(avatar_name, reply.readAll()) + avatar_name = self.image_name(reply.url()) + avatar_path = self._save_image_to_cache(avatar_name, reply.readAll()) - if avatar_name not in self.avatars: - self.avatars[avatar_name] = QPixmap(avatar_path) + if avatar_name not in self.images: + self.images[avatar_name] = QPixmap(avatar_path) reqs = self._requests.pop(url_str, []) for req in reqs: - req.finished(url_str, self.avatars[avatar_name]) + req.finished(url_str, self.images[avatar_name]) - def _save_avatar_to_cache(self, name: str, qbytes: QByteArray) -> str: + def _save_image_to_cache(self, name: str, qbytes: QByteArray) -> str: filepath = os.path.join(self.cache_dir, name) - with qopen(filepath, QFile.OpenModeFlag.WriteOnly) as file: - file.write(qbytes.data()) + pixmap = QPixmap() + pixmap.loadFromData(qbytes) + if self._size is not None: + pixmap.scaled(self._size).save(filepath) + else: + pixmap.save(filepath) return filepath diff --git a/src/playercard/achievements.py b/src/playercard/achievements.py index b9613fa03..68af5ce60 100644 --- a/src/playercard/achievements.py +++ b/src/playercard/achievements.py @@ -5,6 +5,7 @@ from typing import Iterator from typing import NamedTuple +from PyQt6.QtCore import QSize from PyQt6.QtCore import Qt from PyQt6.QtGui import QPixmap from PyQt6.QtWidgets import QGridLayout @@ -20,8 +21,8 @@ from api.models.PlayerAchievement import PlayerAchievement from api.stats_api import AchievementsApiAccessor from api.stats_api import PlayerAchievementApiAccessor -from downloadManager import Downloader from downloadManager import DownloadRequest +from downloadManager import ImageDownloader from util import CACHE_DIR from util import THEME @@ -29,7 +30,7 @@ class AchievementWidget(FormClass, BaseClass): - def __init__(self, player_achievement: PlayerAchievement, img_dler: Downloader) -> None: + def __init__(self, player_achievement: PlayerAchievement, img_dler: ImageDownloader) -> None: BaseClass.__init__(self) self.setupUi(self) @@ -69,29 +70,22 @@ def add_progress_if_present(self) -> None: def add_achievement_image(self) -> None: image_name = os.path.basename(self.achievement.revealed_icon_url) - image_path = os.path.join(CACHE_DIR, "achievements", "revealed", image_name) - if os.path.isfile(image_path): - self.set_icon(image_path) - else: - self.download_icon(self.achievement.revealed_icon_url) + self.set_icon(self.icon(image_name)) + self.download_icon_if_needed(self.achievement.revealed_icon_url) - def icon(self, icon_path: str = "") -> QPixmap: - if os.path.isfile(icon_path): - return QPixmap(icon_path) - else: - return THEME.pixmap("player_card/achievement.png") + def icon(self, icon_name: str = "") -> QPixmap: + if (pixmap := self.img_dler.get_image(icon_name)) is not None: + return pixmap + return THEME.pixmap("player_card/achievement.png") - def set_icon(self, icon_path: str) -> None: - self.iconLabel.setPixmap(self.icon(icon_path).scaled(128, 128)) + def set_icon(self, pixmap: QPixmap) -> None: + self.iconLabel.setPixmap(pixmap) - def on_icon_downloaded(self, _: str, result: tuple[str, bool]) -> None: - icon_path, download_failed = result - if not download_failed: - self.set_icon(icon_path) + def on_icon_downloaded(self, _: str, pixmap: QPixmap) -> None: + self.set_icon(pixmap) - def download_icon(self, url: str) -> None: - name = os.path.basename(url) - self.img_dler.download(name, self.img_dl_request, url) + def download_icon_if_needed(self, url: str) -> None: + self.img_dler.download_if_needed(url, self.img_dl_request) class AchievementsHandler: @@ -103,7 +97,8 @@ def __init__(self, layout: QLayout, player_id: str) -> None: self.achievements_api = AchievementsApiAccessor() self.achievements_api.data_ready.connect(self.on_achievements_ready) - self.img_dler = Downloader(os.path.join(CACHE_DIR, "achievements", "revealed")) + self.cache_dir = os.path.join(CACHE_DIR, "achievements", "revealed") + self.img_dler = ImageDownloader(self.cache_dir, QSize(128, 128)) self.all_achievements = [] self._loaded = False diff --git a/src/playercard/avatarhandler.py b/src/playercard/avatarhandler.py index ac33ce667..133dbfe4b 100644 --- a/src/playercard/avatarhandler.py +++ b/src/playercard/avatarhandler.py @@ -8,12 +8,12 @@ import util from api.models.Avatar import Avatar from api.models.AvatarAssignment import AvatarAssignment -from downloadManager import AvatarDownloader from downloadManager import DownloadRequest +from downloadManager import ImageDownloader class AvatarHandler: - def __init__(self, avatar_list: QListWidget, avatar_downloader: AvatarDownloader) -> None: + def __init__(self, avatar_list: QListWidget, avatar_downloader: ImageDownloader) -> None: self.avatar_list = avatar_list self.avatar_dler = avatar_downloader self.requests = {} @@ -23,7 +23,7 @@ def populate_avatars(self, avatar_assignments: list[AvatarAssignment] | None) -> return for assignment in avatar_assignments: - if self.avatar_dler.has_avatar(assignment.avatar.filename): + if self.avatar_dler.has_image(assignment.avatar.filename): self._add_avatar(assignment.avatar) else: self._download_avatar(assignment.avatar) @@ -36,10 +36,10 @@ def _prepare_avatar_dl_request(self, avatar: Avatar) -> DownloadRequest: def _download_avatar(self, avatar: Avatar) -> None: req = self._prepare_avatar_dl_request(avatar) - self.avatar_dler.download_avatar(avatar.url, req) + self.avatar_dler.download_image(avatar.url, req) def _add_avatar(self, avatar: Avatar) -> None: - self._add_avatar_item(self.avatar_dler.get_avatar(avatar.filename), avatar.tooltip) + self._add_avatar_item(self.avatar_dler.get_image(avatar.filename), avatar.tooltip) def _add_avatar_item(self, pixmap: QPixmap, description: str) -> None: if pixmap.isNull(): diff --git a/src/playercard/leagueformatter.py b/src/playercard/leagueformatter.py index 8e7af3e90..4e6526adf 100644 --- a/src/playercard/leagueformatter.py +++ b/src/playercard/leagueformatter.py @@ -1,13 +1,14 @@ import os +from PyQt6.QtCore import QSize from PyQt6.QtGui import QPixmap import util from api.models.LeaderboardRating import LeaderboardRating from api.models.LeagueSeasonScore import LeagueSeasonScore from api.stats_api import LeagueSeasonScoreApiConnector -from downloadManager import Downloader from downloadManager import DownloadRequest +from downloadManager import ImageDownloader FormClass, BaseClass = util.THEME.loadUiType("player_card/playerleague.ui") @@ -30,7 +31,7 @@ def __init__( self.league_score_api = league_score_api self.league_score_api.score_ready.connect(self.on_league_score_ready) - self._downloader = Downloader(util.DIVISIONS_CACHE_DIR) + self._downloader = ImageDownloader(util.DIVISIONS_CACHE_DIR, QSize(160, 80)) self._images_dl_request = DownloadRequest() self._images_dl_request.done.connect(self.on_image_downloaded) @@ -41,7 +42,7 @@ def load_stylesheet(self) -> None: self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) def default_pixmap(self) -> QPixmap: - return util.THEME.pixmap("player_card/unlisted.png") + return util.THEME.pixmap("player_card/unlisted.png").scaled(80, 80) def default_league(self) -> str: return "Unlisted" @@ -52,7 +53,7 @@ def rating_text(self) -> str: def fill_ui(self) -> None: self.divisionLabel.setText(self.default_league()) - self.set_league_icon() + self.set_league_icon(self.default_pixmap()) self.gamesLabel.setText(f"{self.rating.total_games:.0f} Games") self.ratingLabel.setText(self.rating_text()) self.leaderboardLabel.setText(self.leaderboard.pretty_name) @@ -74,26 +75,22 @@ def on_league_score_ready(self, score: LeagueSeasonScore) -> None: self.divisionLabel.setText(league_name) image_name = os.path.basename(subdivision.image_url) - image_path = os.path.join(util.CACHE_DIR, "divisions", image_name) - if os.path.isfile(image_path): - self.set_league_icon(image_path) - else: - self.download_league_icon(subdivision.image_url) - - def set_league_icon(self, image_path: str = "") -> None: - if os.path.isfile(image_path): - self.iconLabel.setPixmap(QPixmap(image_path).scaled(160, 80)) - else: - self.iconLabel.setPixmap(self.default_pixmap().scaled(80, 80)) - - def download_league_icon(self, url: str) -> None: - name = os.path.basename(url) - self._downloader.download(name, self._images_dl_request, url) - - def on_image_downloaded(self, _: str, result: tuple[str, bool]) -> None: - image_path, download_failed = result - if not download_failed: - self.set_league_icon(image_path) + self.set_league_icon(self.icon(image_name)) + self.download_league_icon_if_needed(subdivision.image_url) + + def icon(self, image_name: str = "") -> QPixmap: + if (pixmap := self._downloader.get_image(image_name)) is not None: + return pixmap + return self.default_pixmap() + + def set_league_icon(self, pixmap: QPixmap) -> None: + self.iconLabel.setPixmap(pixmap) + + def download_league_icon_if_needed(self, url: str) -> None: + self._downloader.download_if_needed(url, self._images_dl_request) + + def on_image_downloaded(self, _: str, pixmap: QPixmap) -> None: + self.set_league_icon(pixmap) class GlobalLeagueFormatter(LeagueFormatter): diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 5ec6076e2..cd2f056d1 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -12,7 +12,7 @@ from api.stats_api import LeaderboardRatingApiConnector from api.stats_api import LeagueSeasonScoreApiConnector from api.stats_api import PlayerEventApiAccessor -from downloadManager import AvatarDownloader +from downloadManager import ImageDownloader from playercard.achievements import AchievementsHandler from playercard.avatarhandler import AvatarHandler from playercard.leagueformatter import league_formatter_factory @@ -23,7 +23,7 @@ class PlayerInfoDialog(FormClass, BaseClass): - def __init__(self, avatar_dler: AvatarDownloader, player_id: str) -> None: + def __init__(self, avatar_dler: ImageDownloader, player_id: str) -> None: BaseClass.__init__(self) self.setupUi(self) self.load_stylesheet() From 92d3f25c424015cb08806abbc7aac02e6ba6fc8b Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 1 Aug 2024 00:03:30 +0300 Subject: [PATCH 64/73] Ensure avatars dir --- src/playercard/achievements.py | 5 ++--- src/util/__init__.py | 5 ++++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/playercard/achievements.py b/src/playercard/achievements.py index 68af5ce60..e1b36a174 100644 --- a/src/playercard/achievements.py +++ b/src/playercard/achievements.py @@ -23,7 +23,7 @@ from api.stats_api import PlayerAchievementApiAccessor from downloadManager import DownloadRequest from downloadManager import ImageDownloader -from util import CACHE_DIR +from util import ACHIEVEMENTS_CACHE_DIR from util import THEME FormClass, BaseClass = THEME.loadUiType("player_card/achievement.ui") @@ -97,8 +97,7 @@ def __init__(self, layout: QLayout, player_id: str) -> None: self.achievements_api = AchievementsApiAccessor() self.achievements_api.data_ready.connect(self.on_achievements_ready) - self.cache_dir = os.path.join(CACHE_DIR, "achievements", "revealed") - self.img_dler = ImageDownloader(self.cache_dir, QSize(128, 128)) + self.img_dler = ImageDownloader(ACHIEVEMENTS_CACHE_DIR, QSize(128, 128)) self.all_achievements = [] self._loaded = False diff --git a/src/util/__init__.py b/src/util/__init__.py index 1daf13549..2265a398c 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -68,6 +68,9 @@ # Cache for league division images DIVISIONS_CACHE_DIR = os.path.join(CACHE_DIR, "divisions") +# Cache for achievement images +ACHIEVEMENTS_CACHE_DIR = os.path.join(CACHE_DIR, "achievements", "revealed") + # This contains cached game files GAME_CACHE_DIR = os.path.join(CACHE_DIR, "featured_mod") @@ -177,7 +180,7 @@ def setPersonalDir(): MAP_PREVIEW_SMALL_DIR, MAP_PREVIEW_LARGE_DIR, MOD_PREVIEW_DIR, THEME_DIR, REPLAY_DIR, LOG_DIR, EXTRA_DIR, NEWS_CACHE_DIR, GAME_CACHE_DIR, GAMEDATA_DIR, BIN_DIR, REPLAY_DIR, AVATARS_CACHE_DIR, - DIVISIONS_CACHE_DIR, + DIVISIONS_CACHE_DIR, ACHIEVEMENTS_CACHE_DIR, ]: if not os.path.isdir(data_dir): os.makedirs(data_dir) From 31cf80031694eea01995c898d5ba004f179602b9 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Thu, 1 Aug 2024 21:39:13 +0300 Subject: [PATCH 65/73] Fix achievement bar's background so black numbers are visible on it --- res/client/client.css | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/res/client/client.css b/res/client/client.css index 58be94c25..5318bce7a 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -1057,7 +1057,7 @@ QFrame#settingsFrame { QProgressBar#achievementBar { - background-color: black; + background-color: gray; text-align: center; max-height: 12px; border: 1px solid black; @@ -1066,5 +1066,4 @@ QProgressBar#achievementBar QProgressBar#achievementBar::chunk { background-color: orange; - } From ff6921fd791e63347e003b082f54e2f08ce3324f Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 3 Aug 2024 09:44:45 +0300 Subject: [PATCH 66/73] Fix fields alignment in Achievement model --- src/api/models/Achievement.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/api/models/Achievement.py b/src/api/models/Achievement.py index 3b34ab374..0f8f5bba5 100644 --- a/src/api/models/Achievement.py +++ b/src/api/models/Achievement.py @@ -25,10 +25,10 @@ class Achievement(AbstractEntity): total_steps: int | None = Field(alias="totalSteps") typ: str = Field(alias="type") unlocked_icon_url: str = Field(alias="unlockedIconUrl") - unlockers_avg_duration: int | None = Field(alias="unlockersAvgDuration") - unlockers_count: int = Field(alias="unlockersCount") - unlockers_max_duration: int | None = Field(alias="unlockersMaxDuration") - unlockers_min_duration: int | None = Field(alias="unlockersMinDuration") + unlockers_avg_duration: int | None = Field(alias="unlockersAvgDuration") + unlockers_count: int | None = Field(alias="unlockersCount") + unlockers_max_duration: int | None = Field(alias="unlockersMaxDuration") + unlockers_min_duration: int | None = Field(alias="unlockersMinDuration") unlockers_percent: float = Field(alias="unlockersPercent") @property From 1faec4aff6dc17977b78a9dbf90290df033e2415 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 3 Aug 2024 09:51:24 +0300 Subject: [PATCH 67/73] Remove obsolete argument from view_replays method since a7197c7e9a8cdc0bb5f0120bd0597a35263d64b9 view_replays uses only player's login to search --- src/client/_clientwindow.py | 4 ++-- src/replays/_replayswidget.py | 8 ++------ 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 02f9cbcc2..fbb0862e8 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -1667,8 +1667,8 @@ def vault_tab_changed(self, curr): self.tab_changed(self.topTabs, curr, self._vault_tab) self._vault_tab = curr - def view_replays(self, name, leaderboardName=None): - self.replays.set_player(name, leaderboardName) + def view_replays(self, name: str) -> None: + self.replays.set_player(name) self.mainTabs.setCurrentIndex(self.mainTabs.indexOf(self.replaysTab)) def manage_power(self): diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index 953ae93e2..1871abc72 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -1102,13 +1102,9 @@ def __init__(self, client, dispatcher, gameset, playerset): def refresh_leaderboards(self) -> None: self.vaultManager.refresh_leaderboards() - def set_player(self, name: str, leaderboard_name: str | None = None) -> None: + def set_player(self, name: str) -> None: self.setCurrentIndex(2) # focus on Online Fault - if leaderboard_name is not None: - item_index = self.leaderboardList.findData(leaderboard_name) - self.vaultManager.searchVault(0, "", name, item_index, 0, 100, exactPlayerName=True) - else: - self.vaultManager.searchVault(0, "", name, 0, 0, 100, exactPlayerName=True) + self.vaultManager.searchVault(0, "", name, 0, 0, 100, exactPlayerName=True) def focusEvent(self, event): self.localManager.updatemyTree() From 5a493c7e29aa791be211b6afc67d686691488724 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 3 Aug 2024 20:46:34 +0300 Subject: [PATCH 68/73] Allow individual override of ingame notification settings which allows to disable ingame notification for everything except the GAME_FULL notification * apply common enabling/disabling rules to party invite notification * fix NEW_GAME notification --- src/notifications/__init__.py | 65 ++++++++++++++++---------------- src/notifications/ns_hook.py | 27 +++++++------ src/notifications/ns_settings.py | 30 ++++++++++++--- 3 files changed, 73 insertions(+), 49 deletions(-) diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py index 590b936cc..4a03a93e2 100644 --- a/src/notifications/__init__.py +++ b/src/notifications/__init__.py @@ -7,6 +7,7 @@ import util from config import Settings from fa import maps +from model.game import Game from model.player import Player from notifications.ns_dialog import NotificationDialog from notifications.ns_settings import IngameNotification @@ -44,10 +45,7 @@ def __init__(self, client, gameset, playerset, me): self.user = util.THEME.icon("client/user.png", pix=True) def _newPlayer(self, player: Player) -> None: - if ( - self.isDisabled() - or not self.settings.popupEnabled(self.USER_ONLINE) - ): + if self.is_disabled(self.USER_ONLINE): return if self.me.player is not None and self.me.player == player: @@ -63,21 +61,21 @@ def _newPlayer(self, player: Player) -> None: self.events.append((self.USER_ONLINE, player.copy())) self.checkEvent() - def _newLobby(self, game): - if self.isDisabled() or not self.settings.popupEnabled(self.NEW_GAME): + def _newLobby(self, game: Game) -> None: + if self.is_disabled(self.NEW_GAME): return host = game.host_player notify_mode = self.settings.getCustomSetting(self.NEW_GAME, 'mode') if notify_mode != 'all': - if host is None or not self.client.user_relations.model.is_friend(host): + if host is None or not self.client.user_relations.model.is_friend(host.id, host.login): return self.events.append((self.NEW_GAME, game.copy())) self.checkEvent() - def _gamefull(self): - if self.isDisabled() or not self.settings.popupEnabled(self.GAME_FULL): + def _gamefull(self) -> None: + if self.is_disabled(self.GAME_FULL): return if (self.GAME_FULL, None) not in self.events: self.events.append((self.GAME_FULL, None)) @@ -95,7 +93,10 @@ def unofficialClient(self, msg): self.events.append((self.UNOFFICIAL_CLIENT, msg)) self.checkEvent() - def partyInvite(self, message): + def partyInvite(self, message: dict) -> None: + if self.is_disabled(self.PARTY_INVITE): + return + notify_mode = self.settings.getCustomSetting(self.PARTY_INVITE, 'mode') if ( notify_mode != 'all' @@ -114,18 +115,22 @@ def gameExit(self): if self.settings.ingame_notifications == IngameNotification.QUEUE: self.checkEvent() - def isDisabled(self): - return ( - self.disabledStartup - or ( - self.game_running - and ( - self.settings.ingame_notifications - == IngameNotification.DISABLE - ) + def is_enabled(self, event_type: str) -> bool: + if not self.settings.enabled or self.disabledStartup: + return False + + if not self.settings.popupEnabled(event_type): + return False + + if self.game_running: + return ( + self.settings.ingame_notifications == IngameNotification.ENABLE + or self.settings.ingame_allowed(event_type) ) - or not self.settings.enabled - ) + return True + + def is_disabled(self, event_type: str) -> bool: + return not self.is_enabled(event_type) def setNotificationEnabled(self, enabled): self.settings.enabled = enabled @@ -233,7 +238,7 @@ def showEvent(self): self.settings.soundEnabled(eventType), ) - def checkEvent(self): + def checkEvent(self) -> None: """ Checks that we are in correct state to show next notification popup @@ -244,15 +249,9 @@ def checkEvent(self): * Game isn't running, or ingame notifications are enabled """ - if ( - len(self.events) > 0 - and self.dialog.isHidden() - and ( - not self.game_running - or ( - self.settings.ingame_notifications - == IngameNotification.ENABLE - ) - ) - ): + if len(self.events) == 0 or not self.dialog.isHidden(): + return + + event_type, _ = self.events[0] + if self.is_enabled(event_type): self.showEvent() diff --git a/src/notifications/ns_hook.py b/src/notifications/ns_hook.py index 00cb15a57..e0da941c5 100644 --- a/src/notifications/ns_hook.py +++ b/src/notifications/ns_hook.py @@ -20,17 +20,15 @@ def __init__(self, eventType): self.button = QtWidgets.QPushButton('More') self.button.setEnabled(False) - def loadSettings(self): - self.popup = Settings.get( - self._settings_key + '/popup', True, type=bool, - ) - self.sound = Settings.get( - self._settings_key + '/sound', True, type=bool, - ) - - def saveSettings(self): - Settings.set(self._settings_key + '/popup', self.popup) - Settings.set(self._settings_key + '/sound', self.sound) + def loadSettings(self) -> None: + self.popup = Settings.get(f"{self._settings_key}/popup", default=True, type=bool) + self.sound = Settings.get(f"{self._settings_key}/sound", default=True, type=bool) + self.ingame = Settings.get(f"{self._settings_key}/ingame", default=False, type=bool) + + def saveSettings(self) -> None: + Settings.set(f"{self._settings_key}/popup", self.popup) + Settings.set(f"{self._settings_key}/sound", self.sound) + Settings.set(f"{self._settings_key}/ingame", self.ingame) def getEventDisplayName(self): return self.eventType @@ -45,6 +43,13 @@ def switchPopup(self): def soundEnabled(self): return self.sound + def ingame_allowed(self) -> bool: + return self.ingame + + def switch_ingame(self) -> None: + self.ingame = not self.ingame + self.saveSettings() + def switchSound(self): self.sound = not self.sound self.saveSettings() diff --git a/src/notifications/ns_settings.py b/src/notifications/ns_settings.py index 8b7b976ec..e4bd94ea2 100644 --- a/src/notifications/ns_settings.py +++ b/src/notifications/ns_settings.py @@ -3,6 +3,7 @@ Each module/hook for the notification system must be registered here. """ from enum import Enum +from typing import Any from PyQt6 import QtCore from PyQt6 import QtWidgets @@ -74,7 +75,7 @@ def __init__(self, client): for row in range(0, model.rowCount(None)): self.tableView.setIndexWidget( - model.createIndex(row, 3), + model.createIndex(row, 4), model.getHook(row).settings(), ) @@ -140,6 +141,11 @@ def soundEnabled(self, eventType): return self.hooks[eventType].soundEnabled() return False + def ingame_allowed(self, event_type: str) -> bool: + if event_type in self.hooks: + return self.hooks[event_type].ingame_allowed() + return False + def getCustomSetting(self, eventType, key): if eventType in self.hooks: if hasattr(self.hooks[eventType], key): @@ -155,17 +161,18 @@ class NotificationHooks(QtCore.QAbstractTableModel): POPUP = 1 SOUND = 2 - SETTINGS = 3 + ALLOW_INGAME = 3 + SETTINGS = 4 def __init__(self, parent, hooks, *args): QtCore.QAbstractTableModel.__init__(self, parent, *args) self.da = True self.hooks = hooks - self.headerdata = ['Type', 'PopUp', 'Sound', '#'] + self.headerdata = ['Type', 'PopUp', 'Sound', 'Allow ingame', '#'] def flags(self, index): flags = super(QtCore.QAbstractTableModel, self).flags(index) - if index.column() == self.POPUP or index.column() == self.SOUND: + if index.column() in (self.POPUP, self.SOUND, self.ALLOW_INGAME): return flags | QtCore.Qt.ItemFlag.ItemIsUserCheckable if index.column() == self.SETTINGS: return flags | QtCore.Qt.ItemFlag.ItemIsEditable @@ -196,6 +203,10 @@ def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole.EditRole): return self.returnChecked( self.hooks[index.row()].soundEnabled(), ) + if index.column() == self.ALLOW_INGAME: + return self.returnChecked( + self.hooks[index.row()].ingame_allowed(), + ) return None if role != QtCore.Qt.ItemDataRole.DisplayRole: @@ -208,7 +219,12 @@ def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole.EditRole): def returnChecked(self, state): return QtCore.Qt.CheckState.Checked if state else QtCore.Qt.CheckState.Unchecked - def setData(self, index, value, role=QtCore.Qt.ItemDataRole.DisplayRole.EditRole): + def setData( + self, + index: QtCore.QModelIndex, + value: Any, + role: QtCore.Qt.ItemDataRole = QtCore.Qt.ItemDataRole.EditRole, + ) -> bool: if index.column() == self.POPUP: self.hooks[index.row()].switchPopup() self.dataChanged.emit(index, index) @@ -217,6 +233,10 @@ def setData(self, index, value, role=QtCore.Qt.ItemDataRole.DisplayRole.EditRole self.hooks[index.row()].switchSound() self.dataChanged.emit(index, index) return True + if index.column() == self.ALLOW_INGAME: + self.hooks[index.row()].switch_ingame() + self.dataChanged.emit(index, index) + return True return False def headerData(self, col, orientation, role): From cf894d7a982430b0a8c4355e5f5215c23e26c10e Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 3 Aug 2024 23:01:25 +0300 Subject: [PATCH 69/73] Fix some previously unnoticed typehint errors --- src/api/vaults_api.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py index c1cf6504a..271e7871c 100644 --- a/src/api/vaults_api.py +++ b/src/api/vaults_api.py @@ -64,11 +64,11 @@ class MapApiConnector(VaultsApiConnector): def __init__(self) -> None: super().__init__("/data/map") - def _extend_query_options(self, query_options: dict) -> dict: + def _extend_query_options(self, query_options: dict) -> None: super()._extend_query_options(query_options) self._extend_includes(query_options, ["author"]) - def prepare_data(self, message: dict) -> None: + def prepare_data(self, message: dict) -> dict: return { "values": MapParser.parse_many(message["data"]), "meta": message["meta"], @@ -89,7 +89,7 @@ def _extend_query_options(self, query_options: dict) -> dict: self._add_default_includes(query_options) return query_options - def prepare_data(self, message: dict) -> None: + def prepare_data(self, message: dict) -> dict: return { "values": MapPoolAssignmentParser.parse_many_to_maps(message["data"]), "meta": message["meta"], From ee6b70074f5adb789670785f8307bf54fe641469 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 3 Aug 2024 23:03:20 +0300 Subject: [PATCH 70/73] setup.py: Unexclude pydoc -- it is needed for numpy --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 728138e0c..00fec336c 100644 --- a/setup.py +++ b/setup.py @@ -74,7 +74,7 @@ # copied from https://github.com/marcelotduarte/cx_Freeze/blob/5e42a97d2da321eae270cdcc65cdc777eb8e8fc4/samples/pyqt6-simplebrowser/setup.py # noqa: E501 # and unexcluded overexcluded - "excludes": ["tkinter", "unittest", "pydoc", "tcl"], + "excludes": ["tkinter", "unittest", "tcl"], "zip_include_packages": ["*"], "zip_exclude_packages": [], From 2b6724b4cebeebae4055d5f0fd287cf6f6fd7763 Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 3 Aug 2024 23:05:15 +0300 Subject: [PATCH 71/73] Update ice adapter to 3.3.9 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26b6370ae..1ba623f46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,7 @@ jobs: runs-on: windows-latest env: UID_VERSION: v4.0.6 - ICE_ADAPTER_VERSION: v3.3.7 + ICE_ADAPTER_VERSION: v3.3.9 BUILD_VERSION: ${{ github.event.inputs.version }} steps: From f6e6a5b35d4fa4bfc2129c3cf79654a4322d0afc Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 3 Aug 2024 23:12:01 +0300 Subject: [PATCH 72/73] release.yml: Remove unnecessary artifact path shenanigans the .msi file is stored in the 'dist' directory --- .github/workflows/release.yml | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ba623f46..b81c8492e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,25 +47,10 @@ jobs: run: | python setup.py bdist_msi - - name: Get Artifact Paths - id: artifact_paths - run: | - function getMsiPath { - $files = Get-ChildItem *.msi -Recurse | Select -First 1 - (Get-Item $files).FullName - } - $WINDOWS_MSI = getMsiPath - Write-Host "MSI path: $WINDOWS_MSI" - $WINDOWS_MSI_NAME = (Get-Item $WINDOWS_MSI).Name - Write-Host "MSI name: $WINDOWS_MSI_NAME" - echo "WINDOWS_MSI=$WINDOWS_MSI" >> "$env:GITHUB_ENV" - echo "WINDOWS_MSI_NAME=$WINDOWS_MSI_NAME" >> "$env:GITHUB_ENV" - - name: Calculate checksum id: checksum run: | - Write-Host MSI path is: $env:WINDOWS_MSI - $MSI_SUM = $(Get-FileHash $env:WINDOWS_MSI).hash + $MSI_SUM = $(Get-FileHash dist/*).hash Write-Host $MSI_SUM echo "MSI_SUM=$MSI_SUM" >> "$env:GITHUB_ENV" @@ -78,4 +63,4 @@ jobs: body: "SHA256: ${{ env.MSI_SUM }}" draft: true prerelease: true - artifacts: ${{ env.WINDOWS_MSI }} + artifacts: dist/* From 04de926a399231c8f6a290722958805eda6885cd Mon Sep 17 00:00:00 2001 From: gatsik <74517072+Gatsik@users.noreply.github.com> Date: Sat, 3 Aug 2024 23:26:57 +0300 Subject: [PATCH 73/73] Get chatters' avatars by url, not by name the ImageDownloader's get_image method accepts url --- src/chat/_avatarWidget.py | 4 +--- src/chat/channel_view.py | 4 +--- src/chat/chatter_model.py | 3 +-- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/src/chat/_avatarWidget.py b/src/chat/_avatarWidget.py index 6efe0d306..29b711b3d 100644 --- a/src/chat/_avatarWidget.py +++ b/src/chat/_avatarWidget.py @@ -1,6 +1,5 @@ from PyQt6.QtCore import QObject from PyQt6.QtCore import QSize -from PyQt6.QtCore import QUrl from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QListWidgetItem from PyQt6.QtWidgets import QPushButton @@ -69,8 +68,7 @@ def set_avatar_list(self, avatars: list[dict]) -> None: for avatar in avatars: self._add_avatar_item(avatar) url = avatar["url"] - avatar_name = QUrl(url).fileName() - icon = self._avatar_dler.get_image(avatar_name) + icon = self._avatar_dler.get_image(url) if icon is not None: self._set_avatar_icon(url, icon) else: diff --git a/src/chat/channel_view.py b/src/chat/channel_view.py index e1e1e85dc..2d420dd50 100644 --- a/src/chat/channel_view.py +++ b/src/chat/channel_view.py @@ -3,7 +3,6 @@ import jinja2 from PyQt6.QtCore import QObject -from PyQt6.QtCore import QUrl from PyQt6.QtCore import pyqtSignal from PyQt6.QtGui import QDesktopServices @@ -176,8 +175,7 @@ def build(cls, widget, avatar_dler, **kwargs): return cls(widget, avatar_dler) def add_avatar(self, url: str) -> None: - avatar_name = QUrl(url).fileName() - avatar_pix = self._avatar_dler.get_image(avatar_name) + avatar_pix = self._avatar_dler.get_image(url) if avatar_pix is not None: self._add_avatar_resource(url, avatar_pix) elif url not in self._requests: diff --git a/src/chat/chatter_model.py b/src/chat/chatter_model.py index 3732febab..3917bd249 100644 --- a/src/chat/chatter_model.py +++ b/src/chat/chatter_model.py @@ -221,10 +221,9 @@ def chatter_rank(self, data): def chatter_avatar_icon(self, data: ChatterModelItem) -> QIcon | None: avatar_url = data.avatar_url() - avatar_name = QtCore.QUrl(avatar_url).fileName() if avatar_url is None: return None - if (pixmap := self._avatars.get_image(avatar_name)) is not None: + if (pixmap := self._avatars.get_image(avatar_url)) is not None: return QIcon(pixmap) def chatter_country(self, data):