From 52c1128ed416d958b9e5cca7ba1a91f61b307492 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 12 Dec 2024 16:42:57 -0500 Subject: [PATCH] When I run the game, it works for a bit but then when the first asteroid gets to the bottom I get this: Traceback (most recent call last): File "/root/making_with_code/mwc1/unit3/lab_retro/nav_game.py", line 14, in game.play() File "/root/making_with_code/mwc1/unit3/lab_retro/retro/game.py", line 80, in play agent.play_turn(self) File "/root/making_with_code/mwc1/unit3/lab_retro/asteroid.py", line 16, in play_turn game.remove_agent_by_name(self.name) AttributeError: 'Asteroid' object has no attribute 'name' --- __pycache__/asteroid.cpython-310.pyc | Bin 0 -> 821 bytes __pycache__/asteroid_spawner.cpython-310.pyc | Bin 0 -> 1050 bytes __pycache__/retro.cpython-310.pyc | Bin 0 -> 11785 bytes __pycache__/spaceship.cpython-310.pyc | Bin 0 -> 903 bytes asteroid.py | 19 + asteroid_spawner.py | 19 + blessed/__init__.py | 23 + blessed/__init__.py:Zone.Identifier | 0 blessed/__pycache__/__init__.cpython-310.pyc | Bin 0 -> 701 bytes .../__pycache__/_capabilities.cpython-310.pyc | Bin 0 -> 4763 bytes blessed/__pycache__/color.cpython-310.pyc | Bin 0 -> 6755 bytes .../__pycache__/colorspace.cpython-310.pyc | Bin 0 -> 34379 bytes .../__pycache__/formatters.cpython-310.pyc | Bin 0 -> 15993 bytes blessed/__pycache__/keyboard.cpython-310.pyc | Bin 0 -> 13408 bytes blessed/__pycache__/sequences.cpython-310.pyc | Bin 0 -> 15407 bytes blessed/__pycache__/terminal.cpython-310.pyc | Bin 0 -> 48816 bytes blessed/_capabilities.py | 168 ++ blessed/_capabilities.py:Zone.Identifier | 0 blessed/_capabilities.pyi | 7 + blessed/_capabilities.pyi:Zone.Identifier | 0 blessed/color.py | 258 +++ blessed/color.py:Zone.Identifier | 0 blessed/color.pyi | 17 + blessed/color.pyi:Zone.Identifier | 0 blessed/colorspace.py | 973 +++++++++++ blessed/colorspace.py:Zone.Identifier | 0 blessed/colorspace.pyi | 12 + blessed/colorspace.pyi:Zone.Identifier | 0 blessed/formatters.py | 496 ++++++ blessed/formatters.py:Zone.Identifier | 0 blessed/formatters.pyi | 70 + blessed/formatters.pyi:Zone.Identifier | 0 blessed/keyboard.py | 451 +++++ blessed/keyboard.py:Zone.Identifier | 0 blessed/keyboard.pyi | 28 + blessed/keyboard.pyi:Zone.Identifier | 0 blessed/py.typed | 0 blessed/py.typed:Zone.Identifier | 0 blessed/sequences.py | 461 +++++ blessed/sequences.py:Zone.Identifier | 0 blessed/sequences.pyi | 55 + blessed/sequences.pyi:Zone.Identifier | 0 blessed/terminal.py | 1552 +++++++++++++++++ blessed/terminal.py:Zone.Identifier | 0 blessed/terminal.pyi | 108 ++ blessed/terminal.pyi:Zone.Identifier | 0 blessed/win_terminal.py | 163 ++ blessed/win_terminal.py:Zone.Identifier | 0 blessed/win_terminal.pyi | 11 + blessed/win_terminal.pyi:Zone.Identifier | 0 example_game.py | 7 + nav_game.py | 10 + retro/__pycache__/agent.cpython-310.pyc | Bin 0 -> 4885 bytes retro/__pycache__/errors.cpython-310.pyc | Bin 0 -> 2430 bytes retro/__pycache__/game.cpython-310.pyc | Bin 0 -> 7116 bytes retro/__pycache__/graph.cpython-310.pyc | Bin 0 -> 6836 bytes retro/__pycache__/validation.cpython-310.pyc | Bin 0 -> 1680 bytes retro/__pycache__/view.cpython-310.pyc | Bin 0 -> 4794 bytes retro/agent.py | 99 ++ retro/agent.py:Zone.Identifier | 0 retro/errors.py | 40 + retro/errors.py:Zone.Identifier | 0 .../__pycache__/debug.cpython-310.pyc | Bin 0 -> 300 bytes retro/examples/debug.py | 6 + retro/examples/debug.py:Zone.Identifier | 0 retro/examples/nav.py | 139 ++ retro/examples/nav.py:Zone.Identifier | 0 retro/examples/simple.py | 7 + retro/examples/simple.py:Zone.Identifier | 0 retro/examples/snake.py | 198 +++ retro/examples/snake.py:Zone.Identifier | 0 retro/game.py | 216 +++ retro/game.py:Zone.Identifier | 0 retro/graph.py | 162 ++ retro/graph.py:Zone.Identifier | 0 retro/grid.py | 5 + retro/grid.py:Zone.Identifier | 0 retro/validation.py | 44 + retro/validation.py:Zone.Identifier | 0 retro/view.py | 127 ++ retro/view.py:Zone.Identifier | 0 spaceship.py | 21 + 82 files changed, 5972 insertions(+) create mode 100644 __pycache__/asteroid.cpython-310.pyc create mode 100644 __pycache__/asteroid_spawner.cpython-310.pyc create mode 100644 __pycache__/retro.cpython-310.pyc create mode 100644 __pycache__/spaceship.cpython-310.pyc create mode 100644 blessed/__init__.py create mode 100644 blessed/__init__.py:Zone.Identifier create mode 100644 blessed/__pycache__/__init__.cpython-310.pyc create mode 100644 blessed/__pycache__/_capabilities.cpython-310.pyc create mode 100644 blessed/__pycache__/color.cpython-310.pyc create mode 100644 blessed/__pycache__/colorspace.cpython-310.pyc create mode 100644 blessed/__pycache__/formatters.cpython-310.pyc create mode 100644 blessed/__pycache__/keyboard.cpython-310.pyc create mode 100644 blessed/__pycache__/sequences.cpython-310.pyc create mode 100644 blessed/__pycache__/terminal.cpython-310.pyc create mode 100644 blessed/_capabilities.py create mode 100644 blessed/_capabilities.py:Zone.Identifier create mode 100644 blessed/_capabilities.pyi create mode 100644 blessed/_capabilities.pyi:Zone.Identifier create mode 100644 blessed/color.py create mode 100644 blessed/color.py:Zone.Identifier create mode 100644 blessed/color.pyi create mode 100644 blessed/color.pyi:Zone.Identifier create mode 100644 blessed/colorspace.py create mode 100644 blessed/colorspace.py:Zone.Identifier create mode 100644 blessed/colorspace.pyi create mode 100644 blessed/colorspace.pyi:Zone.Identifier create mode 100644 blessed/formatters.py create mode 100644 blessed/formatters.py:Zone.Identifier create mode 100644 blessed/formatters.pyi create mode 100644 blessed/formatters.pyi:Zone.Identifier create mode 100644 blessed/keyboard.py create mode 100644 blessed/keyboard.py:Zone.Identifier create mode 100644 blessed/keyboard.pyi create mode 100644 blessed/keyboard.pyi:Zone.Identifier create mode 100644 blessed/py.typed create mode 100644 blessed/py.typed:Zone.Identifier create mode 100644 blessed/sequences.py create mode 100644 blessed/sequences.py:Zone.Identifier create mode 100644 blessed/sequences.pyi create mode 100644 blessed/sequences.pyi:Zone.Identifier create mode 100644 blessed/terminal.py create mode 100644 blessed/terminal.py:Zone.Identifier create mode 100644 blessed/terminal.pyi create mode 100644 blessed/terminal.pyi:Zone.Identifier create mode 100644 blessed/win_terminal.py create mode 100644 blessed/win_terminal.py:Zone.Identifier create mode 100644 blessed/win_terminal.pyi create mode 100644 blessed/win_terminal.pyi:Zone.Identifier create mode 100644 example_game.py create mode 100644 retro/__pycache__/agent.cpython-310.pyc create mode 100644 retro/__pycache__/errors.cpython-310.pyc create mode 100644 retro/__pycache__/game.cpython-310.pyc create mode 100644 retro/__pycache__/graph.cpython-310.pyc create mode 100644 retro/__pycache__/validation.cpython-310.pyc create mode 100644 retro/__pycache__/view.cpython-310.pyc create mode 100644 retro/agent.py create mode 100644 retro/agent.py:Zone.Identifier create mode 100644 retro/errors.py create mode 100644 retro/errors.py:Zone.Identifier create mode 100644 retro/examples/__pycache__/debug.cpython-310.pyc create mode 100644 retro/examples/debug.py create mode 100644 retro/examples/debug.py:Zone.Identifier create mode 100644 retro/examples/nav.py create mode 100644 retro/examples/nav.py:Zone.Identifier create mode 100644 retro/examples/simple.py create mode 100644 retro/examples/simple.py:Zone.Identifier create mode 100644 retro/examples/snake.py create mode 100644 retro/examples/snake.py:Zone.Identifier create mode 100644 retro/game.py create mode 100644 retro/game.py:Zone.Identifier create mode 100644 retro/graph.py create mode 100644 retro/graph.py:Zone.Identifier create mode 100644 retro/grid.py create mode 100644 retro/grid.py:Zone.Identifier create mode 100644 retro/validation.py create mode 100644 retro/validation.py:Zone.Identifier create mode 100644 retro/view.py create mode 100644 retro/view.py:Zone.Identifier diff --git a/__pycache__/asteroid.cpython-310.pyc b/__pycache__/asteroid.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f710e2f9b1617d17e178772f85e46d14c06ae3ce GIT binary patch literal 821 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 PPZg(&#`lcxojCPBH4y}- literal 0 HcmV?d00001 diff --git a/__pycache__/spaceship.cpython-310.pyc b/__pycache__/spaceship.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..002f91b642ac984ec9748f8b1b2948ba09173edf GIT binary patch literal 903 zcmZ8fL2uJA6t*3wO}liOdMINDPFy0SRnsmUCNv3+fj~X5aiAcqEOwi$ZBp!%(keZ% zAHt6OC0{x17a;A#dnqY}7y0vhw%?ccK09l*ngoo#`!H-cg#5%{+hC2H!Z2-sb0UeP zH!!}jK9TH#1Wp9&{}_Yl6oz>Wph%xe(r1$P9m)D!I+EXzzAIf>gSjT_z~R}(#WakR ziIb`2i1+kUggljTaIli+Fw7%>f_`KxwsHz!J|G2f?~?&5h=2;Qs@@%@-%)^oT5p<} zBu}z5;J~lO1KYgHLM??!mdfs|{dpqu*!t@rR>?5Ve-OO`4EImFI?M9zBpfB_P|TA& z7EvZucQTKTy0bLNkGtdWN@$hqtZS-P4yOz236a3Q5U8@jHayy)%l4+2 zXVdRB1O>S!(6O`P1-+({ej)m(^w@~$4)pKrlY$S}%1vt+J@^w9>^m#mTdI%2b4u?? z;Z{1q8owp9)C_eiwd1{jS?~4Pd-3M%#ieba_x9EK%S*irg=iNba5YwFc^XcX^|Dly zs#z~FLQSUm!a6FIK~2~3RAZzT29y~o%ZHdizgoJmeyZl8x|mYuHTamb7l)}FE3rw% zf13_Klj65aeYW;|#(!~ldGJ@!|5JNG9q$2a6~Z=!m}GJW+`{@oe4d5l^^V>vi;dz? zhf%JyZb7UcBH$y~0<2Ku(?Ssp-mFJB<(QJ6Zq#88R4rrfv5( DXgSk* literal 0 HcmV?d00001 diff --git a/asteroid.py b/asteroid.py index 257354b..1338ae8 100644 --- a/asteroid.py +++ b/asteroid.py @@ -2,3 +2,22 @@ # ------------ # By MWC Contributors # This module defines an asteroid agent class. + +class Asteroid: + character = 'O' + + def __init__(self, position): + self.position = position + + def play_turn(self, game): + if game.turn_number % 2 == 0: + x, y = self.position + if y == 25 - 1: + game.remove_agent_by_name(self.name) + else: + ship = game.get_agent_by_name('ship') + new_position = (x, y + 1) + if new_position == ship.position: + game.end() + else: + self.position = new_position \ No newline at end of file diff --git a/asteroid_spawner.py b/asteroid_spawner.py index 92e542d..65eae7f 100644 --- a/asteroid_spawner.py +++ b/asteroid_spawner.py @@ -2,3 +2,22 @@ # ------------------- # By MWC Contributors # This module defines an AsteroidSpawner agent class. + +from random import randint +from asteroid import Asteroid + +class AsteroidSpawner: + display = False + + def __init__(self, board_size): + width, height = board_size + self.board_width = width + + def play_turn(self, game): + game.state['score'] += 1 + if self.should_spawn_asteroid(game.turn_number): + asteroid = Asteroid((randint(0, self.board_width - 1), 0)) + game.add_agent(asteroid) + + def should_spawn_asteroid(self, turn_number): + return randint(0, 1000) < turn_number \ No newline at end of file diff --git a/blessed/__init__.py b/blessed/__init__.py new file mode 100644 index 0000000..e7b00b5 --- /dev/null +++ b/blessed/__init__.py @@ -0,0 +1,23 @@ +""" +A thin, practical wrapper around terminal capabilities in Python. + +http://pypi.python.org/pypi/blessed +""" +# std imports +import sys as _sys +import platform as _platform + +# isort: off +if _platform.system() == 'Windows': + from blessed.win_terminal import Terminal +else: + from blessed.terminal import Terminal # type: ignore + +if (3, 0, 0) <= _sys.version_info[:3] < (3, 2, 3): + # Good till 3.2.10 + # Python 3.x < 3.2.3 has a bug in which tparm() erroneously takes a string. + raise ImportError('Blessed needs Python 3.2.3 or greater for Python 3 ' + 'support due to http://bugs.python.org/issue10570.') + +__all__ = ('Terminal',) +__version__ = "1.20.0" diff --git a/blessed/__init__.py:Zone.Identifier b/blessed/__init__.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/__pycache__/__init__.cpython-310.pyc b/blessed/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..280d45504e925e7a07c783d4ea4d1d2d5fd98b40 GIT binary patch literal 701 zcmYjOO{)_z6iqVorqgNr5D`Uik!?X{+E+vn@e5qI5kWziO_)y7Hg+ZnHyKO2RQw;h zbmjkACDKv$vyXmoO5sVXp|to#O0sGFM`nTj@(?KColZAl?RS;#4#ro z&dK2n@=%1|FMc2%tO?xnxv+}rUf#nSr!%DKw*pkT7Yg8r%pgEmCu)jDdw`0 zP6|t%{~)9vuH04ud(QY~~lcvE*iUju09go~>3!UM+6x7bev zx4Thv@b`HJTDz<+)>19mM!FR%H5XaEDW_SZq?=_`F=rqg=<^q3%u7kfnD4xepVpc; cm3Y<--QM*OqBx52Fu24+@_@tv`4@WrAMM`G)Bpeg literal 0 HcmV?d00001 diff --git a/blessed/__pycache__/_capabilities.cpython-310.pyc b/blessed/__pycache__/_capabilities.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..03f3f943b1902329eeb67f7dae66c13f32fcb4ed GIT binary patch literal 4763 zcmZ`-33wz$6`rn{B$Jw?Gszxd39!qtuwh}7Np_coaAlK#A#4^l;YtE+Prpvmrh9s* z?%BQkXY%FM|Ni&tRn>b{ z)%C@1Y@`YNvGunmuh=J%ctt4HKTW8dh<`y>Z6X1Qu>@#9Ty0FlHt7;CIhNECkQz(b z$*Ba?K&_LQB$MPa4eB7Ra6M4SK!c-6TO;Vo)&yCFo1sPFMX*@mC9qWCR@et)EmN`f zg*Iq_{b2tcIBqoZ0kB+ouYdy;J_rt0_z+mB@S$*+!mFTtk7$R(YGpkFj#PLJbST^j zIfc8RTj8~^PT}=%l)@X}XoZh~9)*vE;}kv~PEhzn=v8-3)V9Q)39toZ5?>m0*@JR7w8@o7U(_{7wG2$Tc8IJ?lD3Mwl4742D1zF?NC

5;->$;nslwlt_*xm`I@w+?+Z$y2 zJ&bqbj>fS%_GCyp^d+c%zZiOo=EZio! z+hK>|?vUIr*rm8TC3hFxrMSB#cMsefarf=SyAb2wFZBnc{vbT0`aCTAJW|CyD!Iq1 zxW^^;L>2d>aCC3GuttgsdXtP4FX1Dn047Y1M zvzej6hJMEi+f#f|U+-zX8wXDv96iVA?;Y*k*gG=7mnw2_V8qzcdzP_j@a)0iJ^0@K z{=w0~GY3X^+XDZ--ZMr9j7^)*9N084Jjzpf+jV&oC2z7!#vF6XHkcijSb-CZ@x|7( zSwJeVnD4r1nR5IBPg()vb%mnIOfT3?aPnNYT-#)ZiB6%J4|p=nTf8AAv9oO#cul17 zI?H87-p5gGHy3L*Y6PbcnQTAc^>If5b<1~4UcoQ{m>po?>7uz?i-#<+z{fEw7OPYo zZ6~Zz7#lq^w5E+I=9h{#@QfAvg1@c6Q&tIbikkL4o2Md~m0nI^s}neR*Ty+J2$ipE zPllp7t_mie+}uXCWu0^_YZsXy!~!N1Z2gxW1Sx6-BgC4p$iy^W$z6 z<7UBj?a(%`3e2={v+xvH(>Qm@yH3I8Nx(d2?E;#OJTfze=L1eX!n{cud>3Lqg}Dc= z$22%z%q9_4f=rfNw^Fmf!>sZ}v3C%f1@KE@+$CL6`BG`dR5R@86s1Qdm}huv#VK&S zqHE84H3X%3iZ71cN(DR!VtMgc^-AX%o0&nJ65I{8HO-gUvrdRxFO2dO*mCq5EQ}TK zhS)0CnL^UT^+Y|wkU9Ah<~K@MDwtxO(%mv+4r7S*H%0zcdC5kEQwVG(N5jAl<1Gns?HG^qm+V^L~Mnu^VGnFDl8qdh#;-WiP^oALGd(i~L^)1|j zID?s4o{`+F=iqJ&1eNye&@}wXNuFe$$LnGhk8yxny^kByD4IBV;um=q54C5AV`9Wx z5)XR`BrUdo6`kkl*c)q*#C=)m9Pim^ngm?wc)=$l^SB)>>W{tC4XXZ^YXL zcTO(AJyDqAgy9j7sV+rzRj6)7b#ooPLZ9J|jE*q&w0MU3+ve}3twob(0(0xUOJH#w zXC|)HNuO;q8DMS-cq%2y?5%i`@Mt684LGk(1MI*E?DMgtoq(Gfxm*>OlU!F7*Cn~` zDy|#38oW_`#+${^wzwb*U_?}VGQK@}@Nt2jOWS zH%%0~PI%*Y0B`G1djN&~!0aDsV#Fc?I8yi8-xqPHUXg(sNo{xoKj2)dZ|= zpXiC2E#>6Ic%KknIW0~73iXxiCT4T_iSbee1YD!#WZSBZb6S0SBdf!Y0jn2cr4a2x ztQKNOh-o20Aubf+Vj(UO;!+`Yq9~K`^UCDh!+A>FtmV|ixf^=sw4O4VnBeJXv0dxd zmopRN-5Yw=_29fco~fikTGlE{ebm8VQ~C0us7IIRQTg+DL#1cM1-tIyk`AUFJcPLG z6LVSc2*A&NXF1h2-Z>;>PO(9xcMY{KVY@^f z?hqoXVY5KXgxF7re@1qf8~zq(MuOR2|jG+mw+1>Phk+nj-(EHRNBkmdw#Q z@=uy3|Iq8n-?NmwMKk10+CctB8_63~Cx4|)H4k(k0{% zbSe2gZ6&+uKIC_F8Tl>Um;8pdkyq({?&7 zdKY<8?`{$wTxQ@*wRY571-D{q#6;A3dJjOHUy8 z=qHl9X)n2pZX|coKC+AUlRM}Dxt*RwZlfoYThpfyE&VpRC4Fkt-jp7W+8gO<X=_0^Q*VjzM4MZe^nPLmH+?% literal 0 HcmV?d00001 diff --git a/blessed/__pycache__/color.cpython-310.pyc b/blessed/__pycache__/color.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..41bfcd6699631ca6e3673903b1373cb2e0399fd1 GIT binary patch literal 6755 zcmeHMYm6IL6`qG5vtHZl-Ss~5XgV}0jp@dlY}2G!(sO#^9ClF%eeTWjn)9-Fmi zoIB&}qhkbYp%tPj5>k0eP#&r(RTZj=R1k_ptI{H*N=QJ3;YUkRt5qe$56Vj=;X8M1 z?<1iopln4n1r91{A20pRg&}?byof~bf$2*Pl7OMT4FL& zs&Y-4R%+_BS_@5wYT@Z{EixUEr3DS6YBgHZrZrhQCE0x;$?TcGPL}9TjKzYnGmm%Sdp}FF zA>0Sdq}gYt%zkr#4SzK}J;+8*NSV=v3$YW8!nT^j8dX!TbLU)zRcuQyI#q}3rG{Pf zDvs@DW3l6A$>gS8G~KaSY@1&8yt+F%K5p9C*~(0%ZnBDzb+|Q7z484tJNB0H*BWg zkv*Q(?{q4*XY$w*bS`DWEw$*lE!CW>w^X-cw?gh&?zO^(XV?=Nxz$tUjeOB4md&C{ z6WTwrTnN{GT*7*m)Kq=RmwlzA9+2*NPc!6)n0%Ea?#fJvZ}22=)DhTagT%s%N;71W zOr$Ln-9xhK*JVHSs4PooqrQ5#+LW4dQ}HFHrLi{aNlPP=udo>1n_=*K1wY>ACj>tN zep2xJ+WeH@NByuL@uMXb*6VL1WJ!wY`0aP>b0+t67jMiA&h+DlZfzUUbM(_^rh^Xb zNYaxAw{$2_=O&vJqUVfi!_=LUe%xf))iM@0O?!>hA>yr(D`15+Vz+|Tmg1(@;I=!d zpKR2Dw5_Md^fP1nbOu|fbJI0#kF4I^Xs`wD&DXJ}M^l2GAJ}!{_{6i{S$@w-wc4mv zY{T}%6ib-)6=%vCNaud^>+gOmXJzhw_}RC%K9RFtT}VH9=+)ontQ*8now8p3X8q>a z%bBSqhH2I}p5FcEXIU#}eS2*G#y5ZSi=6fJi{~zUZT$;5>j#f~u5V%2PdY=d=d62b z&tH7%zNf%BIyGoK_xYSP@%oe3{&w*~&id&!zxvC^KKi@d<4Q}dJF~5bQLmde%V;eX z`fi1VLR%ry7dTd-r9%6y@LXQp=7ZY;-5!^C8aw;z-f`|Y9vo!`O3KexymG$iFmt>% zTih|;uq)mT<5i=OhmN^3UZ|R`YqD|S!rA(KD~4y}Jtsdmzc4~bUk?(BCuI$PX<1V= zIWEVPQJMZ?3LnPsDmUpNd0fIxK8r@+CQL3VfU^RsFtrr8%*MOn5&(6`54T++1h?5F z+y?GqhgrlAr6m>x^jA7cV%brc=F5=i8I_t579)2YmC{l(%6iE$MOz~m@HOEC@kV^D z6Le9e1N-|AAKZ4tC|r*JQq?g$U1)t$0I%xKH~`1tgW=*Wgy9cIQWJiVtKEcND~v9*`bDbf7}$evKiOF3O8C z3=%SQ2n$>vFp<6yxJ{i^C}|Z*#oN;IUMCbW@f{1T?lINHjr}!pu?y zF%Wgi6(lF#NwS-iPMmERT;xBme1#nab&<*1TUzCqB0vOtJXx-|`mDoeT&i0bR!s>2)f8 zbV>ta0Sq{HsbV#dU4k9z+^{WEtkgg`WZ@~MUUGP?Q8gwfgUyms*}W6`&H7e8p>Naq zjtqL&>su}AS)E>idIjnQ({4jqXqHM9__$r19}_FPSJobs5h&PXVk@)dO0lei>o(d8 zc(f^OxmGuL#YLSdkG+B19`6-y$t+ zAb{pECA+uk6pgC8FWZ%b57B0qi15uI2v(}wsPXHFA^b_SMCj8x3*|8=ylHfRZ$o>P zH<1@baEY*X4hxYf=NVHkH)@8h*G*nD>lB)(wr+>3?y1Oq*IY`Vv|SEJQv#`6 z2ey_7<@|bTU82?&5MX*MwKE_s4KXEOGYxxfa_H}ZxuXRt8(hjL={_CRq;hGC61vED zfUyeSs}5baVU&=1Ucxa+X{yZ-da~$+n~`P|30?8Sz@@yXpso6mpbgyPh~-B+J#zS0 z@46hXT@zk8x9H2!5w*acow`xA9FDkJ`!C8m??OZlLYpE|e-KZ#6&O*g`5i6QAW|Sw z%}-Dlq>yh1SP=<>`cL2n*<<&v&k$fJ)Fp8kd7sSptc41lRtTUX6uyGSJ3)oXA*re1 z>cJIj_A;dzXKFJM0H~P^z|-stVAD(yoLFd(&hwxnC{ddI;6(&4+T{)S{r&)sB7+#~ z6Jx1h%pdH^q?zo;{eE#iA3{6PZV&rOzsJX^AKwb-NrQgOAL{h*4Iu6J`oqh8fH=%2 z@S4IQ--wCR>0`M!p4s%NH$U{u`P?ciP)^K@EL(w7g5*1iP+iS;5!p@TCJ-bg6z1wo zK#J5V6M?m6V5lfFtDb?ucEjj2a5gQHP;ptP*crnDZXQdy`SRM}5bGFu&(5#l=^}3@ z6G=IxgyfJoani{XXHR*fDEE^}67eEQ@#2G!UllJlfrau<$TJM}eVZ^n@#nrg0+1>Lulz#2YNq90E+z zI2{k$I{}bEJax!Z{h>vfMQ3FG>0ne;0mJrYk^uK%e~5*5LlPgO(7Fk&wp@5d;*WOP z!Q0r;P9M>es)=Dnu@!qr<6Y8{M{9sj?dAw-c+Cs@!*m`e4(7zXFjEM))W@2==*Owg z5~NMROM=%Yc&X03<|x+c_Y(}-AuZ!cG8Z2`=i3W*0Ujh zfcEIAY}kvi5$I-wjbg=hwBul1+5??9u>TEkOw>x`WI#bboPV8Y_xmIGkhWg5NBp?3 zeSctktS2zwZy-1JN9eh5&Gp9Ve=)yIY~Q~9{~6}WHNOaXBET(0;p|lvWa*YKo(fBq zGSnVa*-wLMJ@?jDe9(J7x3r0RL2P^}XWd8N_7;AYTM{!Vt5{$D68(Oh<5^lP2g2V^ zEef$qA|p*w>*A$LmzMTY3yn*aK|F409lDulknbqca#|2ct>AJ|G11wA(~~kASA<|% zgx^eL9})4ycMQw?R>-v?)u6&UNRy}{YDJ0x=MZ&ND?is#%M^yo2*ZgWR+sa2jzeQB zQVv9K7ouK%jvuCFJ|Mb0@2w5Z@lI$aG+uy|dnZULK_OUE;&NI^BP46`sIrN^$RJ?H z1fNE!H70LVly>?r)`}oQG6EK zy-VEo>}*9*3D8cCX40)_KF^$DKHmz_e*}0}$AQY1&CnzME#Wv-mlIZ6y@zXchkFM( zcX;cnf>E5IuAA+O<-4E%#fJ~^X(_NRoxVhgDvA%0e8kpU>-HZza_o5iz~K`oZ#laE zVE&dPhmIXTeDd}?POMPqa#f@44gQ0&58#p9F_4hFu~$)3&_+s8E^1+IleQlE>(hp` QVXa@=tV}6#@E3dg@0=9ZbN~PV literal 0 HcmV?d00001 diff --git a/blessed/__pycache__/colorspace.cpython-310.pyc b/blessed/__pycache__/colorspace.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47dae6a6b43aff7164cc5e2a4f12425fdb5f070a GIT binary patch literal 34379 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∾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 literal 0 HcmV?d00001 diff --git a/blessed/__pycache__/keyboard.cpython-310.pyc b/blessed/__pycache__/keyboard.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f4c093a4ef3656e00f42306f3baa1bd7809b3fde GIT binary patch literal 13408 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 literal 0 HcmV?d00001 diff --git a/blessed/_capabilities.py b/blessed/_capabilities.py new file mode 100644 index 0000000..c4df54b --- /dev/null +++ b/blessed/_capabilities.py @@ -0,0 +1,168 @@ +"""Terminal capability builder patterns.""" +# std imports +import re +from collections import OrderedDict + +__all__ = ( + 'CAPABILITY_DATABASE', + 'CAPABILITIES_RAW_MIXIN', + 'CAPABILITIES_ADDITIVES', + 'CAPABILITIES_CAUSE_MOVEMENT', +) + +CAPABILITY_DATABASE = OrderedDict(( + ('bell', ('bel', {})), + ('carriage_return', ('cr', {})), + ('change_scroll_region', ('csr', {'nparams': 2})), + ('clear_all_tabs', ('tbc', {})), + ('clear_screen', ('clear', {})), + ('clr_bol', ('el1', {})), + ('clr_eol', ('el', {})), + ('clr_eos', ('clear_eos', {})), + ('column_address', ('hpa', {'nparams': 1})), + ('cursor_address', ('cup', {'nparams': 2, 'match_grouped': True})), + ('cursor_down', ('cud1', {})), + ('cursor_home', ('home', {})), + ('cursor_invisible', ('civis', {})), + ('cursor_left', ('cub1', {})), + ('cursor_normal', ('cnorm', {})), + ('cursor_report', ('u6', {'nparams': 2, 'match_grouped': True})), + ('cursor_right', ('cuf1', {})), + ('cursor_up', ('cuu1', {})), + ('cursor_visible', ('cvvis', {})), + ('delete_character', ('dch1', {})), + ('delete_line', ('dl1', {})), + ('enter_blink_mode', ('blink', {})), + ('enter_bold_mode', ('bold', {})), + ('enter_dim_mode', ('dim', {})), + ('enter_fullscreen', ('smcup', {})), + ('enter_standout_mode', ('standout', {})), + ('enter_superscript_mode', ('superscript', {})), + ('enter_susimpleript_mode', ('susimpleript', {})), + ('enter_underline_mode', ('underline', {})), + ('erase_chars', ('ech', {'nparams': 1})), + ('exit_alt_charset_mode', ('rmacs', {})), + ('exit_am_mode', ('rmam', {})), + ('exit_attribute_mode', ('sgr0', {})), + ('exit_ca_mode', ('rmcup', {})), + ('exit_fullscreen', ('rmcup', {})), + ('exit_insert_mode', ('rmir', {})), + ('exit_standout_mode', ('rmso', {})), + ('exit_underline_mode', ('rmul', {})), + ('flash_hook', ('hook', {})), + ('flash_screen', ('flash', {})), + ('insert_line', ('il1', {})), + ('keypad_local', ('rmkx', {})), + ('keypad_xmit', ('smkx', {})), + ('meta_off', ('rmm', {})), + ('meta_on', ('smm', {})), + ('orig_pair', ('op', {})), + ('parm_down_cursor', ('cud', {'nparams': 1})), + ('parm_left_cursor', ('cub', {'nparams': 1, 'match_grouped': True})), + ('parm_dch', ('dch', {'nparams': 1})), + ('parm_delete_line', ('dl', {'nparams': 1})), + ('parm_ich', ('ich', {'nparams': 1})), + ('parm_index', ('indn', {'nparams': 1})), + ('parm_insert_line', ('il', {'nparams': 1})), + ('parm_right_cursor', ('cuf', {'nparams': 1, 'match_grouped': True})), + ('parm_rindex', ('rin', {'nparams': 1})), + ('parm_up_cursor', ('cuu', {'nparams': 1})), + ('print_screen', ('mc0', {})), + ('prtr_off', ('mc4', {})), + ('prtr_on', ('mc5', {})), + ('reset_1string', ('r1', {})), + ('reset_2string', ('r2', {})), + ('reset_3string', ('r3', {})), + ('restore_cursor', ('rc', {})), + ('row_address', ('vpa', {'nparams': 1})), + ('save_cursor', ('sc', {})), + ('scroll_forward', ('ind', {})), + ('scroll_reverse', ('rev', {})), + ('set0_des_seq', ('s0ds', {})), + ('set1_des_seq', ('s1ds', {})), + ('set2_des_seq', ('s2ds', {})), + ('set3_des_seq', ('s3ds', {})), + # this 'color' is deceiving, but often matching, and a better match + # than set_a_attributes1 or set_a_foreground. + ('color', ('_foreground_color', {'nparams': 1, 'match_any': True, + 'numeric': 1})), + ('set_a_foreground', ('color', {'nparams': 1, 'match_any': True, + 'numeric': 1})), + ('set_a_background', ('on_color', {'nparams': 1, 'match_any': True, + 'numeric': 1})), + ('set_tab', ('hts', {})), + ('tab', ('ht', {})), + ('italic', ('sitm', {})), + ('no_italic', ('sitm', {})), +)) + +CAPABILITIES_RAW_MIXIN = { + 'bell': re.escape('\a'), + 'carriage_return': re.escape('\r'), + 'cursor_left': re.escape('\b'), + 'cursor_report': re.escape('\x1b') + r'\[(\d+)\;(\d+)R', + 'cursor_right': re.escape('\x1b') + r'\[C', + 'exit_attribute_mode': re.escape('\x1b') + r'\[m', + 'parm_left_cursor': re.escape('\x1b') + r'\[(\d+)D', + 'parm_right_cursor': re.escape('\x1b') + r'\[(\d+)C', + 'restore_cursor': re.escape(r'\x1b\[u'), + 'save_cursor': re.escape(r'\x1b\[s'), + 'scroll_forward': re.escape('\n'), + 'set0_des_seq': re.escape('\x1b(B'), + 'tab': re.escape('\t'), +} +_ANY_NOTESC = '[^' + re.escape('\x1b') + ']*' + +CAPABILITIES_ADDITIVES = { + 'link': ('link', + re.escape('\x1b') + r'\]8;' + _ANY_NOTESC + ';' + + _ANY_NOTESC + re.escape('\x1b') + '\\\\'), + 'color256': ('color', re.escape('\x1b') + r'\[38;5;\d+m'), + 'on_color256': ('on_color', re.escape('\x1b') + r'\[48;5;\d+m'), + 'color_rgb': ('color_rgb', re.escape('\x1b') + r'\[38;2;\d+;\d+;\d+m'), + 'on_color_rgb': ('on_color_rgb', re.escape('\x1b') + r'\[48;2;\d+;\d+;\d+m'), + 'shift_in': ('', re.escape('\x0f')), + 'shift_out': ('', re.escape('\x0e')), + # sgr(...) outputs strangely, use the basic ANSI/EMCA-48 codes here. + 'set_a_attributes1': ( + 'sgr', re.escape('\x1b') + r'\[\d+m'), + 'set_a_attributes2': ( + 'sgr', re.escape('\x1b') + r'\[\d+\;\d+m'), + 'set_a_attributes3': ( + 'sgr', re.escape('\x1b') + r'\[\d+\;\d+\;\d+m'), + 'set_a_attributes4': ( + 'sgr', re.escape('\x1b') + r'\[\d+\;\d+\;\d+\;\d+m'), + # this helps where xterm's sgr0 includes set0_des_seq, we'd + # rather like to also match this immediate substring. + 'sgr0': ('sgr0', re.escape('\x1b') + r'\[m'), + 'backspace': ('', re.escape('\b')), + 'ascii_tab': ('', re.escape('\t')), + 'clr_eol': ('', re.escape('\x1b[K')), + 'clr_eol0': ('', re.escape('\x1b[0K')), + 'clr_bol': ('', re.escape('\x1b[1K')), + 'clr_eosK': ('', re.escape('\x1b[2K')), +} + +CAPABILITIES_CAUSE_MOVEMENT = ( + 'ascii_tab', + 'backspace', + 'carriage_return', + 'clear_screen', + 'column_address', + 'cursor_address', + 'cursor_down', + 'cursor_home', + 'cursor_left', + 'cursor_right', + 'cursor_up', + 'enter_fullscreen', + 'exit_fullscreen', + 'parm_down_cursor', + 'parm_left_cursor', + 'parm_right_cursor', + 'parm_up_cursor', + 'restore_cursor', + 'row_address', + 'scroll_forward', + 'tab', +) diff --git a/blessed/_capabilities.py:Zone.Identifier b/blessed/_capabilities.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/_capabilities.pyi b/blessed/_capabilities.pyi new file mode 100644 index 0000000..04c59c3 --- /dev/null +++ b/blessed/_capabilities.pyi @@ -0,0 +1,7 @@ +# std imports +from typing import Any, Dict, Tuple, OrderedDict + +CAPABILITY_DATABASE: OrderedDict[str, Tuple[str, Dict[str, Any]]] +CAPABILITIES_RAW_MIXIN: Dict[str, str] +CAPABILITIES_ADDITIVES: Dict[str, Tuple[str, str]] +CAPABILITIES_CAUSE_MOVEMENT: Tuple[str, ...] diff --git a/blessed/_capabilities.pyi:Zone.Identifier b/blessed/_capabilities.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/color.py b/blessed/color.py new file mode 100644 index 0000000..482fc0e --- /dev/null +++ b/blessed/color.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +""" +Sub-module providing color functions. + +References, + +- https://en.wikipedia.org/wiki/Color_difference +- http://www.easyrgb.com/en/math.php +- Measuring Colour by R.W.G. Hunt and M.R. Pointer +""" + +# std imports +from math import cos, exp, sin, sqrt, atan2 + +# isort: off +try: + from functools import lru_cache +except ImportError: + # lru_cache was added in Python 3.2 + from backports.functools_lru_cache import lru_cache + + +def rgb_to_xyz(red, green, blue): + """ + Convert standard RGB color to XYZ color. + + :arg int red: RGB value of Red. + :arg int green: RGB value of Green. + :arg int blue: RGB value of Blue. + :returns: Tuple (X, Y, Z) representing XYZ color + :rtype: tuple + + D65/2° standard illuminant + """ + rgb = [] + for val in red, green, blue: + val /= 255.0 + if val > 0.04045: + val = pow((val + 0.055) / 1.055, 2.4) + else: + val /= 12.92 + val *= 100 + rgb.append(val) + + red, green, blue = rgb # pylint: disable=unbalanced-tuple-unpacking + x_val = red * 0.4124 + green * 0.3576 + blue * 0.1805 + y_val = red * 0.2126 + green * 0.7152 + blue * 0.0722 + z_val = red * 0.0193 + green * 0.1192 + blue * 0.9505 + + return x_val, y_val, z_val + + +def xyz_to_lab(x_val, y_val, z_val): + """ + Convert XYZ color to CIE-Lab color. + + :arg float x_val: XYZ value of X. + :arg float y_val: XYZ value of Y. + :arg float z_val: XYZ value of Z. + :returns: Tuple (L, a, b) representing CIE-Lab color + :rtype: tuple + + D65/2° standard illuminant + """ + xyz = [] + for val, ref in (x_val, 95.047), (y_val, 100.0), (z_val, 108.883): + val /= ref + val = pow(val, 1 / 3.0) if val > 0.008856 else 7.787 * val + 16 / 116.0 + xyz.append(val) + + x_val, y_val, z_val = xyz # pylint: disable=unbalanced-tuple-unpacking + cie_l = 116 * y_val - 16 + cie_a = 500 * (x_val - y_val) + cie_b = 200 * (y_val - z_val) + + return cie_l, cie_a, cie_b + + +@lru_cache(maxsize=256) +def rgb_to_lab(red, green, blue): + """ + Convert RGB color to CIE-Lab color. + + :arg int red: RGB value of Red. + :arg int green: RGB value of Green. + :arg int blue: RGB value of Blue. + :returns: Tuple (L, a, b) representing CIE-Lab color + :rtype: tuple + + D65/2° standard illuminant + """ + return xyz_to_lab(*rgb_to_xyz(red, green, blue)) + + +def dist_rgb(rgb1, rgb2): + """ + Determine distance between two rgb colors. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + This works by treating RGB colors as coordinates in three dimensional + space and finding the closest point within the configured color range + using the formula:: + + d^2 = (r2 - r1)^2 + (g2 - g1)^2 + (b2 - b1)^2 + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + return sum(pow(rgb1[idx] - rgb2[idx], 2) for idx in (0, 1, 2)) + + +def dist_rgb_weighted(rgb1, rgb2): + """ + Determine the weighted distance between two rgb colors. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + Similar to a standard distance formula, the values are weighted + to approximate human perception of color differences + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + red_mean = (rgb1[0] + rgb2[0]) / 2.0 + + return ((2 + red_mean / 256) * pow(rgb1[0] - rgb2[0], 2) + + 4 * pow(rgb1[1] - rgb2[1], 2) + + (2 + (255 - red_mean) / 256) * pow(rgb1[2] - rgb2[2], 2)) + + +def dist_cie76(rgb1, rgb2): + """ + Determine distance between two rgb colors using the CIE94 algorithm. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + l_1, a_1, b_1 = rgb_to_lab(*rgb1) + l_2, a_2, b_2 = rgb_to_lab(*rgb2) + return pow(l_1 - l_2, 2) + pow(a_1 - a_2, 2) + pow(b_1 - b_2, 2) + + +def dist_cie94(rgb1, rgb2): + # pylint: disable=too-many-locals + """ + Determine distance between two rgb colors using the CIE94 algorithm. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + l_1, a_1, b_1 = rgb_to_lab(*rgb1) + l_2, a_2, b_2 = rgb_to_lab(*rgb2) + + s_l = k_l = k_c = k_h = 1 + k_1 = 0.045 + k_2 = 0.015 + + delta_l = l_1 - l_2 + delta_a = a_1 - a_2 + delta_b = b_1 - b_2 + c_1 = sqrt(a_1 ** 2 + b_1 ** 2) + c_2 = sqrt(a_2 ** 2 + b_2 ** 2) + delta_c = c_1 - c_2 + delta_h = sqrt(delta_a ** 2 + delta_b ** 2 + delta_c ** 2) + s_c = 1 + k_1 * c_1 + s_h = 1 + k_2 * c_1 + + return ((delta_l / (k_l * s_l)) ** 2 + # pylint: disable=superfluous-parens + (delta_c / (k_c * s_c)) ** 2 + + (delta_h / (k_h * s_h)) ** 2) + + +def dist_cie2000(rgb1, rgb2): + # pylint: disable=too-many-locals + """ + Determine distance between two rgb colors using the CIE2000 algorithm. + + :arg tuple rgb1: RGB color definition + :arg tuple rgb2: RGB color definition + :returns: Square of the distance between provided colors + :rtype: float + + For efficiency, the square of the distance is returned + which is sufficient for comparisons + """ + s_l = k_l = k_c = k_h = 1 + + l_1, a_1, b_1 = rgb_to_lab(*rgb1) + l_2, a_2, b_2 = rgb_to_lab(*rgb2) + + delta_l = l_2 - l_1 + l_mean = (l_1 + l_2) / 2 + + c_1 = sqrt(a_1 ** 2 + b_1 ** 2) + c_2 = sqrt(a_2 ** 2 + b_2 ** 2) + c_mean = (c_1 + c_2) / 2 + delta_c = c_1 - c_2 + + g_x = sqrt(c_mean ** 7 / (c_mean ** 7 + 25 ** 7)) + h_1 = atan2(b_1, a_1 + (a_1 / 2) * (1 - g_x)) % 360 + h_2 = atan2(b_2, a_2 + (a_2 / 2) * (1 - g_x)) % 360 + + if 0 in (c_1, c_2): + delta_h_prime = 0 + h_mean = h_1 + h_2 + else: + delta_h_prime = h_2 - h_1 + if abs(delta_h_prime) <= 180: + h_mean = (h_1 + h_2) / 2 + else: + if h_2 <= h_1: + delta_h_prime += 360 + else: + delta_h_prime -= 360 + h_mean = (h_1 + h_2 + 360) / 2 if h_1 + h_2 < 360 else (h_1 + h_2 - 360) / 2 + + delta_h = 2 * sqrt(c_1 * c_2) * sin(delta_h_prime / 2) + + t_x = (1 - + 0.17 * cos(h_mean - 30) + + 0.24 * cos(2 * h_mean) + + 0.32 * cos(3 * h_mean + 6) - + 0.20 * cos(4 * h_mean - 63)) + + s_l = 1 + (0.015 * (l_mean - 50) ** 2) / sqrt(20 + (l_mean - 50) ** 2) + s_c = 1 + 0.045 * c_mean + s_h = 1 + 0.015 * c_mean * t_x + r_t = -2 * g_x * sin(abs(60 * exp(-1 * abs((delta_h - 275) / 25) ** 2))) + + delta_l = delta_l / (k_l * s_l) + delta_c = delta_c / (k_c * s_c) + delta_h = delta_h / (k_h * s_h) + + return delta_l ** 2 + delta_c ** 2 + delta_h ** 2 + r_t * delta_c * delta_h + + +COLOR_DISTANCE_ALGORITHMS = {'rgb': dist_rgb, + 'rgb-weighted': dist_rgb_weighted, + 'cie76': dist_cie76, + 'cie94': dist_cie94, + 'cie2000': dist_cie2000} diff --git a/blessed/color.py:Zone.Identifier b/blessed/color.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/color.pyi b/blessed/color.pyi new file mode 100644 index 0000000..ece82e3 --- /dev/null +++ b/blessed/color.pyi @@ -0,0 +1,17 @@ +# std imports +from typing import Dict, Tuple, Callable + +_RGB = Tuple[int, int, int] + +def rgb_to_xyz(red: int, green: int, blue: int) -> Tuple[float, float, float]: ... +def xyz_to_lab( + x_val: float, y_val: float, z_val: float +) -> Tuple[float, float, float]: ... +def rgb_to_lab(red: int, green: int, blue: int) -> Tuple[float, float, float]: ... +def dist_rgb(rgb1: _RGB, rgb2: _RGB) -> float: ... +def dist_rgb_weighted(rgb1: _RGB, rgb2: _RGB) -> float: ... +def dist_cie76(rgb1: _RGB, rgb2: _RGB) -> float: ... +def dist_cie94(rgb1: _RGB, rgb2: _RGB) -> float: ... +def dist_cie2000(rgb1: _RGB, rgb2: _RGB) -> float: ... + +COLOR_DISTANCE_ALGORITHMS: Dict[str, Callable[[_RGB, _RGB], float]] diff --git a/blessed/color.pyi:Zone.Identifier b/blessed/color.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/colorspace.py b/blessed/colorspace.py new file mode 100644 index 0000000..f95bfd9 --- /dev/null +++ b/blessed/colorspace.py @@ -0,0 +1,973 @@ +""" +Color reference data. + +References, + +- https://github.com/freedesktop/xorg-rgb/blob/master/rgb.txt +- https://github.com/ThomasDickey/xterm-snapshots/blob/master/256colres.h +- https://github.com/ThomasDickey/xterm-snapshots/blob/master/XTerm-col.ad +- https://en.wikipedia.org/wiki/ANSI_escape_code#Colors +- https://gist.github.com/XVilka/8346728 +- https://devblogs.microsoft.com/commandline/24-bit-color-in-the-windows-console/ +- http://jdebp.uk/Softwares/nosh/guide/TerminalCapabilities.html +""" + +# std imports +import collections + +__all__ = ( + 'CGA_COLORS', + 'RGBColor', + 'RGB_256TABLE', + 'X11_COLORNAMES_TO_RGB', +) + +CGA_COLORS = {'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'} + + +class RGBColor(collections.namedtuple("RGBColor", ["red", "green", "blue"])): + """Named tuple for an RGB color definition.""" + + def __str__(self): + return '#{0:02x}{1:02x}{2:02x}'.format(*self) + + +#: X11 Color names to (XTerm-defined) RGB values from xorg-rgb/rgb.txt +X11_COLORNAMES_TO_RGB = { + 'aliceblue': RGBColor(240, 248, 255), + 'antiquewhite': RGBColor(250, 235, 215), + 'antiquewhite1': RGBColor(255, 239, 219), + 'antiquewhite2': RGBColor(238, 223, 204), + 'antiquewhite3': RGBColor(205, 192, 176), + 'antiquewhite4': RGBColor(139, 131, 120), + 'aqua': RGBColor(0, 255, 255), + 'aquamarine': RGBColor(127, 255, 212), + 'aquamarine1': RGBColor(127, 255, 212), + 'aquamarine2': RGBColor(118, 238, 198), + 'aquamarine3': RGBColor(102, 205, 170), + 'aquamarine4': RGBColor(69, 139, 116), + 'azure': RGBColor(240, 255, 255), + 'azure1': RGBColor(240, 255, 255), + 'azure2': RGBColor(224, 238, 238), + 'azure3': RGBColor(193, 205, 205), + 'azure4': RGBColor(131, 139, 139), + 'beige': RGBColor(245, 245, 220), + 'bisque': RGBColor(255, 228, 196), + 'bisque1': RGBColor(255, 228, 196), + 'bisque2': RGBColor(238, 213, 183), + 'bisque3': RGBColor(205, 183, 158), + 'bisque4': RGBColor(139, 125, 107), + 'black': RGBColor(0, 0, 0), + 'blanchedalmond': RGBColor(255, 235, 205), + 'blue': RGBColor(0, 0, 255), + 'blue1': RGBColor(0, 0, 255), + 'blue2': RGBColor(0, 0, 238), + 'blue3': RGBColor(0, 0, 205), + 'blue4': RGBColor(0, 0, 139), + 'blueviolet': RGBColor(138, 43, 226), + 'brown': RGBColor(165, 42, 42), + 'brown1': RGBColor(255, 64, 64), + 'brown2': RGBColor(238, 59, 59), + 'brown3': RGBColor(205, 51, 51), + 'brown4': RGBColor(139, 35, 35), + 'burlywood': RGBColor(222, 184, 135), + 'burlywood1': RGBColor(255, 211, 155), + 'burlywood2': RGBColor(238, 197, 145), + 'burlywood3': RGBColor(205, 170, 125), + 'burlywood4': RGBColor(139, 115, 85), + 'cadetblue': RGBColor(95, 158, 160), + 'cadetblue1': RGBColor(152, 245, 255), + 'cadetblue2': RGBColor(142, 229, 238), + 'cadetblue3': RGBColor(122, 197, 205), + 'cadetblue4': RGBColor(83, 134, 139), + 'chartreuse': RGBColor(127, 255, 0), + 'chartreuse1': RGBColor(127, 255, 0), + 'chartreuse2': RGBColor(118, 238, 0), + 'chartreuse3': RGBColor(102, 205, 0), + 'chartreuse4': RGBColor(69, 139, 0), + 'chocolate': RGBColor(210, 105, 30), + 'chocolate1': RGBColor(255, 127, 36), + 'chocolate2': RGBColor(238, 118, 33), + 'chocolate3': RGBColor(205, 102, 29), + 'chocolate4': RGBColor(139, 69, 19), + 'coral': RGBColor(255, 127, 80), + 'coral1': RGBColor(255, 114, 86), + 'coral2': RGBColor(238, 106, 80), + 'coral3': RGBColor(205, 91, 69), + 'coral4': RGBColor(139, 62, 47), + 'cornflowerblue': RGBColor(100, 149, 237), + 'cornsilk': RGBColor(255, 248, 220), + 'cornsilk1': RGBColor(255, 248, 220), + 'cornsilk2': RGBColor(238, 232, 205), + 'cornsilk3': RGBColor(205, 200, 177), + 'cornsilk4': RGBColor(139, 136, 120), + 'crimson': RGBColor(220, 20, 60), + 'cyan': RGBColor(0, 255, 255), + 'cyan1': RGBColor(0, 255, 255), + 'cyan2': RGBColor(0, 238, 238), + 'cyan3': RGBColor(0, 205, 205), + 'cyan4': RGBColor(0, 139, 139), + 'darkblue': RGBColor(0, 0, 139), + 'darkcyan': RGBColor(0, 139, 139), + 'darkgoldenrod': RGBColor(184, 134, 11), + 'darkgoldenrod1': RGBColor(255, 185, 15), + 'darkgoldenrod2': RGBColor(238, 173, 14), + 'darkgoldenrod3': RGBColor(205, 149, 12), + 'darkgoldenrod4': RGBColor(139, 101, 8), + 'darkgray': RGBColor(169, 169, 169), + 'darkgreen': RGBColor(0, 100, 0), + 'darkgrey': RGBColor(169, 169, 169), + 'darkkhaki': RGBColor(189, 183, 107), + 'darkmagenta': RGBColor(139, 0, 139), + 'darkolivegreen': RGBColor(85, 107, 47), + 'darkolivegreen1': RGBColor(202, 255, 112), + 'darkolivegreen2': RGBColor(188, 238, 104), + 'darkolivegreen3': RGBColor(162, 205, 90), + 'darkolivegreen4': RGBColor(110, 139, 61), + 'darkorange': RGBColor(255, 140, 0), + 'darkorange1': RGBColor(255, 127, 0), + 'darkorange2': RGBColor(238, 118, 0), + 'darkorange3': RGBColor(205, 102, 0), + 'darkorange4': RGBColor(139, 69, 0), + 'darkorchid': RGBColor(153, 50, 204), + 'darkorchid1': RGBColor(191, 62, 255), + 'darkorchid2': RGBColor(178, 58, 238), + 'darkorchid3': RGBColor(154, 50, 205), + 'darkorchid4': RGBColor(104, 34, 139), + 'darkred': RGBColor(139, 0, 0), + 'darksalmon': RGBColor(233, 150, 122), + 'darkseagreen': RGBColor(143, 188, 143), + 'darkseagreen1': RGBColor(193, 255, 193), + 'darkseagreen2': RGBColor(180, 238, 180), + 'darkseagreen3': RGBColor(155, 205, 155), + 'darkseagreen4': RGBColor(105, 139, 105), + 'darkslateblue': RGBColor(72, 61, 139), + 'darkslategray': RGBColor(47, 79, 79), + 'darkslategray1': RGBColor(151, 255, 255), + 'darkslategray2': RGBColor(141, 238, 238), + 'darkslategray3': RGBColor(121, 205, 205), + 'darkslategray4': RGBColor(82, 139, 139), + 'darkslategrey': RGBColor(47, 79, 79), + 'darkturquoise': RGBColor(0, 206, 209), + 'darkviolet': RGBColor(148, 0, 211), + 'deeppink': RGBColor(255, 20, 147), + 'deeppink1': RGBColor(255, 20, 147), + 'deeppink2': RGBColor(238, 18, 137), + 'deeppink3': RGBColor(205, 16, 118), + 'deeppink4': RGBColor(139, 10, 80), + 'deepskyblue': RGBColor(0, 191, 255), + 'deepskyblue1': RGBColor(0, 191, 255), + 'deepskyblue2': RGBColor(0, 178, 238), + 'deepskyblue3': RGBColor(0, 154, 205), + 'deepskyblue4': RGBColor(0, 104, 139), + 'dimgray': RGBColor(105, 105, 105), + 'dimgrey': RGBColor(105, 105, 105), + 'dodgerblue': RGBColor(30, 144, 255), + 'dodgerblue1': RGBColor(30, 144, 255), + 'dodgerblue2': RGBColor(28, 134, 238), + 'dodgerblue3': RGBColor(24, 116, 205), + 'dodgerblue4': RGBColor(16, 78, 139), + 'firebrick': RGBColor(178, 34, 34), + 'firebrick1': RGBColor(255, 48, 48), + 'firebrick2': RGBColor(238, 44, 44), + 'firebrick3': RGBColor(205, 38, 38), + 'firebrick4': RGBColor(139, 26, 26), + 'floralwhite': RGBColor(255, 250, 240), + 'forestgreen': RGBColor(34, 139, 34), + 'fuchsia': RGBColor(255, 0, 255), + 'gainsboro': RGBColor(220, 220, 220), + 'ghostwhite': RGBColor(248, 248, 255), + 'gold': RGBColor(255, 215, 0), + 'gold1': RGBColor(255, 215, 0), + 'gold2': RGBColor(238, 201, 0), + 'gold3': RGBColor(205, 173, 0), + 'gold4': RGBColor(139, 117, 0), + 'goldenrod': RGBColor(218, 165, 32), + 'goldenrod1': RGBColor(255, 193, 37), + 'goldenrod2': RGBColor(238, 180, 34), + 'goldenrod3': RGBColor(205, 155, 29), + 'goldenrod4': RGBColor(139, 105, 20), + 'gray': RGBColor(190, 190, 190), + 'gray0': RGBColor(0, 0, 0), + 'gray1': RGBColor(3, 3, 3), + 'gray10': RGBColor(26, 26, 26), + 'gray100': RGBColor(255, 255, 255), + 'gray11': RGBColor(28, 28, 28), + 'gray12': RGBColor(31, 31, 31), + 'gray13': RGBColor(33, 33, 33), + 'gray14': RGBColor(36, 36, 36), + 'gray15': RGBColor(38, 38, 38), + 'gray16': RGBColor(41, 41, 41), + 'gray17': RGBColor(43, 43, 43), + 'gray18': RGBColor(46, 46, 46), + 'gray19': RGBColor(48, 48, 48), + 'gray2': RGBColor(5, 5, 5), + 'gray20': RGBColor(51, 51, 51), + 'gray21': RGBColor(54, 54, 54), + 'gray22': RGBColor(56, 56, 56), + 'gray23': RGBColor(59, 59, 59), + 'gray24': RGBColor(61, 61, 61), + 'gray25': RGBColor(64, 64, 64), + 'gray26': RGBColor(66, 66, 66), + 'gray27': RGBColor(69, 69, 69), + 'gray28': RGBColor(71, 71, 71), + 'gray29': RGBColor(74, 74, 74), + 'gray3': RGBColor(8, 8, 8), + 'gray30': RGBColor(77, 77, 77), + 'gray31': RGBColor(79, 79, 79), + 'gray32': RGBColor(82, 82, 82), + 'gray33': RGBColor(84, 84, 84), + 'gray34': RGBColor(87, 87, 87), + 'gray35': RGBColor(89, 89, 89), + 'gray36': RGBColor(92, 92, 92), + 'gray37': RGBColor(94, 94, 94), + 'gray38': RGBColor(97, 97, 97), + 'gray39': RGBColor(99, 99, 99), + 'gray4': RGBColor(10, 10, 10), + 'gray40': RGBColor(102, 102, 102), + 'gray41': RGBColor(105, 105, 105), + 'gray42': RGBColor(107, 107, 107), + 'gray43': RGBColor(110, 110, 110), + 'gray44': RGBColor(112, 112, 112), + 'gray45': RGBColor(115, 115, 115), + 'gray46': RGBColor(117, 117, 117), + 'gray47': RGBColor(120, 120, 120), + 'gray48': RGBColor(122, 122, 122), + 'gray49': RGBColor(125, 125, 125), + 'gray5': RGBColor(13, 13, 13), + 'gray50': RGBColor(127, 127, 127), + 'gray51': RGBColor(130, 130, 130), + 'gray52': RGBColor(133, 133, 133), + 'gray53': RGBColor(135, 135, 135), + 'gray54': RGBColor(138, 138, 138), + 'gray55': RGBColor(140, 140, 140), + 'gray56': RGBColor(143, 143, 143), + 'gray57': RGBColor(145, 145, 145), + 'gray58': RGBColor(148, 148, 148), + 'gray59': RGBColor(150, 150, 150), + 'gray6': RGBColor(15, 15, 15), + 'gray60': RGBColor(153, 153, 153), + 'gray61': RGBColor(156, 156, 156), + 'gray62': RGBColor(158, 158, 158), + 'gray63': RGBColor(161, 161, 161), + 'gray64': RGBColor(163, 163, 163), + 'gray65': RGBColor(166, 166, 166), + 'gray66': RGBColor(168, 168, 168), + 'gray67': RGBColor(171, 171, 171), + 'gray68': RGBColor(173, 173, 173), + 'gray69': RGBColor(176, 176, 176), + 'gray7': RGBColor(18, 18, 18), + 'gray70': RGBColor(179, 179, 179), + 'gray71': RGBColor(181, 181, 181), + 'gray72': RGBColor(184, 184, 184), + 'gray73': RGBColor(186, 186, 186), + 'gray74': RGBColor(189, 189, 189), + 'gray75': RGBColor(191, 191, 191), + 'gray76': RGBColor(194, 194, 194), + 'gray77': RGBColor(196, 196, 196), + 'gray78': RGBColor(199, 199, 199), + 'gray79': RGBColor(201, 201, 201), + 'gray8': RGBColor(20, 20, 20), + 'gray80': RGBColor(204, 204, 204), + 'gray81': RGBColor(207, 207, 207), + 'gray82': RGBColor(209, 209, 209), + 'gray83': RGBColor(212, 212, 212), + 'gray84': RGBColor(214, 214, 214), + 'gray85': RGBColor(217, 217, 217), + 'gray86': RGBColor(219, 219, 219), + 'gray87': RGBColor(222, 222, 222), + 'gray88': RGBColor(224, 224, 224), + 'gray89': RGBColor(227, 227, 227), + 'gray9': RGBColor(23, 23, 23), + 'gray90': RGBColor(229, 229, 229), + 'gray91': RGBColor(232, 232, 232), + 'gray92': RGBColor(235, 235, 235), + 'gray93': RGBColor(237, 237, 237), + 'gray94': RGBColor(240, 240, 240), + 'gray95': RGBColor(242, 242, 242), + 'gray96': RGBColor(245, 245, 245), + 'gray97': RGBColor(247, 247, 247), + 'gray98': RGBColor(250, 250, 250), + 'gray99': RGBColor(252, 252, 252), + 'green': RGBColor(0, 255, 0), + 'green1': RGBColor(0, 255, 0), + 'green2': RGBColor(0, 238, 0), + 'green3': RGBColor(0, 205, 0), + 'green4': RGBColor(0, 139, 0), + 'greenyellow': RGBColor(173, 255, 47), + 'grey': RGBColor(190, 190, 190), + 'grey0': RGBColor(0, 0, 0), + 'grey1': RGBColor(3, 3, 3), + 'grey10': RGBColor(26, 26, 26), + 'grey100': RGBColor(255, 255, 255), + 'grey11': RGBColor(28, 28, 28), + 'grey12': RGBColor(31, 31, 31), + 'grey13': RGBColor(33, 33, 33), + 'grey14': RGBColor(36, 36, 36), + 'grey15': RGBColor(38, 38, 38), + 'grey16': RGBColor(41, 41, 41), + 'grey17': RGBColor(43, 43, 43), + 'grey18': RGBColor(46, 46, 46), + 'grey19': RGBColor(48, 48, 48), + 'grey2': RGBColor(5, 5, 5), + 'grey20': RGBColor(51, 51, 51), + 'grey21': RGBColor(54, 54, 54), + 'grey22': RGBColor(56, 56, 56), + 'grey23': RGBColor(59, 59, 59), + 'grey24': RGBColor(61, 61, 61), + 'grey25': RGBColor(64, 64, 64), + 'grey26': RGBColor(66, 66, 66), + 'grey27': RGBColor(69, 69, 69), + 'grey28': RGBColor(71, 71, 71), + 'grey29': RGBColor(74, 74, 74), + 'grey3': RGBColor(8, 8, 8), + 'grey30': RGBColor(77, 77, 77), + 'grey31': RGBColor(79, 79, 79), + 'grey32': RGBColor(82, 82, 82), + 'grey33': RGBColor(84, 84, 84), + 'grey34': RGBColor(87, 87, 87), + 'grey35': RGBColor(89, 89, 89), + 'grey36': RGBColor(92, 92, 92), + 'grey37': RGBColor(94, 94, 94), + 'grey38': RGBColor(97, 97, 97), + 'grey39': RGBColor(99, 99, 99), + 'grey4': RGBColor(10, 10, 10), + 'grey40': RGBColor(102, 102, 102), + 'grey41': RGBColor(105, 105, 105), + 'grey42': RGBColor(107, 107, 107), + 'grey43': RGBColor(110, 110, 110), + 'grey44': RGBColor(112, 112, 112), + 'grey45': RGBColor(115, 115, 115), + 'grey46': RGBColor(117, 117, 117), + 'grey47': RGBColor(120, 120, 120), + 'grey48': RGBColor(122, 122, 122), + 'grey49': RGBColor(125, 125, 125), + 'grey5': RGBColor(13, 13, 13), + 'grey50': RGBColor(127, 127, 127), + 'grey51': RGBColor(130, 130, 130), + 'grey52': RGBColor(133, 133, 133), + 'grey53': RGBColor(135, 135, 135), + 'grey54': RGBColor(138, 138, 138), + 'grey55': RGBColor(140, 140, 140), + 'grey56': RGBColor(143, 143, 143), + 'grey57': RGBColor(145, 145, 145), + 'grey58': RGBColor(148, 148, 148), + 'grey59': RGBColor(150, 150, 150), + 'grey6': RGBColor(15, 15, 15), + 'grey60': RGBColor(153, 153, 153), + 'grey61': RGBColor(156, 156, 156), + 'grey62': RGBColor(158, 158, 158), + 'grey63': RGBColor(161, 161, 161), + 'grey64': RGBColor(163, 163, 163), + 'grey65': RGBColor(166, 166, 166), + 'grey66': RGBColor(168, 168, 168), + 'grey67': RGBColor(171, 171, 171), + 'grey68': RGBColor(173, 173, 173), + 'grey69': RGBColor(176, 176, 176), + 'grey7': RGBColor(18, 18, 18), + 'grey70': RGBColor(179, 179, 179), + 'grey71': RGBColor(181, 181, 181), + 'grey72': RGBColor(184, 184, 184), + 'grey73': RGBColor(186, 186, 186), + 'grey74': RGBColor(189, 189, 189), + 'grey75': RGBColor(191, 191, 191), + 'grey76': RGBColor(194, 194, 194), + 'grey77': RGBColor(196, 196, 196), + 'grey78': RGBColor(199, 199, 199), + 'grey79': RGBColor(201, 201, 201), + 'grey8': RGBColor(20, 20, 20), + 'grey80': RGBColor(204, 204, 204), + 'grey81': RGBColor(207, 207, 207), + 'grey82': RGBColor(209, 209, 209), + 'grey83': RGBColor(212, 212, 212), + 'grey84': RGBColor(214, 214, 214), + 'grey85': RGBColor(217, 217, 217), + 'grey86': RGBColor(219, 219, 219), + 'grey87': RGBColor(222, 222, 222), + 'grey88': RGBColor(224, 224, 224), + 'grey89': RGBColor(227, 227, 227), + 'grey9': RGBColor(23, 23, 23), + 'grey90': RGBColor(229, 229, 229), + 'grey91': RGBColor(232, 232, 232), + 'grey92': RGBColor(235, 235, 235), + 'grey93': RGBColor(237, 237, 237), + 'grey94': RGBColor(240, 240, 240), + 'grey95': RGBColor(242, 242, 242), + 'grey96': RGBColor(245, 245, 245), + 'grey97': RGBColor(247, 247, 247), + 'grey98': RGBColor(250, 250, 250), + 'grey99': RGBColor(252, 252, 252), + 'honeydew': RGBColor(240, 255, 240), + 'honeydew1': RGBColor(240, 255, 240), + 'honeydew2': RGBColor(224, 238, 224), + 'honeydew3': RGBColor(193, 205, 193), + 'honeydew4': RGBColor(131, 139, 131), + 'hotpink': RGBColor(255, 105, 180), + 'hotpink1': RGBColor(255, 110, 180), + 'hotpink2': RGBColor(238, 106, 167), + 'hotpink3': RGBColor(205, 96, 144), + 'hotpink4': RGBColor(139, 58, 98), + 'indianred': RGBColor(205, 92, 92), + 'indianred1': RGBColor(255, 106, 106), + 'indianred2': RGBColor(238, 99, 99), + 'indianred3': RGBColor(205, 85, 85), + 'indianred4': RGBColor(139, 58, 58), + 'indigo': RGBColor(75, 0, 130), + 'ivory': RGBColor(255, 255, 240), + 'ivory1': RGBColor(255, 255, 240), + 'ivory2': RGBColor(238, 238, 224), + 'ivory3': RGBColor(205, 205, 193), + 'ivory4': RGBColor(139, 139, 131), + 'khaki': RGBColor(240, 230, 140), + 'khaki1': RGBColor(255, 246, 143), + 'khaki2': RGBColor(238, 230, 133), + 'khaki3': RGBColor(205, 198, 115), + 'khaki4': RGBColor(139, 134, 78), + 'lavender': RGBColor(230, 230, 250), + 'lavenderblush': RGBColor(255, 240, 245), + 'lavenderblush1': RGBColor(255, 240, 245), + 'lavenderblush2': RGBColor(238, 224, 229), + 'lavenderblush3': RGBColor(205, 193, 197), + 'lavenderblush4': RGBColor(139, 131, 134), + 'lawngreen': RGBColor(124, 252, 0), + 'lemonchiffon': RGBColor(255, 250, 205), + 'lemonchiffon1': RGBColor(255, 250, 205), + 'lemonchiffon2': RGBColor(238, 233, 191), + 'lemonchiffon3': RGBColor(205, 201, 165), + 'lemonchiffon4': RGBColor(139, 137, 112), + 'lightblue': RGBColor(173, 216, 230), + 'lightblue1': RGBColor(191, 239, 255), + 'lightblue2': RGBColor(178, 223, 238), + 'lightblue3': RGBColor(154, 192, 205), + 'lightblue4': RGBColor(104, 131, 139), + 'lightcoral': RGBColor(240, 128, 128), + 'lightcyan': RGBColor(224, 255, 255), + 'lightcyan1': RGBColor(224, 255, 255), + 'lightcyan2': RGBColor(209, 238, 238), + 'lightcyan3': RGBColor(180, 205, 205), + 'lightcyan4': RGBColor(122, 139, 139), + 'lightgoldenrod': RGBColor(238, 221, 130), + 'lightgoldenrod1': RGBColor(255, 236, 139), + 'lightgoldenrod2': RGBColor(238, 220, 130), + 'lightgoldenrod3': RGBColor(205, 190, 112), + 'lightgoldenrod4': RGBColor(139, 129, 76), + 'lightgoldenrodyellow': RGBColor(250, 250, 210), + 'lightgray': RGBColor(211, 211, 211), + 'lightgreen': RGBColor(144, 238, 144), + 'lightgrey': RGBColor(211, 211, 211), + 'lightpink': RGBColor(255, 182, 193), + 'lightpink1': RGBColor(255, 174, 185), + 'lightpink2': RGBColor(238, 162, 173), + 'lightpink3': RGBColor(205, 140, 149), + 'lightpink4': RGBColor(139, 95, 101), + 'lightsalmon': RGBColor(255, 160, 122), + 'lightsalmon1': RGBColor(255, 160, 122), + 'lightsalmon2': RGBColor(238, 149, 114), + 'lightsalmon3': RGBColor(205, 129, 98), + 'lightsalmon4': RGBColor(139, 87, 66), + 'lightseagreen': RGBColor(32, 178, 170), + 'lightskyblue': RGBColor(135, 206, 250), + 'lightskyblue1': RGBColor(176, 226, 255), + 'lightskyblue2': RGBColor(164, 211, 238), + 'lightskyblue3': RGBColor(141, 182, 205), + 'lightskyblue4': RGBColor(96, 123, 139), + 'lightslateblue': RGBColor(132, 112, 255), + 'lightslategray': RGBColor(119, 136, 153), + 'lightslategrey': RGBColor(119, 136, 153), + 'lightsteelblue': RGBColor(176, 196, 222), + 'lightsteelblue1': RGBColor(202, 225, 255), + 'lightsteelblue2': RGBColor(188, 210, 238), + 'lightsteelblue3': RGBColor(162, 181, 205), + 'lightsteelblue4': RGBColor(110, 123, 139), + 'lightyellow': RGBColor(255, 255, 224), + 'lightyellow1': RGBColor(255, 255, 224), + 'lightyellow2': RGBColor(238, 238, 209), + 'lightyellow3': RGBColor(205, 205, 180), + 'lightyellow4': RGBColor(139, 139, 122), + 'lime': RGBColor(0, 255, 0), + 'limegreen': RGBColor(50, 205, 50), + 'linen': RGBColor(250, 240, 230), + 'magenta': RGBColor(255, 0, 255), + 'magenta1': RGBColor(255, 0, 255), + 'magenta2': RGBColor(238, 0, 238), + 'magenta3': RGBColor(205, 0, 205), + 'magenta4': RGBColor(139, 0, 139), + 'maroon': RGBColor(176, 48, 96), + 'maroon1': RGBColor(255, 52, 179), + 'maroon2': RGBColor(238, 48, 167), + 'maroon3': RGBColor(205, 41, 144), + 'maroon4': RGBColor(139, 28, 98), + 'mediumaquamarine': RGBColor(102, 205, 170), + 'mediumblue': RGBColor(0, 0, 205), + 'mediumorchid': RGBColor(186, 85, 211), + 'mediumorchid1': RGBColor(224, 102, 255), + 'mediumorchid2': RGBColor(209, 95, 238), + 'mediumorchid3': RGBColor(180, 82, 205), + 'mediumorchid4': RGBColor(122, 55, 139), + 'mediumpurple': RGBColor(147, 112, 219), + 'mediumpurple1': RGBColor(171, 130, 255), + 'mediumpurple2': RGBColor(159, 121, 238), + 'mediumpurple3': RGBColor(137, 104, 205), + 'mediumpurple4': RGBColor(93, 71, 139), + 'mediumseagreen': RGBColor(60, 179, 113), + 'mediumslateblue': RGBColor(123, 104, 238), + 'mediumspringgreen': RGBColor(0, 250, 154), + 'mediumturquoise': RGBColor(72, 209, 204), + 'mediumvioletred': RGBColor(199, 21, 133), + 'midnightblue': RGBColor(25, 25, 112), + 'mintcream': RGBColor(245, 255, 250), + 'mistyrose': RGBColor(255, 228, 225), + 'mistyrose1': RGBColor(255, 228, 225), + 'mistyrose2': RGBColor(238, 213, 210), + 'mistyrose3': RGBColor(205, 183, 181), + 'mistyrose4': RGBColor(139, 125, 123), + 'moccasin': RGBColor(255, 228, 181), + 'navajowhite': RGBColor(255, 222, 173), + 'navajowhite1': RGBColor(255, 222, 173), + 'navajowhite2': RGBColor(238, 207, 161), + 'navajowhite3': RGBColor(205, 179, 139), + 'navajowhite4': RGBColor(139, 121, 94), + 'navy': RGBColor(0, 0, 128), + 'navyblue': RGBColor(0, 0, 128), + 'oldlace': RGBColor(253, 245, 230), + 'olive': RGBColor(128, 128, 0), + 'olivedrab': RGBColor(107, 142, 35), + 'olivedrab1': RGBColor(192, 255, 62), + 'olivedrab2': RGBColor(179, 238, 58), + 'olivedrab3': RGBColor(154, 205, 50), + 'olivedrab4': RGBColor(105, 139, 34), + 'orange': RGBColor(255, 165, 0), + 'orange1': RGBColor(255, 165, 0), + 'orange2': RGBColor(238, 154, 0), + 'orange3': RGBColor(205, 133, 0), + 'orange4': RGBColor(139, 90, 0), + 'orangered': RGBColor(255, 69, 0), + 'orangered1': RGBColor(255, 69, 0), + 'orangered2': RGBColor(238, 64, 0), + 'orangered3': RGBColor(205, 55, 0), + 'orangered4': RGBColor(139, 37, 0), + 'orchid': RGBColor(218, 112, 214), + 'orchid1': RGBColor(255, 131, 250), + 'orchid2': RGBColor(238, 122, 233), + 'orchid3': RGBColor(205, 105, 201), + 'orchid4': RGBColor(139, 71, 137), + 'palegoldenrod': RGBColor(238, 232, 170), + 'palegreen': RGBColor(152, 251, 152), + 'palegreen1': RGBColor(154, 255, 154), + 'palegreen2': RGBColor(144, 238, 144), + 'palegreen3': RGBColor(124, 205, 124), + 'palegreen4': RGBColor(84, 139, 84), + 'paleturquoise': RGBColor(175, 238, 238), + 'paleturquoise1': RGBColor(187, 255, 255), + 'paleturquoise2': RGBColor(174, 238, 238), + 'paleturquoise3': RGBColor(150, 205, 205), + 'paleturquoise4': RGBColor(102, 139, 139), + 'palevioletred': RGBColor(219, 112, 147), + 'palevioletred1': RGBColor(255, 130, 171), + 'palevioletred2': RGBColor(238, 121, 159), + 'palevioletred3': RGBColor(205, 104, 137), + 'palevioletred4': RGBColor(139, 71, 93), + 'papayawhip': RGBColor(255, 239, 213), + 'peachpuff': RGBColor(255, 218, 185), + 'peachpuff1': RGBColor(255, 218, 185), + 'peachpuff2': RGBColor(238, 203, 173), + 'peachpuff3': RGBColor(205, 175, 149), + 'peachpuff4': RGBColor(139, 119, 101), + 'peru': RGBColor(205, 133, 63), + 'pink': RGBColor(255, 192, 203), + 'pink1': RGBColor(255, 181, 197), + 'pink2': RGBColor(238, 169, 184), + 'pink3': RGBColor(205, 145, 158), + 'pink4': RGBColor(139, 99, 108), + 'plum': RGBColor(221, 160, 221), + 'plum1': RGBColor(255, 187, 255), + 'plum2': RGBColor(238, 174, 238), + 'plum3': RGBColor(205, 150, 205), + 'plum4': RGBColor(139, 102, 139), + 'powderblue': RGBColor(176, 224, 230), + 'purple': RGBColor(160, 32, 240), + 'purple1': RGBColor(155, 48, 255), + 'purple2': RGBColor(145, 44, 238), + 'purple3': RGBColor(125, 38, 205), + 'purple4': RGBColor(85, 26, 139), + 'rebeccapurple': RGBColor(102, 51, 153), + 'red': RGBColor(255, 0, 0), + 'red1': RGBColor(255, 0, 0), + 'red2': RGBColor(238, 0, 0), + 'red3': RGBColor(205, 0, 0), + 'red4': RGBColor(139, 0, 0), + 'rosybrown': RGBColor(188, 143, 143), + 'rosybrown1': RGBColor(255, 193, 193), + 'rosybrown2': RGBColor(238, 180, 180), + 'rosybrown3': RGBColor(205, 155, 155), + 'rosybrown4': RGBColor(139, 105, 105), + 'royalblue': RGBColor(65, 105, 225), + 'royalblue1': RGBColor(72, 118, 255), + 'royalblue2': RGBColor(67, 110, 238), + 'royalblue3': RGBColor(58, 95, 205), + 'royalblue4': RGBColor(39, 64, 139), + 'saddlebrown': RGBColor(139, 69, 19), + 'salmon': RGBColor(250, 128, 114), + 'salmon1': RGBColor(255, 140, 105), + 'salmon2': RGBColor(238, 130, 98), + 'salmon3': RGBColor(205, 112, 84), + 'salmon4': RGBColor(139, 76, 57), + 'sandybrown': RGBColor(244, 164, 96), + 'seagreen': RGBColor(46, 139, 87), + 'seagreen1': RGBColor(84, 255, 159), + 'seagreen2': RGBColor(78, 238, 148), + 'seagreen3': RGBColor(67, 205, 128), + 'seagreen4': RGBColor(46, 139, 87), + 'seashell': RGBColor(255, 245, 238), + 'seashell1': RGBColor(255, 245, 238), + 'seashell2': RGBColor(238, 229, 222), + 'seashell3': RGBColor(205, 197, 191), + 'seashell4': RGBColor(139, 134, 130), + 'sienna': RGBColor(160, 82, 45), + 'sienna1': RGBColor(255, 130, 71), + 'sienna2': RGBColor(238, 121, 66), + 'sienna3': RGBColor(205, 104, 57), + 'sienna4': RGBColor(139, 71, 38), + 'silver': RGBColor(192, 192, 192), + 'skyblue': RGBColor(135, 206, 235), + 'skyblue1': RGBColor(135, 206, 255), + 'skyblue2': RGBColor(126, 192, 238), + 'skyblue3': RGBColor(108, 166, 205), + 'skyblue4': RGBColor(74, 112, 139), + 'slateblue': RGBColor(106, 90, 205), + 'slateblue1': RGBColor(131, 111, 255), + 'slateblue2': RGBColor(122, 103, 238), + 'slateblue3': RGBColor(105, 89, 205), + 'slateblue4': RGBColor(71, 60, 139), + 'slategray': RGBColor(112, 128, 144), + 'slategray1': RGBColor(198, 226, 255), + 'slategray2': RGBColor(185, 211, 238), + 'slategray3': RGBColor(159, 182, 205), + 'slategray4': RGBColor(108, 123, 139), + 'slategrey': RGBColor(112, 128, 144), + 'snow': RGBColor(255, 250, 250), + 'snow1': RGBColor(255, 250, 250), + 'snow2': RGBColor(238, 233, 233), + 'snow3': RGBColor(205, 201, 201), + 'snow4': RGBColor(139, 137, 137), + 'springgreen': RGBColor(0, 255, 127), + 'springgreen1': RGBColor(0, 255, 127), + 'springgreen2': RGBColor(0, 238, 118), + 'springgreen3': RGBColor(0, 205, 102), + 'springgreen4': RGBColor(0, 139, 69), + 'steelblue': RGBColor(70, 130, 180), + 'steelblue1': RGBColor(99, 184, 255), + 'steelblue2': RGBColor(92, 172, 238), + 'steelblue3': RGBColor(79, 148, 205), + 'steelblue4': RGBColor(54, 100, 139), + 'tan': RGBColor(210, 180, 140), + 'tan1': RGBColor(255, 165, 79), + 'tan2': RGBColor(238, 154, 73), + 'tan3': RGBColor(205, 133, 63), + 'tan4': RGBColor(139, 90, 43), + 'teal': RGBColor(0, 128, 128), + 'thistle': RGBColor(216, 191, 216), + 'thistle1': RGBColor(255, 225, 255), + 'thistle2': RGBColor(238, 210, 238), + 'thistle3': RGBColor(205, 181, 205), + 'thistle4': RGBColor(139, 123, 139), + 'tomato': RGBColor(255, 99, 71), + 'tomato1': RGBColor(255, 99, 71), + 'tomato2': RGBColor(238, 92, 66), + 'tomato3': RGBColor(205, 79, 57), + 'tomato4': RGBColor(139, 54, 38), + 'turquoise': RGBColor(64, 224, 208), + 'turquoise1': RGBColor(0, 245, 255), + 'turquoise2': RGBColor(0, 229, 238), + 'turquoise3': RGBColor(0, 197, 205), + 'turquoise4': RGBColor(0, 134, 139), + 'violet': RGBColor(238, 130, 238), + 'violetred': RGBColor(208, 32, 144), + 'violetred1': RGBColor(255, 62, 150), + 'violetred2': RGBColor(238, 58, 140), + 'violetred3': RGBColor(205, 50, 120), + 'violetred4': RGBColor(139, 34, 82), + 'webgray': RGBColor(128, 128, 128), + 'webgreen': RGBColor(0, 128, 0), + 'webgrey': RGBColor(128, 128, 128), + 'webmaroon': RGBColor(128, 0, 0), + 'webpurple': RGBColor(128, 0, 128), + 'wheat': RGBColor(245, 222, 179), + 'wheat1': RGBColor(255, 231, 186), + 'wheat2': RGBColor(238, 216, 174), + 'wheat3': RGBColor(205, 186, 150), + 'wheat4': RGBColor(139, 126, 102), + 'white': RGBColor(255, 255, 255), + 'whitesmoke': RGBColor(245, 245, 245), + 'x11gray': RGBColor(190, 190, 190), + 'x11green': RGBColor(0, 255, 0), + 'x11grey': RGBColor(190, 190, 190), + 'x11maroon': RGBColor(176, 48, 96), + 'x11purple': RGBColor(160, 32, 240), + 'yellow': RGBColor(255, 255, 0), + 'yellow1': RGBColor(255, 255, 0), + 'yellow2': RGBColor(238, 238, 0), + 'yellow3': RGBColor(205, 205, 0), + 'yellow4': RGBColor(139, 139, 0), + 'yellowgreen': RGBColor(154, 205, 50) +} + +#: Curses color indices of 8, 16, and 256-color terminals +RGB_256TABLE = ( + RGBColor(0, 0, 0), + RGBColor(205, 0, 0), + RGBColor(0, 205, 0), + RGBColor(205, 205, 0), + RGBColor(0, 0, 238), + RGBColor(205, 0, 205), + RGBColor(0, 205, 205), + RGBColor(229, 229, 229), + RGBColor(127, 127, 127), + RGBColor(255, 0, 0), + RGBColor(0, 255, 0), + RGBColor(255, 255, 0), + RGBColor(92, 92, 255), + RGBColor(255, 0, 255), + RGBColor(0, 255, 255), + RGBColor(255, 255, 255), + RGBColor(0, 0, 0), + RGBColor(0, 0, 95), + RGBColor(0, 0, 135), + RGBColor(0, 0, 175), + RGBColor(0, 0, 215), + RGBColor(0, 0, 255), + RGBColor(0, 95, 0), + RGBColor(0, 95, 95), + RGBColor(0, 95, 135), + RGBColor(0, 95, 175), + RGBColor(0, 95, 215), + RGBColor(0, 95, 255), + RGBColor(0, 135, 0), + RGBColor(0, 135, 95), + RGBColor(0, 135, 135), + RGBColor(0, 135, 175), + RGBColor(0, 135, 215), + RGBColor(0, 135, 255), + RGBColor(0, 175, 0), + RGBColor(0, 175, 95), + RGBColor(0, 175, 135), + RGBColor(0, 175, 175), + RGBColor(0, 175, 215), + RGBColor(0, 175, 255), + RGBColor(0, 215, 0), + RGBColor(0, 215, 95), + RGBColor(0, 215, 135), + RGBColor(0, 215, 175), + RGBColor(0, 215, 215), + RGBColor(0, 215, 255), + RGBColor(0, 255, 0), + RGBColor(0, 255, 95), + RGBColor(0, 255, 135), + RGBColor(0, 255, 175), + RGBColor(0, 255, 215), + RGBColor(0, 255, 255), + RGBColor(95, 0, 0), + RGBColor(95, 0, 95), + RGBColor(95, 0, 135), + RGBColor(95, 0, 175), + RGBColor(95, 0, 215), + RGBColor(95, 0, 255), + RGBColor(95, 95, 0), + RGBColor(95, 95, 95), + RGBColor(95, 95, 135), + RGBColor(95, 95, 175), + RGBColor(95, 95, 215), + RGBColor(95, 95, 255), + RGBColor(95, 135, 0), + RGBColor(95, 135, 95), + RGBColor(95, 135, 135), + RGBColor(95, 135, 175), + RGBColor(95, 135, 215), + RGBColor(95, 135, 255), + RGBColor(95, 175, 0), + RGBColor(95, 175, 95), + RGBColor(95, 175, 135), + RGBColor(95, 175, 175), + RGBColor(95, 175, 215), + RGBColor(95, 175, 255), + RGBColor(95, 215, 0), + RGBColor(95, 215, 95), + RGBColor(95, 215, 135), + RGBColor(95, 215, 175), + RGBColor(95, 215, 215), + RGBColor(95, 215, 255), + RGBColor(95, 255, 0), + RGBColor(95, 255, 95), + RGBColor(95, 255, 135), + RGBColor(95, 255, 175), + RGBColor(95, 255, 215), + RGBColor(95, 255, 255), + RGBColor(135, 0, 0), + RGBColor(135, 0, 95), + RGBColor(135, 0, 135), + RGBColor(135, 0, 175), + RGBColor(135, 0, 215), + RGBColor(135, 0, 255), + RGBColor(135, 95, 0), + RGBColor(135, 95, 95), + RGBColor(135, 95, 135), + RGBColor(135, 95, 175), + RGBColor(135, 95, 215), + RGBColor(135, 95, 255), + RGBColor(135, 135, 0), + RGBColor(135, 135, 95), + RGBColor(135, 135, 135), + RGBColor(135, 135, 175), + RGBColor(135, 135, 215), + RGBColor(135, 135, 255), + RGBColor(135, 175, 0), + RGBColor(135, 175, 95), + RGBColor(135, 175, 135), + RGBColor(135, 175, 175), + RGBColor(135, 175, 215), + RGBColor(135, 175, 255), + RGBColor(135, 215, 0), + RGBColor(135, 215, 95), + RGBColor(135, 215, 135), + RGBColor(135, 215, 175), + RGBColor(135, 215, 215), + RGBColor(135, 215, 255), + RGBColor(135, 255, 0), + RGBColor(135, 255, 95), + RGBColor(135, 255, 135), + RGBColor(135, 255, 175), + RGBColor(135, 255, 215), + RGBColor(135, 255, 255), + RGBColor(175, 0, 0), + RGBColor(175, 0, 95), + RGBColor(175, 0, 135), + RGBColor(175, 0, 175), + RGBColor(175, 0, 215), + RGBColor(175, 0, 255), + RGBColor(175, 95, 0), + RGBColor(175, 95, 95), + RGBColor(175, 95, 135), + RGBColor(175, 95, 175), + RGBColor(175, 95, 215), + RGBColor(175, 95, 255), + RGBColor(175, 135, 0), + RGBColor(175, 135, 95), + RGBColor(175, 135, 135), + RGBColor(175, 135, 175), + RGBColor(175, 135, 215), + RGBColor(175, 135, 255), + RGBColor(175, 175, 0), + RGBColor(175, 175, 95), + RGBColor(175, 175, 135), + RGBColor(175, 175, 175), + RGBColor(175, 175, 215), + RGBColor(175, 175, 255), + RGBColor(175, 215, 0), + RGBColor(175, 215, 95), + RGBColor(175, 215, 135), + RGBColor(175, 215, 175), + RGBColor(175, 215, 215), + RGBColor(175, 215, 255), + RGBColor(175, 255, 0), + RGBColor(175, 255, 95), + RGBColor(175, 255, 135), + RGBColor(175, 255, 175), + RGBColor(175, 255, 215), + RGBColor(175, 255, 255), + RGBColor(215, 0, 0), + RGBColor(215, 0, 95), + RGBColor(215, 0, 135), + RGBColor(215, 0, 175), + RGBColor(215, 0, 215), + RGBColor(215, 0, 255), + RGBColor(215, 95, 0), + RGBColor(215, 95, 95), + RGBColor(215, 95, 135), + RGBColor(215, 95, 175), + RGBColor(215, 95, 215), + RGBColor(215, 95, 255), + RGBColor(215, 135, 0), + RGBColor(215, 135, 95), + RGBColor(215, 135, 135), + RGBColor(215, 135, 175), + RGBColor(215, 135, 215), + RGBColor(215, 135, 255), + RGBColor(215, 175, 0), + RGBColor(215, 175, 95), + RGBColor(215, 175, 135), + RGBColor(215, 175, 175), + RGBColor(215, 175, 215), + RGBColor(215, 175, 255), + RGBColor(215, 215, 0), + RGBColor(215, 215, 95), + RGBColor(215, 215, 135), + RGBColor(215, 215, 175), + RGBColor(215, 215, 215), + RGBColor(215, 215, 255), + RGBColor(215, 255, 0), + RGBColor(215, 255, 95), + RGBColor(215, 255, 135), + RGBColor(215, 255, 175), + RGBColor(215, 255, 215), + RGBColor(215, 255, 255), + RGBColor(255, 0, 0), + RGBColor(255, 0, 135), + RGBColor(255, 0, 95), + RGBColor(255, 0, 175), + RGBColor(255, 0, 215), + RGBColor(255, 0, 255), + RGBColor(255, 95, 0), + RGBColor(255, 95, 95), + RGBColor(255, 95, 135), + RGBColor(255, 95, 175), + RGBColor(255, 95, 215), + RGBColor(255, 95, 255), + RGBColor(255, 135, 0), + RGBColor(255, 135, 95), + RGBColor(255, 135, 135), + RGBColor(255, 135, 175), + RGBColor(255, 135, 215), + RGBColor(255, 135, 255), + RGBColor(255, 175, 0), + RGBColor(255, 175, 95), + RGBColor(255, 175, 135), + RGBColor(255, 175, 175), + RGBColor(255, 175, 215), + RGBColor(255, 175, 255), + RGBColor(255, 215, 0), + RGBColor(255, 215, 95), + RGBColor(255, 215, 135), + RGBColor(255, 215, 175), + RGBColor(255, 215, 215), + RGBColor(255, 215, 255), + RGBColor(255, 255, 0), + RGBColor(255, 255, 95), + RGBColor(255, 255, 135), + RGBColor(255, 255, 175), + RGBColor(255, 255, 215), + RGBColor(255, 255, 255), + RGBColor(8, 8, 8), + RGBColor(18, 18, 18), + RGBColor(28, 28, 28), + RGBColor(38, 38, 38), + RGBColor(48, 48, 48), + RGBColor(58, 58, 58), + RGBColor(68, 68, 68), + RGBColor(78, 78, 78), + RGBColor(88, 88, 88), + RGBColor(98, 98, 98), + RGBColor(108, 108, 108), + RGBColor(118, 118, 118), + RGBColor(128, 128, 128), + RGBColor(138, 138, 138), + RGBColor(148, 148, 148), + RGBColor(158, 158, 158), + RGBColor(168, 168, 168), + RGBColor(178, 178, 178), + RGBColor(188, 188, 188), + RGBColor(198, 198, 198), + RGBColor(208, 208, 208), + RGBColor(218, 218, 218), + RGBColor(228, 228, 228), + RGBColor(238, 238, 238), +) diff --git a/blessed/colorspace.py:Zone.Identifier b/blessed/colorspace.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/colorspace.pyi b/blessed/colorspace.pyi new file mode 100644 index 0000000..a799cd0 --- /dev/null +++ b/blessed/colorspace.pyi @@ -0,0 +1,12 @@ +# std imports +from typing import Set, Dict, Tuple, NamedTuple + +CGA_COLORS: Set[str] + +class RGBColor(NamedTuple): + red: int + green: int + blue: int + +X11_COLORNAMES_TO_RGB: Dict[str, RGBColor] +RGB_256TABLE: Tuple[RGBColor, ...] diff --git a/blessed/colorspace.pyi:Zone.Identifier b/blessed/colorspace.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/formatters.py b/blessed/formatters.py new file mode 100644 index 0000000..31c1097 --- /dev/null +++ b/blessed/formatters.py @@ -0,0 +1,496 @@ +"""Sub-module providing sequence-formatting functions.""" +# std imports +import platform + +# 3rd party +import six + +# local +from blessed.colorspace import CGA_COLORS, X11_COLORNAMES_TO_RGB + +# isort: off +# curses +if platform.system() == 'Windows': + import jinxed as curses # pylint: disable=import-error +else: + import curses + + +def _make_colors(): + """ + Return set of valid colors and their derivatives. + + :rtype: set + :returns: Color names with prefixes + """ + colors = set() + # basic CGA foreground color, background, high intensity, and bold + # background ('iCE colors' in my day). + for cga_color in CGA_COLORS: + colors.add(cga_color) + colors.add('on_' + cga_color) + colors.add('bright_' + cga_color) + colors.add('on_bright_' + cga_color) + + # foreground and background VGA color + for vga_color in X11_COLORNAMES_TO_RGB: + colors.add(vga_color) + colors.add('on_' + vga_color) + return colors + + +#: Valid colors and their background (on), bright, and bright-background +#: derivatives. +COLORS = _make_colors() + +#: Attributes that may be compounded with colors, by underscore, such as +#: 'reverse_indigo'. +COMPOUNDABLES = set('bold underline reverse blink italic standout'.split()) + + +class ParameterizingString(six.text_type): + r""" + A Unicode string which can be called as a parameterizing termcap. + + For example:: + + >>> from blessed import Terminal + >>> term = Terminal() + >>> color = ParameterizingString(term.color, term.normal, 'color') + >>> color(9)('color #9') + u'\x1b[91mcolor #9\x1b(B\x1b[m' + """ + + def __new__(cls, cap, normal=u'', name=u''): + # pylint: disable = missing-return-doc, missing-return-type-doc + """ + Class constructor accepting 3 positional arguments. + + :arg str cap: parameterized string suitable for curses.tparm() + :arg str normal: terminating sequence for this capability (optional). + :arg str name: name of this terminal capability (optional). + """ + new = six.text_type.__new__(cls, cap) + new._normal = normal + new._name = name + return new + + def __call__(self, *args): + """ + Returning :class:`FormattingString` instance for given parameters. + + Return evaluated terminal capability (self), receiving arguments + ``*args``, followed by the terminating sequence (self.normal) into + a :class:`FormattingString` capable of being called. + + :raises TypeError: Mismatch between capability and arguments + :raises curses.error: :func:`curses.tparm` raised an exception + :rtype: :class:`FormattingString` or :class:`NullCallableString` + :returns: Callable string for given parameters + """ + try: + # Re-encode the cap, because tparm() takes a bytestring in Python + # 3. However, appear to be a plain Unicode string otherwise so + # concats work. + attr = curses.tparm(self.encode('latin1'), *args).decode('latin1') + return FormattingString(attr, self._normal) + except TypeError as err: + # If the first non-int (i.e. incorrect) arg was a string, suggest + # something intelligent: + if args and isinstance(args[0], six.string_types): + raise TypeError( + "Unknown terminal capability, %r, or, TypeError " + "for arguments %r: %s" % (self._name, args, err)) + # Somebody passed a non-string; I don't feel confident + # guessing what they were trying to do. + raise + except curses.error as err: + # ignore 'tparm() returned NULL', you won't get any styling, + # even if does_styling is True. This happens on win32 platforms + # with http://www.lfd.uci.edu/~gohlke/pythonlibs/#curses installed + if "tparm() returned NULL" not in six.text_type(err): + raise + return NullCallableString() + + +class ParameterizingProxyString(six.text_type): + r""" + A Unicode string which can be called to proxy missing termcap entries. + + This class supports the function :func:`get_proxy_string`, and mirrors + the behavior of :class:`ParameterizingString`, except that instead of + a capability name, receives a format string, and callable to filter the + given positional ``*args`` of :meth:`ParameterizingProxyString.__call__` + into a terminal sequence. + + For example:: + + >>> from blessed import Terminal + >>> term = Terminal('screen') + >>> hpa = ParameterizingString(term.hpa, term.normal, 'hpa') + >>> hpa(9) + u'' + >>> fmt = u'\x1b[{0}G' + >>> fmt_arg = lambda *arg: (arg[0] + 1,) + >>> hpa = ParameterizingProxyString((fmt, fmt_arg), term.normal, 'hpa') + >>> hpa(9) + u'\x1b[10G' + """ + + def __new__(cls, fmt_pair, normal=u'', name=u''): + # pylint: disable = missing-return-doc, missing-return-type-doc + """ + Class constructor accepting 4 positional arguments. + + :arg tuple fmt_pair: Two element tuple containing: + - format string suitable for displaying terminal sequences + - callable suitable for receiving __call__ arguments for formatting string + :arg str normal: terminating sequence for this capability (optional). + :arg str name: name of this terminal capability (optional). + """ + assert isinstance(fmt_pair, tuple), fmt_pair + assert callable(fmt_pair[1]), fmt_pair[1] + new = six.text_type.__new__(cls, fmt_pair[0]) + new._fmt_args = fmt_pair[1] + new._normal = normal + new._name = name + return new + + def __call__(self, *args): + """ + Returning :class:`FormattingString` instance for given parameters. + + Arguments are determined by the capability. For example, ``hpa`` + (move_x) receives only a single integer, whereas ``cup`` (move) + receives two integers. See documentation in terminfo(5) for the + given capability. + + :rtype: FormattingString + :returns: Callable string for given parameters + """ + return FormattingString(self.format(*self._fmt_args(*args)), + self._normal) + + +class FormattingString(six.text_type): + r""" + A Unicode string which doubles as a callable. + + This is used for terminal attributes, so that it may be used both + directly, or as a callable. When used directly, it simply emits + the given terminal sequence. When used as a callable, it wraps the + given (string) argument with the 2nd argument used by the class + constructor:: + + >>> from blessed import Terminal + >>> term = Terminal() + >>> style = FormattingString(term.bright_blue, term.normal) + >>> print(repr(style)) + u'\x1b[94m' + >>> style('Big Blue') + u'\x1b[94mBig Blue\x1b(B\x1b[m' + """ + + def __new__(cls, sequence, normal=u''): + # pylint: disable = missing-return-doc, missing-return-type-doc + """ + Class constructor accepting 2 positional arguments. + + :arg str sequence: terminal attribute sequence. + :arg str normal: terminating sequence for this attribute (optional). + """ + new = six.text_type.__new__(cls, sequence) + new._normal = normal + return new + + def __call__(self, *args): + """ + Return ``text`` joined by ``sequence`` and ``normal``. + + :raises TypeError: Not a string type + :rtype: str + :returns: Arguments wrapped in sequence and normal + """ + # Jim Allman brings us this convenience of allowing existing + # unicode strings to be joined as a call parameter to a formatting + # string result, allowing nestation: + # + # >>> t.red('This is ', t.bold('extremely'), ' dangerous!') + for idx, ucs_part in enumerate(args): + if not isinstance(ucs_part, six.string_types): + expected_types = ', '.join(_type.__name__ for _type in six.string_types) + raise TypeError( + "TypeError for FormattingString argument, " + "%r, at position %s: expected type %s, " + "got %s" % (ucs_part, idx, expected_types, + type(ucs_part).__name__)) + postfix = u'' + if self and self._normal: + postfix = self._normal + _refresh = self._normal + self + args = [_refresh.join(ucs_part.split(self._normal)) + for ucs_part in args] + + return self + u''.join(args) + postfix + + +class FormattingOtherString(six.text_type): + r""" + A Unicode string which doubles as a callable for another sequence when called. + + This is used for the :meth:`~.Terminal.move_up`, ``down``, ``left``, and ``right()`` + family of functions:: + + >>> from blessed import Terminal + >>> term = Terminal() + >>> move_right = FormattingOtherString(term.cuf1, term.cuf) + >>> print(repr(move_right)) + u'\x1b[C' + >>> print(repr(move_right(666))) + u'\x1b[666C' + >>> print(repr(move_right())) + u'\x1b[C' + """ + + def __new__(cls, direct, target): + # pylint: disable = missing-return-doc, missing-return-type-doc + """ + Class constructor accepting 2 positional arguments. + + :arg str direct: capability name for direct formatting, eg ``('x' + term.right)``. + :arg str target: capability name for callable, eg ``('x' + term.right(99))``. + """ + new = six.text_type.__new__(cls, direct) + new._callable = target + return new + + def __getnewargs__(self): + # return arguments used for the __new__ method upon unpickling. + return six.text_type.__new__(six.text_type, self), self._callable + + def __call__(self, *args): + """Return ``text`` by ``target``.""" + return self._callable(*args) if args else self + + +class NullCallableString(six.text_type): + """ + A dummy callable Unicode alternative to :class:`FormattingString`. + + This is used for colors on terminals that do not support colors, it is just a basic form of + unicode that may also act as a callable. + """ + + def __new__(cls): + """Class constructor.""" + return six.text_type.__new__(cls, u'') + + def __call__(self, *args): + """ + Allow empty string to be callable, returning given string, if any. + + When called with an int as the first arg, return an empty Unicode. An + int is a good hint that I am a :class:`ParameterizingString`, as there + are only about half a dozen string-returning capabilities listed in + terminfo(5) which accept non-int arguments, they are seldom used. + + When called with a non-int as the first arg (no no args at all), return + the first arg, acting in place of :class:`FormattingString` without + any attributes. + """ + if not args or isinstance(args[0], int): + # As a NullCallableString, even when provided with a parameter, + # such as t.color(5), we must also still be callable, fe: + # + # >>> t.color(5)('shmoo') + # + # is actually simplified result of NullCallable()() on terminals + # without color support, so turtles all the way down: we return + # another instance. + return NullCallableString() + return u''.join(args) + + +def get_proxy_string(term, attr): + """ + Proxy and return callable string for proxied attributes. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str attr: terminal capability name that may be proxied. + :rtype: None or :class:`ParameterizingProxyString`. + :returns: :class:`ParameterizingProxyString` for some attributes + of some terminal types that support it, where the terminfo(5) + database would otherwise come up empty, such as ``move_x`` + attribute for ``term.kind`` of ``screen``. Otherwise, None. + """ + # normalize 'screen-256color', or 'ansi.sys' to its basic names + term_kind = next(iter(_kind for _kind in ('screen', 'ansi',) + if term.kind.startswith(_kind)), term) + _proxy_table = { # pragma: no cover + 'screen': { + # proxy move_x/move_y for 'screen' terminal type, used by tmux(1). + 'hpa': ParameterizingProxyString( + (u'\x1b[{0}G', lambda *arg: (arg[0] + 1,)), term.normal, attr), + 'vpa': ParameterizingProxyString( + (u'\x1b[{0}d', lambda *arg: (arg[0] + 1,)), term.normal, attr), + }, + 'ansi': { + # proxy show/hide cursor for 'ansi' terminal type. There is some + # demand for a richly working ANSI terminal type for some reason. + 'civis': ParameterizingProxyString( + (u'\x1b[?25l', lambda *arg: ()), term.normal, attr), + 'cnorm': ParameterizingProxyString( + (u'\x1b[?25h', lambda *arg: ()), term.normal, attr), + 'hpa': ParameterizingProxyString( + (u'\x1b[{0}G', lambda *arg: (arg[0] + 1,)), term.normal, attr), + 'vpa': ParameterizingProxyString( + (u'\x1b[{0}d', lambda *arg: (arg[0] + 1,)), term.normal, attr), + 'sc': '\x1b[s', + 'rc': '\x1b[u', + } + } + return _proxy_table.get(term_kind, {}).get(attr, None) + + +def split_compound(compound): + """ + Split compound formating string into segments. + + >>> split_compound('bold_underline_bright_blue_on_red') + ['bold', 'underline', 'bright_blue', 'on_red'] + + :arg str compound: a string that may contain compounds, separated by + underline (``_``). + :rtype: list + :returns: List of formating string segments + """ + merged_segs = [] + # These occur only as prefixes, so they can always be merged: + mergeable_prefixes = ['on', 'bright', 'on_bright'] + for segment in compound.split('_'): + if merged_segs and merged_segs[-1] in mergeable_prefixes: + merged_segs[-1] += '_' + segment + else: + merged_segs.append(segment) + return merged_segs + + +def resolve_capability(term, attr): + """ + Resolve a raw terminal capability using :func:`tigetstr`. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str attr: terminal capability name. + :returns: string of the given terminal capability named by ``attr``, + which may be empty (u'') if not found or not supported by the + given :attr:`~.Terminal.kind`. + :rtype: str + """ + if not term.does_styling: + return u'' + val = curses.tigetstr(term._sugar.get(attr, attr)) # pylint: disable=protected-access + # Decode sequences as latin1, as they are always 8-bit bytes, so when + # b'\xff' is returned, this is decoded as u'\xff'. + return u'' if val is None else val.decode('latin1') + + +def resolve_color(term, color): + """ + Resolve a simple color name to a callable capability. + + This function supports :func:`resolve_attribute`. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str color: any string found in set :const:`COLORS`. + :returns: a string class instance which emits the terminal sequence + for the given color, and may be used as a callable to wrap the + given string with such sequence. + :returns: :class:`NullCallableString` when + :attr:`~.Terminal.number_of_colors` is 0, + otherwise :class:`FormattingString`. + :rtype: :class:`NullCallableString` or :class:`FormattingString` + """ + # pylint: disable=protected-access + if term.number_of_colors == 0: + return NullCallableString() + + # fg/bg capabilities terminals that support 0-256+ colors. + vga_color_cap = (term._background_color if 'on_' in color else + term._foreground_color) + + base_color = color.rsplit('_', 1)[-1] + if base_color in CGA_COLORS: + # curses constants go up to only 7, so add an offset to get at the + # bright colors at 8-15: + offset = 8 if 'bright_' in color else 0 + base_color = color.rsplit('_', 1)[-1] + attr = 'COLOR_%s' % (base_color.upper(),) + fmt_attr = vga_color_cap(getattr(curses, attr) + offset) + return FormattingString(fmt_attr, term.normal) + + assert base_color in X11_COLORNAMES_TO_RGB, ( + 'color not known', base_color) + rgb = X11_COLORNAMES_TO_RGB[base_color] + + # downconvert X11 colors to CGA, EGA, or VGA color spaces + if term.number_of_colors <= 256: + fmt_attr = vga_color_cap(term.rgb_downconvert(*rgb)) + return FormattingString(fmt_attr, term.normal) + + # Modern 24-bit color terminals are written pretty basically. The + # foreground and background sequences are: + # - ^[38;2;;;m + # - ^[48;2;;;m + fgbg_seq = ('48' if 'on_' in color else '38') + assert term.number_of_colors == 1 << 24 + fmt_attr = u'\x1b[' + fgbg_seq + ';2;{0};{1};{2}m' + return FormattingString(fmt_attr.format(*rgb), term.normal) + + +def resolve_attribute(term, attr): + """ + Resolve a terminal attribute name into a capability class. + + :arg Terminal term: :class:`~.Terminal` instance. + :arg str attr: Sugary, ordinary, or compound formatted terminal + capability, such as "red_on_white", "normal", "red", or + "bold_on_black". + :returns: a string class instance which emits the terminal sequence + for the given terminal capability, or may be used as a callable to + wrap the given string with such sequence. + :returns: :class:`NullCallableString` when + :attr:`~.Terminal.number_of_colors` is 0, + otherwise :class:`FormattingString`. + :rtype: :class:`NullCallableString` or :class:`FormattingString` + """ + if attr in COLORS: + return resolve_color(term, attr) + + # A direct compoundable, such as `bold' or `on_red'. + if attr in COMPOUNDABLES: + sequence = resolve_capability(term, attr) + return FormattingString(sequence, term.normal) + + # Given `bold_on_red', resolve to ('bold', 'on_red'), RECURSIVE + # call for each compounding section, joined and returned as + # a completed completed FormattingString. + formatters = split_compound(attr) + if all((fmt in COLORS or fmt in COMPOUNDABLES) for fmt in formatters): + resolution = (resolve_attribute(term, fmt) for fmt in formatters) + return FormattingString(u''.join(resolution), term.normal) + + # otherwise, this is our end-game: given a sequence such as 'csr' + # (change scrolling region), return a ParameterizingString instance, + # that when called, performs and returns the final string after curses + # capability lookup is performed. + tparm_capseq = resolve_capability(term, attr) + if not tparm_capseq: + # and, for special terminals, such as 'screen', provide a Proxy + # ParameterizingString for attributes they do not claim to support, + # but actually do! (such as 'hpa' and 'vpa'). + proxy = get_proxy_string(term, + term._sugar.get(attr, attr)) # pylint: disable=protected-access + if proxy is not None: + return proxy + + return ParameterizingString(tparm_capseq, term.normal, attr) diff --git a/blessed/formatters.py:Zone.Identifier b/blessed/formatters.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/formatters.pyi b/blessed/formatters.pyi new file mode 100644 index 0000000..32a3dc2 --- /dev/null +++ b/blessed/formatters.pyi @@ -0,0 +1,70 @@ +# std imports +from typing import (Any, + Set, + List, + Type, + Tuple, + Union, + TypeVar, + Callable, + NoReturn, + Optional, + overload) + +# local +from .terminal import Terminal + +COLORS: Set[str] +COMPOUNDABLES: Set[str] + +_T = TypeVar("_T") + +class ParameterizingString(str): + def __new__(cls: Type[_T], cap: str, normal: str = ..., name: str = ...) -> _T: ... + @overload + def __call__( + self, *args: int + ) -> Union["FormattingString", "NullCallableString"]: ... + @overload + def __call__(self, *args: str) -> NoReturn: ... + +class ParameterizingProxyString(str): + def __new__( + cls: Type[_T], + fmt_pair: Tuple[str, Callable[..., Tuple[object, ...]]], + normal: str = ..., + name: str = ..., + ) -> _T: ... + def __call__(self, *args: Any) -> "FormattingString": ... + +class FormattingString(str): + def __new__(cls: Type[_T], sequence: str, normal: str = ...) -> _T: ... + @overload + def __call__(self, *args: int) -> NoReturn: ... + @overload + def __call__(self, *args: str) -> str: ... + +class FormattingOtherString(str): + def __new__( + cls: Type[_T], direct: ParameterizingString, target: ParameterizingString = ... + ) -> _T: ... + def __call__(self, *args: Union[int, str]) -> str: ... + +class NullCallableString(str): + def __new__(cls: Type[_T]) -> _T: ... + @overload + def __call__(self, *args: int) -> "NullCallableString": ... + @overload + def __call__(self, *args: str) -> str: ... + +def get_proxy_string( + term: Terminal, attr: str +) -> Optional[ParameterizingProxyString]: ... +def split_compound(compound: str) -> List[str]: ... +def resolve_capability(term: Terminal, attr: str) -> str: ... +def resolve_color( + term: Terminal, color: str +) -> Union[NullCallableString, FormattingString]: ... +def resolve_attribute( + term: Terminal, attr: str +) -> Union[ParameterizingString, FormattingString]: ... diff --git a/blessed/formatters.pyi:Zone.Identifier b/blessed/formatters.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/keyboard.py b/blessed/keyboard.py new file mode 100644 index 0000000..31cc98c --- /dev/null +++ b/blessed/keyboard.py @@ -0,0 +1,451 @@ +"""Sub-module providing 'keyboard awareness'.""" + +# std imports +import re +import time +import platform +from collections import OrderedDict + +# 3rd party +import six + +# isort: off +# curses +if platform.system() == 'Windows': + # pylint: disable=import-error + import jinxed as curses + from jinxed.has_key import _capability_names as capability_names +else: + import curses + from curses.has_key import _capability_names as capability_names + + +class Keystroke(six.text_type): + """ + A unicode-derived class for describing a single keystroke. + + A class instance describes a single keystroke received on input, + which may contain multiple characters as a multibyte sequence, + which is indicated by properties :attr:`is_sequence` returning + ``True``. + + When the string is a known sequence, :attr:`code` matches terminal + class attributes for comparison, such as ``term.KEY_LEFT``. + + The string-name of the sequence, such as ``u'KEY_LEFT'`` is accessed + by property :attr:`name`, and is used by the :meth:`__repr__` method + to display a human-readable form of the Keystroke this class + instance represents. It may otherwise by joined, split, or evaluated + just as as any other unicode string. + """ + + def __new__(cls, ucs='', code=None, name=None): + """Class constructor.""" + new = six.text_type.__new__(cls, ucs) + new._name = name + new._code = code + return new + + @property + def is_sequence(self): + """Whether the value represents a multibyte sequence (bool).""" + return self._code is not None + + def __repr__(self): + """Docstring overwritten.""" + return (six.text_type.__repr__(self) if self._name is None else + self._name) + __repr__.__doc__ = six.text_type.__doc__ + + @property + def name(self): + """String-name of key sequence, such as ``u'KEY_LEFT'`` (str).""" + return self._name + + @property + def code(self): + """Integer keycode value of multibyte sequence (int).""" + return self._code + + +def get_curses_keycodes(): + """ + Return mapping of curses key-names paired by their keycode integer value. + + :rtype: dict + :returns: Dictionary of (name, code) pairs for curses keyboard constant + values and their mnemonic name. Such as code ``260``, with the value of + its key-name identity, ``u'KEY_LEFT'``. + """ + _keynames = [attr for attr in dir(curses) + if attr.startswith('KEY_')] + return {keyname: getattr(curses, keyname) for keyname in _keynames} + + +def get_keyboard_codes(): + """ + Return mapping of keycode integer values paired by their curses key-name. + + :rtype: dict + :returns: Dictionary of (code, name) pairs for curses keyboard constant + values and their mnemonic name. Such as key ``260``, with the value of + its identity, ``u'KEY_LEFT'``. + + These keys are derived from the attributes by the same of the curses module, + with the following exceptions: + + * ``KEY_DELETE`` in place of ``KEY_DC`` + * ``KEY_INSERT`` in place of ``KEY_IC`` + * ``KEY_PGUP`` in place of ``KEY_PPAGE`` + * ``KEY_PGDOWN`` in place of ``KEY_NPAGE`` + * ``KEY_ESCAPE`` in place of ``KEY_EXIT`` + * ``KEY_SUP`` in place of ``KEY_SR`` + * ``KEY_SDOWN`` in place of ``KEY_SF`` + + This function is the inverse of :func:`get_curses_keycodes`. With the + given override "mixins" listed above, the keycode for the delete key will + map to our imaginary ``KEY_DELETE`` mnemonic, effectively erasing the + phrase ``KEY_DC`` from our code vocabulary for anyone that wishes to use + the return value to determine the key-name by keycode. + """ + keycodes = OrderedDict(get_curses_keycodes()) + keycodes.update(CURSES_KEYCODE_OVERRIDE_MIXIN) + # merge _CURSES_KEYCODE_ADDINS added to our module space + keycodes.update( + (name, value) for name, value in globals().copy().items() if name.startswith('KEY_') + ) + + # invert dictionary (key, values) => (values, key), preferring the + # last-most inserted value ('KEY_DELETE' over 'KEY_DC'). + return dict(zip(keycodes.values(), keycodes.keys())) + + +def _alternative_left_right(term): + r""" + Determine and return mapping of left and right arrow keys sequences. + + :arg blessed.Terminal term: :class:`~.Terminal` instance. + :rtype: dict + :returns: Dictionary of sequences ``term._cuf1``, and ``term._cub1``, + valued as ``KEY_RIGHT``, ``KEY_LEFT`` (when appropriate). + + This function supports :func:`get_terminal_sequences` to discover + the preferred input sequence for the left and right application keys. + + It is necessary to check the value of these sequences to ensure we do not + use ``u' '`` and ``u'\b'`` for ``KEY_RIGHT`` and ``KEY_LEFT``, + preferring their true application key sequence, instead. + """ + # pylint: disable=protected-access + keymap = {} + if term._cuf1 and term._cuf1 != u' ': + keymap[term._cuf1] = curses.KEY_RIGHT + if term._cub1 and term._cub1 != u'\b': + keymap[term._cub1] = curses.KEY_LEFT + return keymap + + +def get_keyboard_sequences(term): + r""" + Return mapping of keyboard sequences paired by keycodes. + + :arg blessed.Terminal term: :class:`~.Terminal` instance. + :returns: mapping of keyboard unicode sequences paired by keycodes + as integer. This is used as the argument ``mapper`` to + the supporting function :func:`resolve_sequence`. + :rtype: OrderedDict + + Initialize and return a keyboard map and sequence lookup table, + (sequence, keycode) from :class:`~.Terminal` instance ``term``, + where ``sequence`` is a multibyte input sequence of unicode + characters, such as ``u'\x1b[D'``, and ``keycode`` is an integer + value, matching curses constant such as term.KEY_LEFT. + + The return value is an OrderedDict instance, with their keys + sorted longest-first. + """ + # A small gem from curses.has_key that makes this all possible, + # _capability_names: a lookup table of terminal capability names for + # keyboard sequences (fe. kcub1, key_left), keyed by the values of + # constants found beginning with KEY_ in the main curses module + # (such as KEY_LEFT). + # + # latin1 encoding is used so that bytes in 8-bit range of 127-255 + # have equivalent chr() and unichr() values, so that the sequence + # of a kermit or avatar terminal, for example, remains unchanged + # in its byte sequence values even when represented by unicode. + # + sequence_map = dict(( + (seq.decode('latin1'), val) + for (seq, val) in ( + (curses.tigetstr(cap), val) + for (val, cap) in capability_names.items() + ) if seq + ) if term.does_styling else ()) + + sequence_map.update(_alternative_left_right(term)) + sequence_map.update(DEFAULT_SEQUENCE_MIXIN) + + # This is for fast lookup matching of sequences, preferring + # full-length sequence such as ('\x1b[D', KEY_LEFT) + # over simple sequences such as ('\x1b', KEY_EXIT). + return OrderedDict(( + (seq, sequence_map[seq]) for seq in sorted( + sequence_map.keys(), key=len, reverse=True))) + + +def get_leading_prefixes(sequences): + """ + Return a set of proper prefixes for given sequence of strings. + + :arg iterable sequences + :rtype: set + :return: Set of all string prefixes + + Given an iterable of strings, all textparts leading up to the final + string is returned as a unique set. This function supports the + :meth:`~.Terminal.inkey` method by determining whether the given + input is a sequence that **may** lead to a final matching pattern. + + >>> prefixes(['abc', 'abdf', 'e', 'jkl']) + set([u'a', u'ab', u'abd', u'j', u'jk']) + """ + return {seq[:i] for seq in sequences for i in range(1, len(seq))} + + +def resolve_sequence(text, mapper, codes): + r""" + Return a single :class:`Keystroke` instance for given sequence ``text``. + + :arg str text: string of characters received from terminal input stream. + :arg OrderedDict mapper: unicode multibyte sequences, such as ``u'\x1b[D'`` + paired by their integer value (260) + :arg dict codes: a :type:`dict` of integer values (such as 260) paired + by their mnemonic name, such as ``'KEY_LEFT'``. + :rtype: Keystroke + :returns: Keystroke instance for the given sequence + + The given ``text`` may extend beyond a matching sequence, such as + ``u\x1b[Dxxx`` returns a :class:`Keystroke` instance of attribute + :attr:`Keystroke.sequence` valued only ``u\x1b[D``. It is up to + calls to determine that ``xxx`` remains unresolved. + """ + for sequence, code in mapper.items(): + if text.startswith(sequence): + return Keystroke(ucs=sequence, code=code, name=codes[code]) + return Keystroke(ucs=text and text[0] or u'') + + +def _time_left(stime, timeout): + """ + Return time remaining since ``stime`` before given ``timeout``. + + This function assists determining the value of ``timeout`` for + class method :meth:`~.Terminal.kbhit` and similar functions. + + :arg float stime: starting time for measurement + :arg float timeout: timeout period, may be set to None to + indicate no timeout (where None is always returned). + :rtype: float or int + :returns: time remaining as float. If no time is remaining, + then the integer ``0`` is returned. + """ + return max(0, timeout - (time.time() - stime)) if timeout else timeout + + +def _read_until(term, pattern, timeout): + """ + Convenience read-until-pattern function, supporting :meth:`~.get_location`. + + :arg blessed.Terminal term: :class:`~.Terminal` instance. + :arg float timeout: timeout period, may be set to None to indicate no + timeout (where 0 is always returned). + :arg str pattern: target regular expression pattern to seek. + :rtype: tuple + :returns: tuple in form of ``(match, str)``, *match* + may be :class:`re.MatchObject` if pattern is discovered + in input stream before timeout has elapsed, otherwise + None. ``str`` is any remaining text received exclusive + of the matching pattern). + + The reason a tuple containing non-matching data is returned, is that the + consumer should push such data back into the input buffer by + :meth:`~.Terminal.ungetch` if any was received. + + For example, when a user is performing rapid input keystrokes while its + terminal emulator surreptitiously responds to this in-band sequence, we + must ensure any such keyboard data is well-received by the next call to + term.inkey() without delay. + """ + stime = time.time() + match, buf = None, u'' + + # first, buffer all pending data. pexpect library provides a + # 'searchwindowsize' attribute that limits this memory region. We're not + # concerned about OOM conditions: only (human) keyboard input and terminal + # response sequences are expected. + + while True: # pragma: no branch + # block as long as necessary to ensure at least one character is + # received on input or remaining timeout has elapsed. + ucs = term.inkey(timeout=_time_left(stime, timeout)) + # while the keyboard buffer is "hot" (has input), we continue to + # aggregate all awaiting data. We do this to ensure slow I/O + # calls do not unnecessarily give up within the first 'while' loop + # for short timeout periods. + while ucs: + buf += ucs + ucs = term.inkey(timeout=0) + + match = re.search(pattern=pattern, string=buf) + if match is not None: + # match + break + + if timeout is not None and not _time_left(stime, timeout): + # timeout + break + + return match, buf + + +#: Though we may determine *keynames* and codes for keyboard input that +#: generate multibyte sequences, it is also especially useful to aliases +#: a few basic ASCII characters such as ``KEY_TAB`` instead of ``u'\t'`` for +#: uniformity. +#: +#: Furthermore, many key-names for application keys enabled only by context +#: manager :meth:`~.Terminal.keypad` are surprisingly absent. We inject them +#: here directly into the curses module. +_CURSES_KEYCODE_ADDINS = ( + 'TAB', + 'KP_MULTIPLY', + 'KP_ADD', + 'KP_SEPARATOR', + 'KP_SUBTRACT', + 'KP_DECIMAL', + 'KP_DIVIDE', + 'KP_EQUAL', + 'KP_0', + 'KP_1', + 'KP_2', + 'KP_3', + 'KP_4', + 'KP_5', + 'KP_6', + 'KP_7', + 'KP_8', + 'KP_9') + +_LASTVAL = max(get_curses_keycodes().values()) +for keycode_name in _CURSES_KEYCODE_ADDINS: + _LASTVAL += 1 + globals()['KEY_' + keycode_name] = _LASTVAL + +#: In a perfect world, terminal emulators would always send exactly what +#: the terminfo(5) capability database plans for them, accordingly by the +#: value of the ``TERM`` name they declare. +#: +#: But this isn't a perfect world. Many vt220-derived terminals, such as +#: those declaring 'xterm', will continue to send vt220 codes instead of +#: their native-declared codes, for backwards-compatibility. +#: +#: This goes for many: rxvt, putty, iTerm. +#: +#: These "mixins" are used for *all* terminals, regardless of their type. +#: +#: Furthermore, curses does not provide sequences sent by the keypad, +#: at least, it does not provide a way to distinguish between keypad 0 +#: and numeric 0. +DEFAULT_SEQUENCE_MIXIN = ( + # these common control characters (and 127, ctrl+'?') mapped to + # an application key definition. + (six.unichr(10), curses.KEY_ENTER), + (six.unichr(13), curses.KEY_ENTER), + (six.unichr(8), curses.KEY_BACKSPACE), + (six.unichr(9), KEY_TAB), # noqa # pylint: disable=undefined-variable + (six.unichr(27), curses.KEY_EXIT), + (six.unichr(127), curses.KEY_BACKSPACE), + + (u"\x1b[A", curses.KEY_UP), + (u"\x1b[B", curses.KEY_DOWN), + (u"\x1b[C", curses.KEY_RIGHT), + (u"\x1b[D", curses.KEY_LEFT), + (u"\x1b[1;2A", curses.KEY_SR), + (u"\x1b[1;2B", curses.KEY_SF), + (u"\x1b[1;2C", curses.KEY_SRIGHT), + (u"\x1b[1;2D", curses.KEY_SLEFT), + (u"\x1b[F", curses.KEY_END), + (u"\x1b[H", curses.KEY_HOME), + # not sure where these are from .. please report + (u"\x1b[K", curses.KEY_END), + (u"\x1b[U", curses.KEY_NPAGE), + (u"\x1b[V", curses.KEY_PPAGE), + + # keys sent after term.smkx (keypad_xmit) is emitted, source: + # http://www.xfree86.org/current/ctlseqs.html#PC-Style%20Function%20Keys + # http://fossies.org/linux/rxvt/doc/rxvtRef.html#KeyCodes + # + # keypad, numlock on + (u"\x1bOM", curses.KEY_ENTER), # noqa return + (u"\x1bOj", KEY_KP_MULTIPLY), # noqa * # pylint: disable=undefined-variable + (u"\x1bOk", KEY_KP_ADD), # noqa + # pylint: disable=undefined-variable + (u"\x1bOl", KEY_KP_SEPARATOR), # noqa , # pylint: disable=undefined-variable + (u"\x1bOm", KEY_KP_SUBTRACT), # noqa - # pylint: disable=undefined-variable + (u"\x1bOn", KEY_KP_DECIMAL), # noqa . # pylint: disable=undefined-variable + (u"\x1bOo", KEY_KP_DIVIDE), # noqa / # pylint: disable=undefined-variable + (u"\x1bOX", KEY_KP_EQUAL), # noqa = # pylint: disable=undefined-variable + (u"\x1bOp", KEY_KP_0), # noqa 0 # pylint: disable=undefined-variable + (u"\x1bOq", KEY_KP_1), # noqa 1 # pylint: disable=undefined-variable + (u"\x1bOr", KEY_KP_2), # noqa 2 # pylint: disable=undefined-variable + (u"\x1bOs", KEY_KP_3), # noqa 3 # pylint: disable=undefined-variable + (u"\x1bOt", KEY_KP_4), # noqa 4 # pylint: disable=undefined-variable + (u"\x1bOu", KEY_KP_5), # noqa 5 # pylint: disable=undefined-variable + (u"\x1bOv", KEY_KP_6), # noqa 6 # pylint: disable=undefined-variable + (u"\x1bOw", KEY_KP_7), # noqa 7 # pylint: disable=undefined-variable + (u"\x1bOx", KEY_KP_8), # noqa 8 # pylint: disable=undefined-variable + (u"\x1bOy", KEY_KP_9), # noqa 9 # pylint: disable=undefined-variable + + # keypad, numlock off + (u"\x1b[1~", curses.KEY_FIND), # find + (u"\x1b[2~", curses.KEY_IC), # insert (0) + (u"\x1b[3~", curses.KEY_DC), # delete (.), "Execute" + (u"\x1b[4~", curses.KEY_SELECT), # select + (u"\x1b[5~", curses.KEY_PPAGE), # pgup (9) + (u"\x1b[6~", curses.KEY_NPAGE), # pgdown (3) + (u"\x1b[7~", curses.KEY_HOME), # home + (u"\x1b[8~", curses.KEY_END), # end + (u"\x1b[OA", curses.KEY_UP), # up (8) + (u"\x1b[OB", curses.KEY_DOWN), # down (2) + (u"\x1b[OC", curses.KEY_RIGHT), # right (6) + (u"\x1b[OD", curses.KEY_LEFT), # left (4) + (u"\x1b[OF", curses.KEY_END), # end (1) + (u"\x1b[OH", curses.KEY_HOME), # home (7) + + # The vt220 placed F1-F4 above the keypad, in place of actual + # F1-F4 were local functions (hold screen, print screen, + # set up, data/talk, break). + (u"\x1bOP", curses.KEY_F1), + (u"\x1bOQ", curses.KEY_F2), + (u"\x1bOR", curses.KEY_F3), + (u"\x1bOS", curses.KEY_F4), +) + +#: Override mixins for a few curses constants with easier +#: mnemonics: there may only be a 1:1 mapping when only a +#: keycode (int) is given, where these phrases are preferred. +CURSES_KEYCODE_OVERRIDE_MIXIN = ( + ('KEY_DELETE', curses.KEY_DC), + ('KEY_INSERT', curses.KEY_IC), + ('KEY_PGUP', curses.KEY_PPAGE), + ('KEY_PGDOWN', curses.KEY_NPAGE), + ('KEY_ESCAPE', curses.KEY_EXIT), + ('KEY_SUP', curses.KEY_SR), + ('KEY_SDOWN', curses.KEY_SF), + ('KEY_UP_LEFT', curses.KEY_A1), + ('KEY_UP_RIGHT', curses.KEY_A3), + ('KEY_CENTER', curses.KEY_B2), + ('KEY_BEGIN', curses.KEY_BEG), +) + +__all__ = ('Keystroke', 'get_keyboard_codes', 'get_keyboard_sequences',) diff --git a/blessed/keyboard.py:Zone.Identifier b/blessed/keyboard.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/keyboard.pyi b/blessed/keyboard.pyi new file mode 100644 index 0000000..ae76393 --- /dev/null +++ b/blessed/keyboard.pyi @@ -0,0 +1,28 @@ +# std imports +from typing import Set, Dict, Type, Mapping, TypeVar, Iterable, Optional, OrderedDict + +# local +from .terminal import Terminal + +_T = TypeVar("_T") + +class Keystroke(str): + def __new__( + cls: Type[_T], + ucs: str = ..., + code: Optional[int] = ..., + name: Optional[str] = ..., + ) -> _T: ... + @property + def is_sequence(self) -> bool: ... + @property + def name(self) -> Optional[str]: ... + @property + def code(self) -> Optional[int]: ... + +def get_keyboard_codes() -> Dict[int, str]: ... +def get_keyboard_sequences(term: Terminal) -> OrderedDict[str, int]: ... +def get_leading_prefixes(sequences: Iterable[str]) -> Set[str]: ... +def resolve_sequence( + text: str, mapper: Mapping[str, int], codes: Mapping[int, str] +) -> Keystroke: ... diff --git a/blessed/keyboard.pyi:Zone.Identifier b/blessed/keyboard.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/py.typed b/blessed/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/blessed/py.typed:Zone.Identifier b/blessed/py.typed:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/sequences.py b/blessed/sequences.py new file mode 100644 index 0000000..a5fe0d6 --- /dev/null +++ b/blessed/sequences.py @@ -0,0 +1,461 @@ +# -*- coding: utf-8 -*- +"""Module providing 'sequence awareness'.""" +# std imports +import re +import math +import textwrap + +# 3rd party +import six +from wcwidth import wcwidth + +# local +from blessed._capabilities import CAPABILITIES_CAUSE_MOVEMENT + +__all__ = ('Sequence', 'SequenceTextWrapper', 'iter_parse', 'measure_length') + + +class Termcap(object): + """Terminal capability of given variable name and pattern.""" + + def __init__(self, name, pattern, attribute): + """ + Class initializer. + + :arg str name: name describing capability. + :arg str pattern: regular expression string. + :arg str attribute: :class:`~.Terminal` attribute used to build + this terminal capability. + """ + self.name = name + self.pattern = pattern + self.attribute = attribute + self._re_compiled = None + + def __repr__(self): + # pylint: disable=redundant-keyword-arg + return ''.format(self=self) + + @property + def named_pattern(self): + """Regular expression pattern for capability with named group.""" + # pylint: disable=redundant-keyword-arg + return '(?P<{self.name}>{self.pattern})'.format(self=self) + + @property + def re_compiled(self): + """Compiled regular expression pattern for capability.""" + if self._re_compiled is None: + self._re_compiled = re.compile(self.pattern) + return self._re_compiled + + @property + def will_move(self): + """Whether capability causes cursor movement.""" + return self.name in CAPABILITIES_CAUSE_MOVEMENT + + def horizontal_distance(self, text): + """ + Horizontal carriage adjusted by capability, may be negative. + + :rtype: int + :arg str text: for capabilities *parm_left_cursor*, + *parm_right_cursor*, provide the matching sequence + text, its interpreted distance is returned. + + :returns: 0 except for matching ' + """ + value = { + 'cursor_left': -1, + 'backspace': -1, + 'cursor_right': 1, + 'tab': 8, + 'ascii_tab': 8, + }.get(self.name) + if value is not None: + return value + + unit = { + 'parm_left_cursor': -1, + 'parm_right_cursor': 1 + }.get(self.name) + if unit is not None: + value = int(self.re_compiled.match(text).group(1)) + return unit * value + + return 0 + + # pylint: disable=too-many-arguments + @classmethod + def build(cls, name, capability, attribute, nparams=0, + numeric=99, match_grouped=False, match_any=False, + match_optional=False): + r""" + Class factory builder for given capability definition. + + :arg str name: Variable name given for this pattern. + :arg str capability: A unicode string representing a terminal + capability to build for. When ``nparams`` is non-zero, it + must be a callable unicode string (such as the result from + ``getattr(term, 'bold')``. + :arg str attribute: The terminfo(5) capability name by which this + pattern is known. + :arg int nparams: number of positional arguments for callable. + :arg int numeric: Value to substitute into capability to when generating pattern + :arg bool match_grouped: If the numeric pattern should be + grouped, ``(\d+)`` when ``True``, ``\d+`` default. + :arg bool match_any: When keyword argument ``nparams`` is given, + *any* numeric found in output is suitable for building as + pattern ``(\d+)``. Otherwise, only the first matching value of + range *(numeric - 1)* through *(numeric + 1)* will be replaced by + pattern ``(\d+)`` in builder. + :arg bool match_optional: When ``True``, building of numeric patterns + containing ``(\d+)`` will be built as optional, ``(\d+)?``. + :rtype: blessed.sequences.Termcap + :returns: Terminal capability instance for given capability definition + """ + _numeric_regex = r'\d+' + if match_grouped: + _numeric_regex = r'(\d+)' + if match_optional: + _numeric_regex = r'(\d+)?' + numeric = 99 if numeric is None else numeric + + # basic capability attribute, not used as a callable + if nparams == 0: + return cls(name, re.escape(capability), attribute) + + # a callable capability accepting numeric argument + _outp = re.escape(capability(*(numeric,) * nparams)) + if not match_any: + for num in range(numeric - 1, numeric + 2): + if str(num) in _outp: + pattern = _outp.replace(str(num), _numeric_regex) + return cls(name, pattern, attribute) + + if match_grouped: + pattern = re.sub(r'(\d+)', lambda x: _numeric_regex, _outp) + else: + pattern = re.sub(r'\d+', lambda x: _numeric_regex, _outp) + return cls(name, pattern, attribute) + + +class SequenceTextWrapper(textwrap.TextWrapper): + """Docstring overridden.""" + + def __init__(self, width, term, **kwargs): + """ + Class initializer. + + This class supports the :meth:`~.Terminal.wrap` method. + """ + self.term = term + textwrap.TextWrapper.__init__(self, width, **kwargs) + + def _wrap_chunks(self, chunks): + """ + Sequence-aware variant of :meth:`textwrap.TextWrapper._wrap_chunks`. + + :raises ValueError: ``self.width`` is not a positive integer + :rtype: list + :returns: text chunks adjusted for width + + This simply ensures that word boundaries are not broken mid-sequence, as standard python + textwrap would incorrectly determine the length of a string containing sequences, and may + also break consider sequences part of a "word" that may be broken by hyphen (``-``), where + this implementation corrects both. + """ + lines = [] + if self.width <= 0 or not isinstance(self.width, int): + raise ValueError( + "invalid width {0!r}({1!r}) (must be integer > 0)" + .format(self.width, type(self.width))) + + term = self.term + drop_whitespace = not hasattr(self, 'drop_whitespace' + ) or self.drop_whitespace + chunks.reverse() + while chunks: + cur_line = [] + cur_len = 0 + indent = self.subsequent_indent if lines else self.initial_indent + width = self.width - len(indent) + if drop_whitespace and ( + Sequence(chunks[-1], term).strip() == '' and lines): + del chunks[-1] + while chunks: + chunk_len = Sequence(chunks[-1], term).length() + if cur_len + chunk_len > width: + break + cur_line.append(chunks.pop()) + cur_len += chunk_len + if chunks and Sequence(chunks[-1], term).length() > width: + self._handle_long_word(chunks, cur_line, cur_len, width) + if drop_whitespace and ( + cur_line and Sequence(cur_line[-1], term).strip() == ''): + del cur_line[-1] + if cur_line: + lines.append(indent + u''.join(cur_line)) + return lines + + def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width): + """ + Sequence-aware :meth:`textwrap.TextWrapper._handle_long_word`. + + This simply ensures that word boundaries are not broken mid-sequence, as standard python + textwrap would incorrectly determine the length of a string containing sequences, and may + also break consider sequences part of a "word" that may be broken by hyphen (``-``), where + this implementation corrects both. + """ + # Figure out when indent is larger than the specified width, and make + # sure at least one character is stripped off on every pass + space_left = 1 if width < 1 else width - cur_len + # If we're allowed to break long words, then do so: put as much + # of the next chunk onto the current line as will fit. + + if self.break_long_words: + term = self.term + chunk = reversed_chunks[-1] + idx = nxt = 0 + for text, _ in iter_parse(term, chunk): + nxt += len(text) + if Sequence(chunk[:nxt], term).length() > space_left: + break + idx = nxt + cur_line.append(chunk[:idx]) + reversed_chunks[-1] = chunk[idx:] + + # Otherwise, we have to preserve the long word intact. Only add + # it to the current line if there's nothing already there -- + # that minimizes how much we violate the width constraint. + elif not cur_line: + cur_line.append(reversed_chunks.pop()) + + # If we're not allowed to break long words, and there's already + # text on the current line, do nothing. Next time through the + # main loop of _wrap_chunks(), we'll wind up here again, but + # cur_len will be zero, so the next line will be entirely + # devoted to the long word that we can't handle right now. + + +SequenceTextWrapper.__doc__ = textwrap.TextWrapper.__doc__ + + +class Sequence(six.text_type): + """ + A "sequence-aware" version of the base :class:`str` class. + + This unicode-derived class understands the effect of escape sequences + of printable length, allowing a properly implemented :meth:`rjust`, + :meth:`ljust`, :meth:`center`, and :meth:`length`. + """ + + def __new__(cls, sequence_text, term): + # pylint: disable = missing-return-doc, missing-return-type-doc + """ + Class constructor. + + :arg str sequence_text: A string that may contain sequences. + :arg blessed.Terminal term: :class:`~.Terminal` instance. + """ + new = six.text_type.__new__(cls, sequence_text) + new._term = term + return new + + def ljust(self, width, fillchar=u' '): + """ + Return string containing sequences, left-adjusted. + + :arg int width: Total width given to left-adjust ``text``. If + unspecified, the width of the attached terminal is used (default). + :arg str fillchar: String for padding right-of ``text``. + :returns: String of ``text``, left-aligned by ``width``. + :rtype: str + """ + rightside = fillchar * int( + (max(0.0, float(width.__index__() - self.length()))) / float(len(fillchar))) + return u''.join((self, rightside)) + + def rjust(self, width, fillchar=u' '): + """ + Return string containing sequences, right-adjusted. + + :arg int width: Total width given to right-adjust ``text``. If + unspecified, the width of the attached terminal is used (default). + :arg str fillchar: String for padding left-of ``text``. + :returns: String of ``text``, right-aligned by ``width``. + :rtype: str + """ + leftside = fillchar * int( + (max(0.0, float(width.__index__() - self.length()))) / float(len(fillchar))) + return u''.join((leftside, self)) + + def center(self, width, fillchar=u' '): + """ + Return string containing sequences, centered. + + :arg int width: Total width given to center ``text``. If + unspecified, the width of the attached terminal is used (default). + :arg str fillchar: String for padding left and right-of ``text``. + :returns: String of ``text``, centered by ``width``. + :rtype: str + """ + split = max(0.0, float(width.__index__()) - self.length()) / 2 + leftside = fillchar * int( + (max(0.0, math.floor(split))) / float(len(fillchar))) + rightside = fillchar * int( + (max(0.0, math.ceil(split))) / float(len(fillchar))) + return u''.join((leftside, self, rightside)) + + def truncate(self, width): + """ + Truncate a string in a sequence-aware manner. + + Any printable characters beyond ``width`` are removed, while all + sequences remain in place. Horizontal Sequences are first expanded + by :meth:`padd`. + + :arg int width: The printable width to truncate the string to. + :rtype: str + :returns: String truncated to at most ``width`` printable characters. + """ + output = "" + current_width = 0 + target_width = width.__index__() + parsed_seq = iter_parse(self._term, self.padd()) + + # Retain all text until non-cap width reaches desired width + for text, cap in parsed_seq: + if not cap: + # use wcwidth clipped to 0 because it can sometimes return -1 + current_width += max(wcwidth(text), 0) + if current_width > target_width: + break + output += text + + # Return with remaining caps appended + return output + ''.join(text for text, cap in parsed_seq if cap) + + def length(self): + r""" + Return the printable length of string containing sequences. + + Strings containing ``term.left`` or ``\b`` will cause "overstrike", + but a length less than 0 is not ever returned. So ``_\b+`` is a + length of 1 (displays as ``+``), but ``\b`` alone is simply a + length of 0. + + Some characters may consume more than one cell, mainly those CJK + Unified Ideographs (Chinese, Japanese, Korean) defined by Unicode + as half or full-width characters. + + For example: + + >>> from blessed import Terminal + >>> from blessed.sequences import Sequence + >>> term = Terminal() + >>> msg = term.clear + term.red(u'コンニチハ') + >>> Sequence(msg, term).length() + 10 + + .. note:: Although accounted for, strings containing sequences such + as ``term.clear`` will not give accurate returns, it is not + considered lengthy (a length of 0). + """ + # because control characters may return -1, "clip" their length to 0. + return sum(max(wcwidth(w_char), 0) for w_char in self.padd(strip=True)) + + def strip(self, chars=None): + """ + Return string of sequences, leading and trailing whitespace removed. + + :arg str chars: Remove characters in chars instead of whitespace. + :rtype: str + :returns: string of sequences with leading and trailing whitespace removed. + """ + return self.strip_seqs().strip(chars) + + def lstrip(self, chars=None): + """ + Return string of all sequences and leading whitespace removed. + + :arg str chars: Remove characters in chars instead of whitespace. + :rtype: str + :returns: string of sequences with leading removed. + """ + return self.strip_seqs().lstrip(chars) + + def rstrip(self, chars=None): + """ + Return string of all sequences and trailing whitespace removed. + + :arg str chars: Remove characters in chars instead of whitespace. + :rtype: str + :returns: string of sequences with trailing removed. + """ + return self.strip_seqs().rstrip(chars) + + def strip_seqs(self): + """ + Return ``text`` stripped of only its terminal sequences. + + :rtype: str + :returns: Text with terminal sequences removed + """ + return self.padd(strip=True) + + def padd(self, strip=False): + """ + Return non-destructive horizontal movement as destructive spacing. + + :arg bool strip: Strip terminal sequences + :rtype: str + :returns: Text adjusted for horizontal movement + """ + outp = '' + for text, cap in iter_parse(self._term, self): + if not cap: + outp += text + continue + + value = cap.horizontal_distance(text) + if value > 0: + outp += ' ' * value + elif value < 0: + outp = outp[:value] + elif not strip: + outp += text + return outp + + +def iter_parse(term, text): + """ + Generator yields (text, capability) for characters of ``text``. + + value for ``capability`` may be ``None``, where ``text`` is + :class:`str` of length 1. Otherwise, ``text`` is a full + matching sequence of given capability. + """ + for match in term._caps_compiled_any.finditer(text): # pylint: disable=protected-access + name = match.lastgroup + value = match.group(name) + if name == 'MISMATCH': + yield (value, None) + else: + yield value, term.caps[name] + + +def measure_length(text, term): + """ + .. deprecated:: 1.12.0. + + :rtype: int + :returns: Length of the first sequence in the string + """ + try: + text, capability = next(iter_parse(term, text)) + if capability: + return len(text) + except StopIteration: + return 0 + return 0 diff --git a/blessed/sequences.py:Zone.Identifier b/blessed/sequences.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/sequences.pyi b/blessed/sequences.pyi new file mode 100644 index 0000000..4460b7a --- /dev/null +++ b/blessed/sequences.pyi @@ -0,0 +1,55 @@ +# std imports +import textwrap +from typing import Any, Type, Tuple, Pattern, TypeVar, Iterator, Optional, SupportsIndex + +# local +from .terminal import Terminal + +_T = TypeVar("_T") + +class Termcap: + name: str = ... + pattern: str = ... + attribute: str = ... + def __init__(self, name: str, pattern: str, attribute: str) -> None: ... + @property + def named_pattern(self) -> str: ... + @property + def re_compiled(self) -> Pattern[str]: ... + @property + def will_move(self) -> bool: ... + def horizontal_distance(self, text: str) -> int: ... + @classmethod + def build( + cls, + name: str, + capability: str, + attribute: str, + nparams: int = ..., + numeric: int = ..., + match_grouped: bool = ..., + match_any: bool = ..., + match_optional: bool = ..., + ) -> "Termcap": ... + +class SequenceTextWrapper(textwrap.TextWrapper): + term: Terminal = ... + def __init__(self, width: int, term: Terminal, **kwargs: Any) -> None: ... + +class Sequence(str): + def __new__(cls: Type[_T], sequence_text: str, term: Terminal) -> _T: ... + def ljust(self, width: SupportsIndex, fillchar: str = ...) -> str: ... + def rjust(self, width: SupportsIndex, fillchar: str = ...) -> str: ... + def center(self, width: SupportsIndex, fillchar: str = ...) -> str: ... + def truncate(self, width: SupportsIndex) -> str: ... + def length(self) -> int: ... + def strip(self, chars: Optional[str] = ...) -> str: ... + def lstrip(self, chars: Optional[str] = ...) -> str: ... + def rstrip(self, chars: Optional[str] = ...) -> str: ... + def strip_seqs(self) -> str: ... + def padd(self, strip: bool = ...) -> str: ... + +def iter_parse( + term: Terminal, text: str +) -> Iterator[Tuple[str, Optional[Termcap]]]: ... +def measure_length(text: str, term: Terminal) -> int: ... diff --git a/blessed/sequences.pyi:Zone.Identifier b/blessed/sequences.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/terminal.py b/blessed/terminal.py new file mode 100644 index 0000000..7b6165b --- /dev/null +++ b/blessed/terminal.py @@ -0,0 +1,1552 @@ +# -*- coding: utf-8 -*- +# pylint: disable=too-many-lines +"""Module containing :class:`Terminal`, the primary API entry point.""" +# std imports +import os +import re +import sys +import time +import codecs +import locale +import select +import struct +import platform +import warnings +import functools +import contextlib +import collections + +# local +from .color import COLOR_DISTANCE_ALGORITHMS +from .keyboard import (_time_left, + _read_until, + resolve_sequence, + get_keyboard_codes, + get_leading_prefixes, + get_keyboard_sequences) +from .sequences import Termcap, Sequence, SequenceTextWrapper +from .colorspace import RGB_256TABLE +from .formatters import (COLORS, + COMPOUNDABLES, + FormattingString, + NullCallableString, + ParameterizingString, + FormattingOtherString, + split_compound, + resolve_attribute, + resolve_capability) +from ._capabilities import CAPABILITY_DATABASE, CAPABILITIES_ADDITIVES, CAPABILITIES_RAW_MIXIN + +# isort: off + +# Alias py2 exception to py3 +if sys.version_info[:2] < (3, 3): + InterruptedError = select.error # pylint: disable=redefined-builtin + + +HAS_TTY = True +if platform.system() == 'Windows': + IS_WINDOWS = True + import jinxed as curses # pylint: disable=import-error + from jinxed.win32 import get_console_input_encoding # pylint: disable=import-error +else: + IS_WINDOWS = False + import curses + + try: + import fcntl + import termios + import tty + except ImportError: + _TTY_METHODS = ('setraw', 'cbreak', 'kbhit', 'height', 'width') + _MSG_NOSUPPORT = ( + "One or more of the modules: 'termios', 'fcntl', and 'tty' " + "are not found on your platform '{platform}'. " + "The following methods of Terminal are dummy/no-op " + "unless a deriving class overrides them: {tty_methods}." + .format(platform=platform.system(), + tty_methods=', '.join(_TTY_METHODS))) + warnings.warn(_MSG_NOSUPPORT) + HAS_TTY = False + +_CUR_TERM = None # See comments at end of file + + +class Terminal(object): + """ + An abstraction for color, style, positioning, and input in the terminal. + + This keeps the endless calls to ``tigetstr()`` and ``tparm()`` out of your code, acts + intelligently when somebody pipes your output to a non-terminal, and abstracts over the + complexity of unbuffered keyboard input. It uses the terminfo database to remain portable across + terminal types. + """ + # pylint: disable=too-many-instance-attributes,too-many-public-methods + # Too many public methods (28/20) + # Too many instance attributes (12/7) + + #: Sugary names for commonly-used capabilities + _sugar = { + 'save': 'sc', + 'restore': 'rc', + 'clear_eol': 'el', + 'clear_bol': 'el1', + 'clear_eos': 'ed', + 'enter_fullscreen': 'smcup', + 'exit_fullscreen': 'rmcup', + 'move': 'cup', + 'move_yx': 'cup', + 'move_x': 'hpa', + 'move_y': 'vpa', + 'hide_cursor': 'civis', + 'normal_cursor': 'cnorm', + 'reset_colors': 'op', + 'normal': 'sgr0', + 'reverse': 'rev', + 'italic': 'sitm', + 'no_italic': 'ritm', + 'shadow': 'sshm', + 'no_shadow': 'rshm', + 'standout': 'smso', + 'no_standout': 'rmso', + 'subscript': 'ssubm', + 'no_subscript': 'rsubm', + 'superscript': 'ssupm', + 'no_superscript': 'rsupm', + 'underline': 'smul', + 'no_underline': 'rmul', + 'cursor_report': 'u6', + 'cursor_request': 'u7', + 'terminal_answerback': 'u8', + 'terminal_enquire': 'u9', + } + + def __init__(self, kind=None, stream=None, force_styling=False): + """ + Initialize the terminal. + + :arg str kind: A terminal string as taken by :func:`curses.setupterm`. + Defaults to the value of the ``TERM`` environment variable. + + .. note:: Terminals withing a single process must share a common + ``kind``. See :obj:`_CUR_TERM`. + + :arg file stream: A file-like object representing the Terminal output. + Defaults to the original value of :obj:`sys.__stdout__`, like + :func:`curses.initscr` does. + + If ``stream`` is not a tty, empty Unicode strings are returned for + all capability values, so things like piping your program output to + a pipe or file does not emit terminal sequences. + + :arg bool force_styling: Whether to force the emission of capabilities + even if :obj:`sys.__stdout__` does not seem to be connected to a + terminal. If you want to force styling to not happen, use + ``force_styling=None``. + + This comes in handy if users are trying to pipe your output through + something like ``less -r`` or build systems which support decoding + of terminal sequences. + """ + # pylint: disable=global-statement,too-many-branches + global _CUR_TERM + self.errors = ['parameters: kind=%r, stream=%r, force_styling=%r' % + (kind, stream, force_styling)] + self._normal = None # cache normal attr, preventing recursive lookups + # we assume our input stream to be line-buffered until either the + # cbreak of raw context manager methods are entered with an attached tty. + self._line_buffered = True + + self._stream = stream + self._keyboard_fd = None + self._init_descriptor = None + self._is_a_tty = False + self.__init__streams() + + if IS_WINDOWS and self._init_descriptor is not None: + self._kind = kind or curses.get_term(self._init_descriptor) + else: + self._kind = kind or os.environ.get('TERM', 'dumb') or 'dumb' + + self._does_styling = False + if force_styling is None and self.is_a_tty: + self.errors.append('force_styling is None') + elif force_styling or self.is_a_tty: + self._does_styling = True + + if self.does_styling: + # Initialize curses (call setupterm), so things like tigetstr() work. + try: + curses.setupterm(self._kind, self._init_descriptor) + except curses.error as err: + msg = 'Failed to setupterm(kind={0!r}): {1}'.format(self._kind, err) + warnings.warn(msg) + self.errors.append(msg) + self._kind = None + self._does_styling = False + else: + if _CUR_TERM is None or self._kind == _CUR_TERM: + _CUR_TERM = self._kind + else: + # termcap 'kind' is immutable in a python process! Once + # initialized by setupterm, it is unsupported by the + # 'curses' module to change the terminal type again. If you + # are a downstream developer and you need this + # functionality, consider sub-processing, instead. + warnings.warn( + 'A terminal of kind "%s" has been requested; due to an' + ' internal python curses bug, terminal capabilities' + ' for a terminal of kind "%s" will continue to be' + ' returned for the remainder of this process.' % ( + self._kind, _CUR_TERM,)) + + self.__init__color_capabilities() + self.__init__capabilities() + self.__init__keycodes() + + def __init__streams(self): + # pylint: disable=too-complex,too-many-branches + # Agree to disagree ! + stream_fd = None + + # Default stream is stdout + if self._stream is None: + self._stream = sys.__stdout__ + + if not hasattr(self._stream, 'fileno'): + self.errors.append('stream has no fileno method') + elif not callable(self._stream.fileno): + self.errors.append('stream.fileno is not callable') + else: + try: + stream_fd = self._stream.fileno() + except ValueError as err: + # The stream is not a file, such as the case of StringIO, or, when it has been + # "detached", such as might be the case of stdout in some test scenarios. + self.errors.append('Unable to determine output stream file descriptor: %s' % err) + else: + self._is_a_tty = os.isatty(stream_fd) + if not self._is_a_tty: + self.errors.append('stream not a TTY') + + # Keyboard valid as stdin only when output stream is stdout or stderr and is a tty. + if self._stream in (sys.__stdout__, sys.__stderr__): + try: + self._keyboard_fd = sys.__stdin__.fileno() + except (AttributeError, ValueError) as err: + self.errors.append('Unable to determine input stream file descriptor: %s' % err) + else: + # _keyboard_fd only non-None if both stdin and stdout is a tty. + if not self.is_a_tty: + self.errors.append('Output stream is not a TTY') + self._keyboard_fd = None + elif not os.isatty(self._keyboard_fd): + self.errors.append('Input stream is not a TTY') + self._keyboard_fd = None + else: + self.errors.append('Output stream is not a default stream') + + # The descriptor to direct terminal initialization sequences to. + self._init_descriptor = stream_fd + if stream_fd is None: + try: + self._init_descriptor = sys.__stdout__.fileno() + except ValueError as err: + self.errors.append('Unable to determine __stdout__ file descriptor: %s' % err) + + def __init__color_capabilities(self): + self._color_distance_algorithm = 'cie2000' + if not self.does_styling: + self.number_of_colors = 0 + elif IS_WINDOWS or os.environ.get('COLORTERM') in ('truecolor', '24bit'): + self.number_of_colors = 1 << 24 + else: + self.number_of_colors = max(0, curses.tigetnum('colors') or -1) + + def __clear_color_capabilities(self): + for cached_color_cap in set(dir(self)) & COLORS: + delattr(self, cached_color_cap) + + def __init__capabilities(self): + # important that we lay these in their ordered direction, so that our + # preferred, 'color' over 'set_a_attributes1', for example. + self.caps = collections.OrderedDict() + + # some static injected patterns, esp. without named attribute access. + for name, (attribute, pattern) in CAPABILITIES_ADDITIVES.items(): + self.caps[name] = Termcap(name, pattern, attribute) + + for name, (attribute, kwds) in CAPABILITY_DATABASE.items(): + if self.does_styling: + # attempt dynamic lookup + cap = getattr(self, attribute) + if cap: + self.caps[name] = Termcap.build( + name, cap, attribute, **kwds) + continue + + # fall-back + pattern = CAPABILITIES_RAW_MIXIN.get(name) + if pattern: + self.caps[name] = Termcap(name, pattern, attribute) + + # make a compiled named regular expression table + self.caps_compiled = re.compile( + '|'.join(cap.pattern for name, cap in self.caps.items())) + + # for tokenizer, the '.lastgroup' is the primary lookup key for + # 'self.caps', unless 'MISMATCH'; then it is an unmatched character. + self._caps_compiled_any = re.compile('|'.join( + cap.named_pattern for name, cap in self.caps.items() + ) + '|(?P.)') + self._caps_unnamed_any = re.compile('|'.join( + '({0})'.format(cap.pattern) for name, cap in self.caps.items() + ) + '|(.)') + + def __init__keycodes(self): + # Initialize keyboard data determined by capability. + # Build database of int code <=> KEY_NAME. + self._keycodes = get_keyboard_codes() + + # Store attributes as: self.KEY_NAME = code. + for key_code, key_name in self._keycodes.items(): + setattr(self, key_name, key_code) + + # Build database of sequence <=> KEY_NAME. + self._keymap = get_keyboard_sequences(self) + + # build set of prefixes of sequences + self._keymap_prefixes = get_leading_prefixes(self._keymap) + + # keyboard stream buffer + self._keyboard_buf = collections.deque() + + if self._keyboard_fd is not None: + # set input encoding and initialize incremental decoder + + if IS_WINDOWS: + self._encoding = get_console_input_encoding() \ + or locale.getpreferredencoding() or 'UTF-8' + else: + self._encoding = locale.getpreferredencoding() or 'UTF-8' + + try: + self._keyboard_decoder = codecs.getincrementaldecoder(self._encoding)() + except LookupError as err: + # encoding is illegal or unsupported, use 'UTF-8' + warnings.warn('LookupError: {0}, defaulting to UTF-8 for keyboard.'.format(err)) + self._encoding = 'UTF-8' + self._keyboard_decoder = codecs.getincrementaldecoder(self._encoding)() + + def __getattr__(self, attr): + r""" + Return a terminal capability as Unicode string. + + For example, ``term.bold`` is a unicode string that may be prepended + to text to set the video attribute for bold, which should also be + terminated with the pairing :attr:`normal`. This capability + returns a callable, so you can use ``term.bold("hi")`` which + results in the joining of ``(term.bold, "hi", term.normal)``. + + Compound formatters may also be used. For example:: + + >>> term.bold_blink_red_on_green("merry x-mas!") + + For a parameterized capability such as ``move`` (or ``cup``), pass the + parameters as positional arguments:: + + >>> term.move(line, column) + + See the manual page `terminfo(5) + `_ for a + complete list of capabilities and their arguments. + """ + if not self._does_styling: + return NullCallableString() + # Fetch the missing 'attribute' into some kind of curses-resolved + # capability, and cache by attaching to this Terminal class instance. + # + # Note that this will prevent future calls to __getattr__(), but + # that's precisely the idea of the cache! + val = resolve_attribute(self, attr) + setattr(self, attr, val) + return val + + @property + def kind(self): + """ + Read-only property: Terminal kind determined on class initialization. + + :rtype: str + """ + return self._kind + + @property + def does_styling(self): + """ + Read-only property: Whether this class instance may emit sequences. + + :rtype: bool + """ + return self._does_styling + + @property + def is_a_tty(self): + """ + Read-only property: Whether :attr:`~.stream` is a terminal. + + :rtype: bool + """ + return self._is_a_tty + + @property + def height(self): + """ + Read-only property: Height of the terminal (in number of lines). + + :rtype: int + """ + return self._height_and_width().ws_row + + @property + def width(self): + """ + Read-only property: Width of the terminal (in number of columns). + + :rtype: int + """ + return self._height_and_width().ws_col + + @property + def pixel_height(self): + """ + Read-only property: Height ofthe terminal (in pixels). + + :rtype: int + """ + return self._height_and_width().ws_ypixel + + @property + def pixel_width(self): + """ + Read-only property: Width of terminal (in pixels). + + :rtype: int + """ + return self._height_and_width().ws_xpixel + + @staticmethod + def _winsize(fd): + """ + Return named tuple describing size of the terminal by ``fd``. + + If the given platform does not have modules :mod:`termios`, + :mod:`fcntl`, or :mod:`tty`, window size of 80 columns by 25 + rows is always returned. + + :arg int fd: file descriptor queries for its window size. + :raises IOError: the file descriptor ``fd`` is not a terminal. + :rtype: WINSZ + :returns: named tuple describing size of the terminal + + WINSZ is a :class:`collections.namedtuple` instance, whose structure + directly maps to the return value of the :const:`termios.TIOCGWINSZ` + ioctl return value. The return parameters are: + + - ``ws_row``: width of terminal by its number of character cells. + - ``ws_col``: height of terminal by its number of character cells. + - ``ws_xpixel``: width of terminal by pixels (not accurate). + - ``ws_ypixel``: height of terminal by pixels (not accurate). + """ + if HAS_TTY: + # pylint: disable=protected-access + data = fcntl.ioctl(fd, termios.TIOCGWINSZ, WINSZ._BUF) + return WINSZ(*struct.unpack(WINSZ._FMT, data)) + return WINSZ(ws_row=25, ws_col=80, ws_xpixel=0, ws_ypixel=0) + + def _height_and_width(self): + """ + Return a tuple of (terminal height, terminal width). + + If :attr:`stream` or :obj:`sys.__stdout__` is not a tty or does not + support :func:`fcntl.ioctl` of :const:`termios.TIOCGWINSZ`, a window + size of 80 columns by 25 rows is returned for any values not + represented by environment variables ``LINES`` and ``COLUMNS``, which + is the default text mode of IBM PC compatibles. + + :rtype: WINSZ + :returns: Named tuple specifying the terminal size + + WINSZ is a :class:`collections.namedtuple` instance, whose structure + directly maps to the return value of the :const:`termios.TIOCGWINSZ` + ioctl return value. The return parameters are: + + - ``ws_row``: height of terminal by its number of cell rows. + - ``ws_col``: width of terminal by its number of cell columns. + - ``ws_xpixel``: width of terminal by pixels (not accurate). + - ``ws_ypixel``: height of terminal by pixels (not accurate). + + .. note:: the peculiar (height, width, width, height) order, which + matches the return order of TIOCGWINSZ! + """ + for fd in (self._init_descriptor, sys.__stdout__): + try: + if fd is not None: + return self._winsize(fd) + except (IOError, OSError, ValueError, TypeError): # pylint: disable=overlapping-except + pass + + return WINSZ(ws_row=int(os.getenv('LINES', '25')), + ws_col=int(os.getenv('COLUMNS', '80')), + ws_xpixel=None, + ws_ypixel=None) + + def _query_response(self, query_str, response_re, timeout): + """ + Sends a query string to the terminal and waits for a response. + + :arg str query_str: Query string written to output + :arg str response_re: Regular expression matching query response + :arg float timeout: Return after time elapsed in seconds + :return: re.match object for response_re or None if not found + :rtype: re.Match + """ + # Avoid changing user's desired raw or cbreak mode if already entered, + # by entering cbreak mode ourselves. This is necessary to receive user + # input without awaiting a human to press the return key. This mode + # also disables echo, which we should also hide, as our input is an + # sequence that is not meaningful for display as an output sequence. + + ctx = None + try: + if self._line_buffered: + ctx = self.cbreak() + ctx.__enter__() # pylint: disable=no-member + + # Emit the query sequence, + self.stream.write(query_str) + self.stream.flush() + + # Wait for response + match, data = _read_until(term=self, + pattern=response_re, + timeout=timeout) + + # Exclude response from subsequent input + if match: + data = data[:match.start()] + data[match.end():] + + # re-buffer keyboard data, if any + self.ungetch(data) + + finally: + if ctx is not None: + ctx.__exit__(None, None, None) # pylint: disable=no-member + + return match + + @contextlib.contextmanager + def location(self, x=None, y=None): + """ + Context manager for temporarily moving the cursor. + + :arg int x: horizontal position, from left, *0*, to right edge of screen, *self.width - 1*. + :arg int y: vertical position, from top, *0*, to bottom of screen, *self.height - 1*. + :return: a context manager. + :rtype: Iterator + + Move the cursor to a certain position on entry, do any kind of I/O, and upon exit + let you print stuff there, then return the cursor to its original position: + + + .. code-block:: python + + term = Terminal() + with term.location(y=0, x=0): + for row_num in range(term.height-1): + print('Row #{row_num}') + print(term.clear_eol + 'Back to original location.') + + Specify ``x`` to move to a certain column, ``y`` to move to a certain + row, both, or neither. If you specify neither, only the saving and + restoration of cursor position will happen. This can be useful if you + simply want to restore your place after doing some manual cursor + movement. + + Calls cannot be nested: only one should be entered at a time. + + .. note:: The argument order *(x, y)* differs from the return value order *(y, x)* + of :meth:`get_location`, or argument order *(y, x)* of :meth:`move`. This is + for API Compaibility with the blessings library, sorry for the trouble! + """ + # pylint: disable=invalid-name + # Invalid argument name "x" + + # Save position and move to the requested column, row, or both: + self.stream.write(self.save) + if x is not None and y is not None: + self.stream.write(self.move(y, x)) + elif x is not None: + self.stream.write(self.move_x(x)) + elif y is not None: + self.stream.write(self.move_y(y)) + try: + self.stream.flush() + yield + finally: + # Restore original cursor position: + self.stream.write(self.restore) + self.stream.flush() + + def get_location(self, timeout=None): + r""" + Return tuple (row, column) of cursor position. + + :arg float timeout: Return after time elapsed in seconds with value ``(-1, -1)`` indicating + that the remote end did not respond. + :rtype: tuple + :returns: cursor position as tuple in form of ``(y, x)``. When a timeout is specified, + always ensure the return value is checked for ``(-1, -1)``. + + The location of the cursor is determined by emitting the ``u7`` terminal capability, or + VT100 `Query Cursor Position + `_ + when such capability is undefined, which elicits a response from a reply string described by + capability ``u6``, or again VT100's definition of ``\x1b[%i%d;%dR`` when undefined. + + The ``(y, x)`` return value matches the parameter order of the :meth:`move_xy` capability. + The following sequence should cause the cursor to not move at all:: + + >>> term = Terminal() + >>> term.move_yx(*term.get_location())) + + And the following should assert True with a terminal: + + >>> term = Terminal() + >>> given_y, given_x = 10, 20 + >>> with term.location(y=given_y, x=given_x): + ... result_y, result_x = term.get_location() + ... + >>> assert given_x == result_x, (given_x, result_x) + >>> assert given_y == result_y, (given_y, result_y) + """ + # Local lines attached by termios and remote login protocols such as + # ssh and telnet both provide a means to determine the window + # dimensions of a connected client, but **no means to determine the + # location of the cursor**. + # + # from https://invisible-island.net/ncurses/terminfo.src.html, + # + # > The System V Release 4 and XPG4 terminfo format defines ten string + # > capabilities for use by applications, .... In this file, + # > we use certain of these capabilities to describe functions which + # > are not covered by terminfo. The mapping is as follows: + # > + # > u9 terminal enquire string (equiv. to ANSI/ECMA-48 DA) + # > u8 terminal answerback description + # > u7 cursor position request (equiv. to VT100/ANSI/ECMA-48 DSR 6) + # > u6 cursor position report (equiv. to ANSI/ECMA-48 CPR) + + response_str = getattr(self, self.caps['cursor_report'].attribute) or u'\x1b[%i%d;%dR' + match = self._query_response( + self.u7 or u'\x1b[6n', self.caps['cursor_report'].re_compiled, timeout + ) + + if match: + # return matching sequence response, the cursor location. + row, col = (int(val) for val in match.groups()) + + # Per https://invisible-island.net/ncurses/terminfo.src.html + # The cursor position report () string must contain two + # scanf(3)-style %d format elements. The first of these must + # correspond to the Y coordinate and the second to the %d. + # If the string contains the sequence %i, it is taken as an + # instruction to decrement each value after reading it (this is + # the inverse sense from the cup string). + if u'%i' in response_str: + row -= 1 + col -= 1 + return row, col + + # We chose to return an illegal value rather than an exception, + # favoring that users author function filters, such as max(0, y), + # rather than crowbarring such logic into an exception handler. + return -1, -1 + + def get_fgcolor(self, timeout=None): + """ + Return tuple (r, g, b) of foreground color. + + :arg float timeout: Return after time elapsed in seconds with value ``(-1, -1, -1)`` + indicating that the remote end did not respond. + :rtype: tuple + :returns: foreground color as tuple in form of ``(r, g, b)``. When a timeout is specified, + always ensure the return value is checked for ``(-1, -1, -1)``. + + The foreground color is determined by emitting an `OSC 10 color query + `_. + """ + match = self._query_response( + u'\x1b]10;?\x07', + re.compile(u'\x1b]10;rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)\x07'), + timeout + ) + + return tuple(int(val, 16) for val in match.groups()) if match else (-1, -1, -1) + + def get_bgcolor(self, timeout=None): + """ + Return tuple (r, g, b) of background color. + + :arg float timeout: Return after time elapsed in seconds with value ``(-1, -1, -1)`` + indicating that the remote end did not respond. + :rtype: tuple + :returns: background color as tuple in form of ``(r, g, b)``. When a timeout is specified, + always ensure the return value is checked for ``(-1, -1, -1)``. + + The background color is determined by emitting an `OSC 11 color query + `_. + """ + match = self._query_response( + u'\x1b]11;?\x07', + re.compile(u'\x1b]11;rgb:([0-9a-fA-F]+)/([0-9a-fA-F]+)/([0-9a-fA-F]+)\x07'), + timeout + ) + + return tuple(int(val, 16) for val in match.groups()) if match else (-1, -1, -1) + + @contextlib.contextmanager + def fullscreen(self): + """ + Context manager that switches to secondary screen, restoring on exit. + + Under the hood, this switches between the primary screen buffer and + the secondary one. The primary one is saved on entry and restored on + exit. Likewise, the secondary contents are also stable and are + faithfully restored on the next entry:: + + with term.fullscreen(): + main() + + .. note:: There is only one primary and one secondary screen buffer. + :meth:`fullscreen` calls cannot be nested, only one should be + entered at a time. + """ + self.stream.write(self.enter_fullscreen) + self.stream.flush() + try: + yield + finally: + self.stream.write(self.exit_fullscreen) + self.stream.flush() + + @contextlib.contextmanager + def hidden_cursor(self): + """ + Context manager that hides the cursor, setting visibility on exit. + + with term.hidden_cursor(): + main() + + .. note:: :meth:`hidden_cursor` calls cannot be nested: only one + should be entered at a time. + """ + self.stream.write(self.hide_cursor) + self.stream.flush() + try: + yield + finally: + self.stream.write(self.normal_cursor) + self.stream.flush() + + def move_xy(self, x, y): + """ + A callable string that moves the cursor to the given ``(x, y)`` screen coordinates. + + :arg int x: horizontal position, from left, *0*, to right edge of screen, *self.width - 1*. + :arg int y: vertical position, from top, *0*, to bottom of screen, *self.height - 1*. + :rtype: ParameterizingString + :returns: Callable string that moves the cursor to the given coordinates + """ + # this is just a convenience alias to the built-in, but hidden 'move' + # attribute -- we encourage folks to use only (x, y) positional + # arguments, or, if they must use (y, x), then use the 'move_yx' + # alias. + return self.move(y, x) + + def move_yx(self, y, x): + """ + A callable string that moves the cursor to the given ``(y, x)`` screen coordinates. + + :arg int y: vertical position, from top, *0*, to bottom of screen, *self.height - 1*. + :arg int x: horizontal position, from left, *0*, to right edge of screen, *self.width - 1*. + :rtype: ParameterizingString + :returns: Callable string that moves the cursor to the given coordinates + """ + return self.move(y, x) + + @property + def move_left(self): + """Move cursor 1 cells to the left, or callable string for n>1 cells.""" + return FormattingOtherString(self.cub1, ParameterizingString(self.cub)) + + @property + def move_right(self): + """Move cursor 1 or more cells to the right, or callable string for n>1 cells.""" + return FormattingOtherString(self.cuf1, ParameterizingString(self.cuf)) + + @property + def move_up(self): + """Move cursor 1 or more cells upwards, or callable string for n>1 cells.""" + return FormattingOtherString(self.cuu1, ParameterizingString(self.cuu)) + + @property + def move_down(self): + """Move cursor 1 or more cells downwards, or callable string for n>1 cells.""" + return FormattingOtherString(self.cud1, ParameterizingString(self.cud)) + + @property + def color(self): + """ + A callable string that sets the foreground color. + + :rtype: ParameterizingString + + The capability is unparameterized until called and passed a number, at which point it + returns another string which represents a specific color change. This second string can + further be called to color a piece of text and set everything back to normal afterward. + + This should not be used directly, but rather a specific color by name or + :meth:`~.Terminal.color_rgb` value. + """ + if self.does_styling: + return ParameterizingString(self._foreground_color, self.normal, 'color') + + return NullCallableString() + + def color_rgb(self, red, green, blue): + """ + Provides callable formatting string to set foreground color to the specified RGB color. + + :arg int red: RGB value of Red. + :arg int green: RGB value of Green. + :arg int blue: RGB value of Blue. + :rtype: FormattingString + :returns: Callable string that sets the foreground color + + If the terminal does not support RGB color, the nearest supported + color will be determined using :py:attr:`color_distance_algorithm`. + """ + if self.number_of_colors == 1 << 24: + # "truecolor" 24-bit + fmt_attr = u'\x1b[38;2;{0};{1};{2}m'.format(red, green, blue) + return FormattingString(fmt_attr, self.normal) + + # color by approximation to 256 or 16-color terminals + color_idx = self.rgb_downconvert(red, green, blue) + return FormattingString(self._foreground_color(color_idx), self.normal) + + @property + def on_color(self): + """ + A callable capability that sets the background color. + + :rtype: ParameterizingString + """ + if self.does_styling: + return ParameterizingString(self._background_color, self.normal, 'on_color') + + return NullCallableString() + + def on_color_rgb(self, red, green, blue): + """ + Provides callable formatting string to set background color to the specified RGB color. + + :arg int red: RGB value of Red. + :arg int green: RGB value of Green. + :arg int blue: RGB value of Blue. + :rtype: FormattingString + :returns: Callable string that sets the foreground color + + If the terminal does not support RGB color, the nearest supported + color will be determined using :py:attr:`color_distance_algorithm`. + """ + if self.number_of_colors == 1 << 24: + fmt_attr = u'\x1b[48;2;{0};{1};{2}m'.format(red, green, blue) + return FormattingString(fmt_attr, self.normal) + + color_idx = self.rgb_downconvert(red, green, blue) + return FormattingString(self._background_color(color_idx), self.normal) + + def formatter(self, value): + """ + Provides callable formatting string to set color and other text formatting options. + + :arg str value: Sugary, ordinary, or compound formatted terminal capability, + such as "red_on_white", "normal", "red", or "bold_on_black". + :rtype: :class:`FormattingString` or :class:`NullCallableString` + :returns: Callable string that sets color and other text formatting options + + Calling ``term.formatter('bold_on_red')`` is equivalent to ``term.bold_on_red``, but a + string that is not a valid text formatter will return a :class:`NullCallableString`. + This is intended to allow validation of text formatters without the possibility of + inadvertently returning another terminal capability. + """ + formatters = split_compound(value) + if all((fmt in COLORS or fmt in COMPOUNDABLES) for fmt in formatters): + return getattr(self, value) + + return NullCallableString() + + def rgb_downconvert(self, red, green, blue): + """ + Translate an RGB color to a color code of the terminal's color depth. + + :arg int red: RGB value of Red (0-255). + :arg int green: RGB value of Green (0-255). + :arg int blue: RGB value of Blue (0-255). + :rtype: int + :returns: Color code of downconverted RGB color + """ + # Though pre-computing all 1 << 24 options is memory-intensive, a pre-computed + # "k-d tree" of 256 (x,y,z) vectors of a colorspace in 3 dimensions, such as a + # cone of HSV, or simply 255x255x255 RGB square, any given rgb value is just a + # nearest-neighbor search of 256 points, which k-d should be much faster by + # sub-dividing / culling search points, rather than our "search all 256 points + # always" approach. + fn_distance = COLOR_DISTANCE_ALGORITHMS[self.color_distance_algorithm] + color_idx = 7 + shortest_distance = None + for cmp_depth, cmp_rgb in enumerate(RGB_256TABLE): + cmp_distance = fn_distance(cmp_rgb, (red, green, blue)) + if shortest_distance is None or cmp_distance < shortest_distance: + shortest_distance = cmp_distance + color_idx = cmp_depth + if cmp_depth >= self.number_of_colors: + break + return color_idx + + @property + def normal(self): + """ + A capability that resets all video attributes. + + :rtype: str + + ``normal`` is an alias for ``sgr0`` or ``exit_attribute_mode``. Any + styling attributes previously applied, such as foreground or + background colors, reverse video, or bold are reset to defaults. + """ + if self._normal: + return self._normal + self._normal = resolve_capability(self, 'normal') + return self._normal + + def link(self, url, text, url_id=''): + """ + Display ``text`` that when touched or clicked, navigates to ``url``. + + Optional ``url_id`` may be specified, so that non-adjacent cells can reference a single + target, all cells painted with the same "id" will highlight on hover, rather than any + individual one, as described in "Hovering and underlining the id parameter" of gist + https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda. + + :param str url: Hyperlink URL. + :param str text: Clickable text. + :param str url_id: Optional 'id'. + :rtype: str + :returns: String of ``text`` as a hyperlink to ``url``. + """ + assert len(url) < 2000, (len(url), url) + if url_id: + assert len(str(url_id)) < 250, (len(str(url_id)), url_id) + params = 'id={0}'.format(url_id) + else: + params = '' + if not self.does_styling: + return text + return ('\x1b]8;{0};{1}\x1b\\{2}' + '\x1b]8;;\x1b\\'.format(params, url, text)) + + @property + def stream(self): + """ + Read-only property: stream the terminal outputs to. + + This is a convenience attribute. It is used internally for implied + writes performed by context managers :meth:`~.hidden_cursor`, + :meth:`~.fullscreen`, :meth:`~.location`, and :meth:`~.keypad`. + """ + return self._stream + + @property + def number_of_colors(self): + """ + Number of colors supported by terminal. + + Common return values are 0, 8, 16, 256, or 1 << 24. + + This may be used to test whether the terminal supports colors, + and at what depth, if that's a concern. + + If this property is assigned a value of 88, the value 16 will be saved. This is due to the + the rarity of 88 color support and the inconsistency of behavior between implementations. + + Assigning this property to a value other than 0, 4, 8, 16, 88, 256, or 1 << 24 will + raise an :py:exc:`AssertionError`. + """ + return self._number_of_colors + + @number_of_colors.setter + def number_of_colors(self, value): + assert value in (0, 4, 8, 16, 88, 256, 1 << 24) + # Because 88 colors is rare and we can't guarantee consistent behavior, + # when 88 colors is detected, it is treated as 16 colors + self._number_of_colors = 16 if value == 88 else value + self.__clear_color_capabilities() + + @property + def color_distance_algorithm(self): + """ + Color distance algorithm used by :meth:`rgb_downconvert`. + + The slowest, but most accurate, 'cie2000', is default. Other available options are 'rgb', + 'rgb-weighted', 'cie76', and 'cie94'. + """ + return self._color_distance_algorithm + + @color_distance_algorithm.setter + def color_distance_algorithm(self, value): + assert value in COLOR_DISTANCE_ALGORITHMS + self._color_distance_algorithm = value + self.__clear_color_capabilities() + + @property + def _foreground_color(self): + """ + Convenience capability to support :attr:`~.on_color`. + + Prefers returning sequence for capability ``setaf``, "Set foreground color to #1, using ANSI + escape". If the given terminal does not support such sequence, fallback to returning + attribute ``setf``, "Set foreground color #1". + """ + return self.setaf or self.setf + + @property + def _background_color(self): + """ + Convenience capability to support :attr:`~.on_color`. + + Prefers returning sequence for capability ``setab``, "Set background color to #1, using ANSI + escape". If the given terminal does not support such sequence, fallback to returning + attribute ``setb``, "Set background color #1". + """ + return self.setab or self.setb + + def ljust(self, text, width=None, fillchar=u' '): + """ + Left-align ``text``, which may contain terminal sequences. + + :arg str text: String to be aligned + :arg int width: Total width to fill with aligned text. If + unspecified, the whole width of the terminal is filled. + :arg str fillchar: String for padding the right of ``text`` + :rtype: str + :returns: String of ``text``, left-aligned by ``width``. + """ + # Left justification is different from left alignment, but we continue + # the vocabulary error of the str method for polymorphism. + if width is None: + width = self.width + return Sequence(text, self).ljust(width, fillchar) + + def rjust(self, text, width=None, fillchar=u' '): + """ + Right-align ``text``, which may contain terminal sequences. + + :arg str text: String to be aligned + :arg int width: Total width to fill with aligned text. If + unspecified, the whole width of the terminal is used. + :arg str fillchar: String for padding the left of ``text`` + :rtype: str + :returns: String of ``text``, right-aligned by ``width``. + """ + if width is None: + width = self.width + return Sequence(text, self).rjust(width, fillchar) + + def center(self, text, width=None, fillchar=u' '): + """ + Center ``text``, which may contain terminal sequences. + + :arg str text: String to be centered + :arg int width: Total width in which to center text. If + unspecified, the whole width of the terminal is used. + :arg str fillchar: String for padding the left and right of ``text`` + :rtype: str + :returns: String of ``text``, centered by ``width`` + """ + if width is None: + width = self.width + return Sequence(text, self).center(width, fillchar) + + def truncate(self, text, width=None): + r""" + Truncate ``text`` to maximum ``width`` printable characters, retaining terminal sequences. + + :arg str text: Text to truncate + :arg int width: The maximum width to truncate it to + :rtype: str + :returns: ``text`` truncated to at most ``width`` printable characters + + >>> term.truncate(u'xyz\x1b[0;3m', 2) + u'xy\x1b[0;3m' + """ + if width is None: + width = self.width + return Sequence(text, self).truncate(width) + + def length(self, text): + u""" + Return printable length of a string containing sequences. + + :arg str text: String to measure. May contain terminal sequences. + :rtype: int + :returns: The number of terminal character cells the string will occupy + when printed + + Wide characters that consume 2 character cells are supported: + + >>> term = Terminal() + >>> term.length(term.clear + term.red(u'コンニチハ')) + 10 + + .. note:: Sequences such as 'clear', which is considered as a + "movement sequence" because it would move the cursor to + (y, x)(0, 0), are evaluated as a printable length of + *0*. + """ + return Sequence(text, self).length() + + def strip(self, text, chars=None): + r""" + Return ``text`` without sequences and leading or trailing whitespace. + + :rtype: str + :returns: Text with leading and trailing whitespace removed + + >>> term.strip(u' \x1b[0;3m xyz ') + u'xyz' + """ + return Sequence(text, self).strip(chars) + + def rstrip(self, text, chars=None): + r""" + Return ``text`` without terminal sequences or trailing whitespace. + + :rtype: str + :returns: Text with terminal sequences and trailing whitespace removed + + >>> term.rstrip(u' \x1b[0;3m xyz ') + u' xyz' + """ + return Sequence(text, self).rstrip(chars) + + def lstrip(self, text, chars=None): + r""" + Return ``text`` without terminal sequences or leading whitespace. + + :rtype: str + :returns: Text with terminal sequences and leading whitespace removed + + >>> term.lstrip(u' \x1b[0;3m xyz ') + u'xyz ' + """ + return Sequence(text, self).lstrip(chars) + + def strip_seqs(self, text): + r""" + Return ``text`` stripped of only its terminal sequences. + + :rtype: str + :returns: Text with terminal sequences removed + + >>> term.strip_seqs(u'\x1b[0;3mxyz') + u'xyz' + >>> term.strip_seqs(term.cuf(5) + term.red(u'test')) + u' test' + + .. note:: Non-destructive sequences that adjust horizontal distance + (such as ``\b`` or ``term.cuf(5)``) are replaced by destructive + space or erasing. + """ + return Sequence(text, self).strip_seqs() + + def split_seqs(self, text, maxsplit=0): + r""" + Return ``text`` split by individual character elements and sequences. + + :arg str text: String containing sequences + :arg int maxsplit: When maxsplit is nonzero, at most maxsplit splits + occur, and the remainder of the string is returned as the final element + of the list (same meaning is argument for :func:`re.split`). + :rtype: list[str] + :returns: List of sequences and individual characters + + >>> term.split_seqs(term.underline(u'xyz')) + ['\x1b[4m', 'x', 'y', 'z', '\x1b(B', '\x1b[m'] + + >>> term.split_seqs(term.underline(u'xyz'), 1) + ['\x1b[4m', r'xyz\x1b(B\x1b[m'] + """ + pattern = self._caps_unnamed_any + result = [] + for idx, match in enumerate(re.finditer(pattern, text)): + result.append(match.group()) + if maxsplit and idx == maxsplit: + remaining = text[match.end():] + if remaining: + result[-1] += remaining + break + return result + + def wrap(self, text, width=None, **kwargs): + r""" + Text-wrap a string, returning a list of wrapped lines. + + :arg str text: Unlike :func:`textwrap.wrap`, ``text`` may contain + terminal sequences, such as colors, bold, or underline. By + default, tabs in ``text`` are expanded by + :func:`string.expandtabs`. + :arg int width: Unlike :func:`textwrap.wrap`, ``width`` will + default to the width of the attached terminal. + :arg \**kwargs: See :py:class:`textwrap.TextWrapper` + :rtype: list + :returns: List of wrapped lines + + See :class:`textwrap.TextWrapper` for keyword arguments that can + customize wrapping behaviour. + """ + width = self.width if width is None else width + wrapper = SequenceTextWrapper(width=width, term=self, **kwargs) + lines = [] + for line in text.splitlines(): + lines.extend(iter(wrapper.wrap(line)) if line.strip() else (u'',)) + + return lines + + def getch(self): + """ + Read, decode, and return the next byte from the keyboard stream. + + :rtype: unicode + :returns: a single unicode character, or ``u''`` if a multi-byte + sequence has not yet been fully received. + + This method name and behavior mimics curses ``getch(void)``, and + it supports :meth:`inkey`, reading only one byte from + the keyboard string at a time. This method should always return + without blocking if called after :meth:`kbhit` has returned True. + + Implementors of alternate input stream methods should override + this method. + """ + assert self._keyboard_fd is not None + byte = os.read(self._keyboard_fd, 1) + return self._keyboard_decoder.decode(byte, final=False) + + def ungetch(self, text): + """ + Buffer input data to be discovered by next call to :meth:`~.inkey`. + + :arg str text: String to be buffered as keyboard input. + """ + self._keyboard_buf.extendleft(text) + + def kbhit(self, timeout=None): + """ + Return whether a keypress has been detected on the keyboard. + + This method is used by :meth:`inkey` to determine if a byte may + be read using :meth:`getch` without blocking. The standard + implementation simply uses the :func:`select.select` call on stdin. + + :arg float timeout: When ``timeout`` is 0, this call is + non-blocking, otherwise blocking indefinitely until keypress + is detected when None (default). When ``timeout`` is a + positive number, returns after ``timeout`` seconds have + elapsed (float). + :rtype: bool + :returns: True if a keypress is awaiting to be read on the keyboard + attached to this terminal. When input is not a terminal, False is + always returned. + """ + stime = time.time() + ready_r = [None, ] + check_r = [self._keyboard_fd] if self._keyboard_fd is not None else [] + + while HAS_TTY: + try: + ready_r, _, _ = select.select(check_r, [], [], timeout) + except InterruptedError: + # Beginning with python3.5, IntrruptError is no longer thrown + # https://www.python.org/dev/peps/pep-0475/ + # + # For previous versions of python, we take special care to + # retry select on InterruptedError exception, namely to handle + # a custom SIGWINCH handler. When installed, it would cause + # select() to be interrupted with errno 4 (EAGAIN). + # + # Just as in python3.5, it is ignored, and a new timeout value + # is derived from the previous unless timeout becomes negative. + # because the signal handler has blocked beyond timeout, then + # False is returned. Otherwise, when timeout is None, we + # continue to block indefinitely (default). + if timeout is not None: + # subtract time already elapsed, + timeout -= time.time() - stime + if timeout > 0: + continue + # no time remains after handling exception (rare) + ready_r = [] # pragma: no cover + break # pragma: no cover + else: + break + + return False if self._keyboard_fd is None else check_r == ready_r + + @contextlib.contextmanager + def cbreak(self): + """ + Allow each keystroke to be read immediately after it is pressed. + + This is a context manager for :func:`tty.setcbreak`. + + This context manager activates 'rare' mode, the opposite of 'cooked' + mode: On entry, :func:`tty.setcbreak` mode is activated disabling + line-buffering of keyboard input and turning off automatic echo of + input as output. + + .. note:: You must explicitly print any user input you would like + displayed. If you provide any kind of editing, you must handle + backspace and other line-editing control functions in this mode + as well! + + **Normally**, characters received from the keyboard cannot be read + by Python until the *Return* key is pressed. Also known as *cooked* or + *canonical input* mode, it allows the tty driver to provide + line-editing before shuttling the input to your program and is the + (implicit) default terminal mode set by most unix shells before + executing programs. + + Technically, this context manager sets the :mod:`termios` attributes + of the terminal attached to :obj:`sys.__stdin__`. + + .. note:: :func:`tty.setcbreak` sets ``VMIN = 1`` and ``VTIME = 0``, + see http://www.unixwiz.net/techtips/termios-vmin-vtime.html + """ + if HAS_TTY and self._keyboard_fd is not None: + # Save current terminal mode: + save_mode = termios.tcgetattr(self._keyboard_fd) + save_line_buffered = self._line_buffered + tty.setcbreak(self._keyboard_fd, termios.TCSANOW) + try: + self._line_buffered = False + yield + finally: + # Restore prior mode: + termios.tcsetattr(self._keyboard_fd, + termios.TCSAFLUSH, + save_mode) + self._line_buffered = save_line_buffered + else: + yield + + @contextlib.contextmanager + def raw(self): + r""" + A context manager for :func:`tty.setraw`. + + Although both :meth:`break` and :meth:`raw` modes allow each keystroke + to be read immediately after it is pressed, Raw mode disables + processing of input and output. + + In cbreak mode, special input characters such as ``^C`` or ``^S`` are + interpreted by the terminal driver and excluded from the stdin stream. + In raw mode these values are receive by the :meth:`inkey` method. + + Because output processing is not done, the newline ``'\n'`` is not + enough, you must also print carriage return to ensure that the cursor + is returned to the first column:: + + with term.raw(): + print("printing in raw mode", end="\r\n") + """ + if HAS_TTY and self._keyboard_fd is not None: + # Save current terminal mode: + save_mode = termios.tcgetattr(self._keyboard_fd) + save_line_buffered = self._line_buffered + tty.setraw(self._keyboard_fd, termios.TCSANOW) + try: + self._line_buffered = False + yield + finally: + # Restore prior mode: + termios.tcsetattr(self._keyboard_fd, + termios.TCSAFLUSH, + save_mode) + self._line_buffered = save_line_buffered + else: + yield + + @contextlib.contextmanager + def keypad(self): + r""" + Context manager that enables directional keypad input. + + On entrying, this puts the terminal into "keyboard_transmit" mode by + emitting the keypad_xmit (smkx) capability. On exit, it emits + keypad_local (rmkx). + + On an IBM-PC keyboard with numeric keypad of terminal-type *xterm*, + with numlock off, the lower-left diagonal key transmits sequence + ``\\x1b[F``, translated to :class:`~.Terminal` attribute + ``KEY_END``. + + However, upon entering :meth:`keypad`, ``\\x1b[OF`` is transmitted, + translating to ``KEY_LL`` (lower-left key), allowing you to determine + diagonal direction keys. + """ + try: + self.stream.write(self.smkx) + self.stream.flush() + yield + finally: + self.stream.write(self.rmkx) + self.stream.flush() + + def inkey(self, timeout=None, esc_delay=0.35): + """ + Read and return the next keyboard event within given timeout. + + Generally, this should be used inside the :meth:`raw` context manager. + + :arg float timeout: Number of seconds to wait for a keystroke before + returning. When ``None`` (default), this method may block + indefinitely. + :arg float esc_delay: To distinguish between the keystroke of + ``KEY_ESCAPE``, and sequences beginning with escape, the parameter + ``esc_delay`` specifies the amount of time after receiving escape + (``chr(27)``) to seek for the completion of an application key + before returning a :class:`~.Keystroke` instance for + ``KEY_ESCAPE``. + :rtype: :class:`~.Keystroke`. + :returns: :class:`~.Keystroke`, which may be empty (``u''``) if + ``timeout`` is specified and keystroke is not received. + + .. note:: When used without the context manager :meth:`cbreak`, or + :meth:`raw`, :obj:`sys.__stdin__` remains line-buffered, and this + function will block until the return key is pressed! + + .. note:: On Windows, a 10 ms sleep is added to the key press detection loop to reduce CPU + load. Due to the behavior of :py:func:`time.sleep` on Windows, this will actually + result in a 15.6 ms delay when using the default `time resolution + `_. + Decreasing the time resolution will reduce this to 10 ms, while increasing it, which + is rarely done, will have a perceptable impact on the behavior. + """ + resolve = functools.partial(resolve_sequence, + mapper=self._keymap, + codes=self._keycodes) + + stime = time.time() + + # re-buffer previously received keystrokes, + ucs = u'' + while self._keyboard_buf: + ucs += self._keyboard_buf.pop() + + # receive all immediately available bytes + while self.kbhit(timeout=0): + ucs += self.getch() + + # decode keystroke, if any + ks = resolve(text=ucs) + + # so long as the most immediately received or buffered keystroke is + # incomplete, (which may be a multibyte encoding), block until until + # one is received. + while not ks and self.kbhit(timeout=_time_left(stime, timeout)): + ucs += self.getch() + ks = resolve(text=ucs) + + # handle escape key (KEY_ESCAPE) vs. escape sequence (like those + # that begin with \x1b[ or \x1bO) up to esc_delay when + # received. This is not optimal, but causes least delay when + # "meta sends escape" is used, or when an unsupported sequence is + # sent. + # + # The statement, "ucs in self._keymap_prefixes" has an effect on + # keystrokes such as Alt + Z ("\x1b[z" with metaSendsEscape): because + # no known input sequences begin with such phrasing to allow it to be + # returned more quickly than esc_delay otherwise blocks for. + if ks.code == self.KEY_ESCAPE: + esctime = time.time() + while (ks.code == self.KEY_ESCAPE and + ucs in self._keymap_prefixes and + self.kbhit(timeout=_time_left(esctime, esc_delay))): + ucs += self.getch() + ks = resolve(text=ucs) + + # buffer any remaining text received + self.ungetch(ucs[len(ks):]) + return ks + + +class WINSZ(collections.namedtuple('WINSZ', ( + 'ws_row', 'ws_col', 'ws_xpixel', 'ws_ypixel'))): + """ + Structure represents return value of :const:`termios.TIOCGWINSZ`. + + .. py:attribute:: ws_row + + rows, in characters + + .. py:attribute:: ws_col + + columns, in characters + + .. py:attribute:: ws_xpixel + + horizontal size, pixels + + .. py:attribute:: ws_ypixel + + vertical size, pixels + """ + #: format of termios structure + _FMT = 'hhhh' + #: buffer of termios structure appropriate for ioctl argument + _BUF = '\x00' * struct.calcsize(_FMT) + + +#: _CUR_TERM = None +#: From libcurses/doc/ncurses-intro.html (ESR, Thomas Dickey, et. al):: +#: +#: "After the call to setupterm(), the global variable cur_term is set to +#: point to the current structure of terminal capabilities. By calling +#: setupterm() for each terminal, and saving and restoring cur_term, it +#: is possible for a program to use two or more terminals at once." +#: +#: However, if you study Python's ``./Modules/_cursesmodule.c``, you'll find:: +#: +#: if (!initialised_setupterm && setupterm(termstr,fd,&err) == ERR) { +#: +#: Python - perhaps wrongly - will not allow for re-initialisation of new +#: terminals through :func:`curses.setupterm`, so the value of cur_term cannot +#: be changed once set: subsequent calls to :func:`curses.setupterm` have no +#: effect. +#: +#: Therefore, the :attr:`Terminal.kind` of each :class:`Terminal` is +#: essentially a singleton. This global variable reflects that, and a warning +#: is emitted if somebody expects otherwise. diff --git a/blessed/terminal.py:Zone.Identifier b/blessed/terminal.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/terminal.pyi b/blessed/terminal.pyi new file mode 100644 index 0000000..bff670b --- /dev/null +++ b/blessed/terminal.pyi @@ -0,0 +1,108 @@ +# std imports +from typing import IO, Any, List, Tuple, Union, Optional, OrderedDict, SupportsIndex, ContextManager + +# local +from .keyboard import Keystroke +from .sequences import Termcap +from .formatters import (FormattingString, + NullCallableString, + ParameterizingString, + FormattingOtherString) + +HAS_TTY: bool + +class Terminal: + caps: OrderedDict[str, Termcap] + errors: List[str] = ... + def __init__( + self, + kind: Optional[str] = ..., + stream: Optional[IO[str]] = ..., + force_styling: bool = ..., + ) -> None: ... + def __getattr__( + self, attr: str + ) -> Union[NullCallableString, ParameterizingString, FormattingString]: ... + @property + def kind(self) -> str: ... + @property + def does_styling(self) -> bool: ... + @property + def is_a_tty(self) -> bool: ... + @property + def height(self) -> int: ... + @property + def width(self) -> int: ... + @property + def pixel_height(self) -> int: ... + @property + def pixel_width(self) -> int: ... + def location( + self, x: Optional[int] = ..., y: Optional[int] = ... + ) -> ContextManager[None]: ... + def get_location(self, timeout: Optional[float] = ...) -> Tuple[int, int]: ... + def get_fgcolor(self, timeout: Optional[float] = ...) -> Tuple[int, int, int]: ... + def get_bgcolor(self, timeout: Optional[float] = ...) -> Tuple[int, int, int]: ... + def fullscreen(self) -> ContextManager[None]: ... + def hidden_cursor(self) -> ContextManager[None]: ... + def move_xy(self, x: int, y: int) -> ParameterizingString: ... + def move_yx(self, y: int, x: int) -> ParameterizingString: ... + @property + def move_left(self) -> FormattingOtherString: ... + @property + def move_right(self) -> FormattingOtherString: ... + @property + def move_up(self) -> FormattingOtherString: ... + @property + def move_down(self) -> FormattingOtherString: ... + @property + def color(self) -> Union[NullCallableString, ParameterizingString]: ... + def color_rgb(self, red: int, green: int, blue: int) -> FormattingString: ... + @property + def on_color(self) -> Union[NullCallableString, ParameterizingString]: ... + def on_color_rgb(self, red: int, green: int, blue: int) -> FormattingString: ... + def formatter(self, value: str) -> Union[NullCallableString, FormattingString]: ... + def rgb_downconvert(self, red: int, green: int, blue: int) -> int: ... + @property + def normal(self) -> str: ... + def link(self, url: str, text: str, url_id: str = ...) -> str: ... + @property + def stream(self) -> IO[str]: ... + @property + def number_of_colors(self) -> int: ... + @number_of_colors.setter + def number_of_colors(self, value: int) -> None: ... + @property + def color_distance_algorithm(self) -> str: ... + @color_distance_algorithm.setter + def color_distance_algorithm(self, value: str) -> None: ... + def ljust( + self, text: str, width: Optional[SupportsIndex] = ..., fillchar: str = ... + ) -> str: ... + def rjust( + self, text: str, width: Optional[SupportsIndex] = ..., fillchar: str = ... + ) -> str: ... + def center( + self, text: str, width: Optional[SupportsIndex] = ..., fillchar: str = ... + ) -> str: ... + def truncate(self, text: str, width: Optional[SupportsIndex] = ...) -> str: ... + def length(self, text: str) -> int: ... + def strip(self, text: str, chars: Optional[str] = ...) -> str: ... + def rstrip(self, text: str, chars: Optional[str] = ...) -> str: ... + def lstrip(self, text: str, chars: Optional[str] = ...) -> str: ... + def strip_seqs(self, text: str) -> str: ... + def split_seqs(self, text: str, maxsplit: int) -> List[str]: ... + def wrap( + self, text: str, width: Optional[int] = ..., **kwargs: Any + ) -> List[str]: ... + def getch(self) -> str: ... + def ungetch(self, text: str) -> None: ... + def kbhit(self, timeout: Optional[float] = ...) -> bool: ... + def cbreak(self) -> ContextManager[None]: ... + def raw(self) -> ContextManager[None]: ... + def keypad(self) -> ContextManager[None]: ... + def inkey( + self, timeout: Optional[float] = ..., esc_delay: float = ... + ) -> Keystroke: ... + +class WINSZ: ... diff --git a/blessed/terminal.pyi:Zone.Identifier b/blessed/terminal.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/win_terminal.py b/blessed/win_terminal.py new file mode 100644 index 0000000..267e028 --- /dev/null +++ b/blessed/win_terminal.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +"""Module containing Windows version of :class:`Terminal`.""" + +from __future__ import absolute_import + +# std imports +import time +import msvcrt # pylint: disable=import-error +import contextlib + +# 3rd party +from jinxed import win32 # pylint: disable=import-error + +# local +from .terminal import WINSZ +from .terminal import Terminal as _Terminal + + +class Terminal(_Terminal): + """Windows subclass of :class:`Terminal`.""" + + def getch(self): + r""" + Read, decode, and return the next byte from the keyboard stream. + + :rtype: unicode + :returns: a single unicode character, or ``u''`` if a multi-byte + sequence has not yet been fully received. + + For versions of Windows 10.0.10586 and later, the console is expected + to be in ENABLE_VIRTUAL_TERMINAL_INPUT mode and the default method is + called. + + For older versions of Windows, msvcrt.getwch() is used. If the received + character is ``\x00`` or ``\xe0``, the next character is + automatically retrieved. + """ + if win32.VTMODE_SUPPORTED: + return super(Terminal, self).getch() + + rtn = msvcrt.getwch() + if rtn in ('\x00', '\xe0'): + rtn += msvcrt.getwch() + return rtn + + def kbhit(self, timeout=None): + """ + Return whether a keypress has been detected on the keyboard. + + This method is used by :meth:`inkey` to determine if a byte may + be read using :meth:`getch` without blocking. This is implemented + by wrapping msvcrt.kbhit() in a timeout. + + :arg float timeout: When ``timeout`` is 0, this call is + non-blocking, otherwise blocking indefinitely until keypress + is detected when None (default). When ``timeout`` is a + positive number, returns after ``timeout`` seconds have + elapsed (float). + :rtype: bool + :returns: True if a keypress is awaiting to be read on the keyboard + attached to this terminal. + """ + end = time.time() + (timeout or 0) + while True: + + if msvcrt.kbhit(): + return True + + if timeout is not None and end < time.time(): + break + + time.sleep(0.01) # Sleep to reduce CPU load + return False + + @staticmethod + def _winsize(fd): + """ + Return named tuple describing size of the terminal by ``fd``. + + :arg int fd: file descriptor queries for its window size. + :rtype: WINSZ + :returns: named tuple describing size of the terminal + + WINSZ is a :class:`collections.namedtuple` instance, whose structure + directly maps to the return value of the :const:`termios.TIOCGWINSZ` + ioctl return value. The return parameters are: + + - ``ws_row``: width of terminal by its number of character cells. + - ``ws_col``: height of terminal by its number of character cells. + - ``ws_xpixel``: width of terminal by pixels (not accurate). + - ``ws_ypixel``: height of terminal by pixels (not accurate). + """ + window = win32.get_terminal_size(fd) + return WINSZ(ws_row=window.lines, ws_col=window.columns, + ws_xpixel=0, ws_ypixel=0) + + @contextlib.contextmanager + def cbreak(self): + """ + Allow each keystroke to be read immediately after it is pressed. + + This is a context manager for ``jinxed.w32.setcbreak()``. + + .. note:: You must explicitly print any user input you would like + displayed. If you provide any kind of editing, you must handle + backspace and other line-editing control functions in this mode + as well! + + **Normally**, characters received from the keyboard cannot be read + by Python until the *Return* key is pressed. Also known as *cooked* or + *canonical input* mode, it allows the tty driver to provide + line-editing before shuttling the input to your program and is the + (implicit) default terminal mode set by most unix shells before + executing programs. + """ + if self._keyboard_fd is not None: + + filehandle = msvcrt.get_osfhandle(self._keyboard_fd) + + # Save current terminal mode: + save_mode = win32.get_console_mode(filehandle) + save_line_buffered = self._line_buffered + win32.setcbreak(filehandle) + try: + self._line_buffered = False + yield + finally: + win32.set_console_mode(filehandle, save_mode) + self._line_buffered = save_line_buffered + + else: + yield + + @contextlib.contextmanager + def raw(self): + """ + A context manager for ``jinxed.w32.setcbreak()``. + + Although both :meth:`break` and :meth:`raw` modes allow each keystroke + to be read immediately after it is pressed, Raw mode disables + processing of input and output. + + In cbreak mode, special input characters such as ``^C`` are + interpreted by the terminal driver and excluded from the stdin stream. + In raw mode these values are receive by the :meth:`inkey` method. + """ + if self._keyboard_fd is not None: + + filehandle = msvcrt.get_osfhandle(self._keyboard_fd) + + # Save current terminal mode: + save_mode = win32.get_console_mode(filehandle) + save_line_buffered = self._line_buffered + win32.setraw(filehandle) + try: + self._line_buffered = False + yield + finally: + win32.set_console_mode(filehandle, save_mode) + self._line_buffered = save_line_buffered + + else: + yield diff --git a/blessed/win_terminal.py:Zone.Identifier b/blessed/win_terminal.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/blessed/win_terminal.pyi b/blessed/win_terminal.pyi new file mode 100644 index 0000000..275f16f --- /dev/null +++ b/blessed/win_terminal.pyi @@ -0,0 +1,11 @@ +# std imports +from typing import Optional, ContextManager + +# local +from .terminal import Terminal as _Terminal + +class Terminal(_Terminal): + def getch(self) -> str: ... + def kbhit(self, timeout: Optional[float] = ...) -> bool: ... + def cbreak(self) -> ContextManager[None]: ... + def raw(self) -> ContextManager[None]: ... diff --git a/blessed/win_terminal.pyi:Zone.Identifier b/blessed/win_terminal.pyi:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/example_game.py b/example_game.py new file mode 100644 index 0000000..ba996f5 --- /dev/null +++ b/example_game.py @@ -0,0 +1,7 @@ +from retro.game import Game +from retro.agent import ArrowKeyAgent + +agent = ArrowKeyAgent() +state = {} +game = Game([agent], state) +game.play() \ No newline at end of file diff --git a/nav_game.py b/nav_game.py index 191d83d..aa4cad3 100644 --- a/nav_game.py +++ b/nav_game.py @@ -2,3 +2,13 @@ # ------------ # By MWC Contributors # This class implements a simple game where a spaceship avoids asteroids. + +from retro.game import Game +from spaceship import Spaceship +from asteroid_spawner import AsteroidSpawner + +board_size = (25, 25) +ship = Spaceship(board_size) +spawner = AsteroidSpawner(board_size) +game = Game([ship, spawner], {"score": 0}, board_size=board_size) +game.play() \ No newline at end of file diff --git a/retro/__pycache__/agent.cpython-310.pyc b/retro/__pycache__/agent.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1b9a2fb4e9242740830bade5673d8ffb8bc91cf6 GIT binary patch literal 4885 zcmdT|&5zs06(=caSNr8SNozYrQ49-t>Ur1&xO=Dpv0@AuwpdT_Ao z;1JjTIsE%|$N3vQtRFQz+{dXHE)Sg03El5;{?Y5vlNZ+TRKt@S)}K19Mu1V3!^6{k zoa!T7goANT&kNmNE%bWzu-0pY_1;$4=xv8vy=J)G+XP@Vgi8L+(M| zI39^a^S^F6&J!_}Lg7g<&Vn(QJkUb2C}Dam*oaR=hn=P@kb-N$c*4xoHVeftO7Mau z;$oNK2x6`jyNL9dsdOS(Iy4fv)-oDoT0njhvWd{+G*oOL;`E}!o~0R^@EI+fj7a

un04<&Lb5KVo@<_*L*o1 zlCd*rQ5c9@Y1dVdABJSZgz41S((gY29{qls37CnA7h{pINV7;4^Ft#wl`KcmSiq%0FW_-ZLN?%7V#u{ccsiBoRFch%a8Vsr z7+1n}WMq(+rm2c_lqN^)@QHYyMN)(<(q!d&;N9;ZMu~0%FWrL5%Q?{6EJ#673M8R} zl*$fXj?PGE z8$teH=96Omb-2?Q5qAj4S{0MXt;Qg@G@vnSb4buofK!=?6`J!+948nV zkvtP<3bA^Qw1=k^qGBUe4Qv+iip@4k4O!FNaWopkt8mgB`F_r06Xsyk^*p;2J*Svw ztyhdP8Zi#Xai%ZQyCG;OQ6{t4;fpq#wX6f%43)srEmDKh=#1(_MK!B~$v_N{A-kPT zZzJ0aiB-5hv{>ct2d}&zQ4<|m$0ctV@8{Rxel7eo@dq&v&Q8t8fQsnypnDhRXE^^s zQ$rU8(&HVxw2WYpB1V8Vs;FOv7EHMTK*ZC=_@F2^q^Cn4#5C z)nv+Zc1U0%feBah!p1Jv3^FM(%M>(UZN}d=oK1egYA&q1heGtnNs!vp;ZM~*SLkrSz^2&MT<#i?EVP2>4vI#iN zsPls~6y3>1@XIb(RsRaKGVl%Qb?wzSurrXhGt8s0Atx?eg~ZsP$Cy^EKZ9g0}}-!2vBLe5GHskSoC&V(sm)HM;x>y_Zm zp2lR*bP5y?mlTUvmfRjKzZF413l;o@=quKTtBBYT4PrzXper=34oAZ^%3>~AEHt{AHcPU}fPN7o zYBm|9@sb{~Ou^jGsK>feZ4-oQDuRf|Mz5lIqQEQFL?3VJ!*!t;WFs~~Fp~8fb}M1ImE?i)*I-)@eR6=wkpJ@(COb+9 zPE6{yYLa#|`A+EwxrgUfZp(f8d>!q37z@7~^&#HcT)jTOVRQBJkdxMa-t#6oUk&WP zw!&@fxn8#iJ2;jyy?7*M2A=t+$4o`0Q83h3WPZ^>PSSHw4VP(#EmT>xISDW+VymDe z^<)i~H&i-hDxHH$=UO+*9hzAZwsERK@DbiyKf3dq6@_2Jy$wvD^D~4UBlWl5w*lgH~#7^BwsAcfgej`>Ib~1{MQHRtSi?PHa)iZS5uODR&;@GrByC zYTMFkMVCpjIuMni|5)Qm+bUNxHePRW!%QeNa+^!)p;@+STM_KPo1?cL{(zdktuDf|>; zp)wwWoMC z5%&RZ7Tyntq?fLIci}D^-^DZjMuIqW|Lpw5d+8$9yr<@D)#kr0LcnhD*S&9R-P)FS zxd$nMo$r2C?xq1s-lRs8w!#LUycVaUf;Im=SG9i) zVz8#fUzQyCHeKGK%e!J#knFwZ$*X8+WvJ( dBTibLAw~0Wm7KhJ&r;SIt zIlyxU7XVjz4RFoiBA?$P%{zVYe5p}DkKsD2f)S)gIq6xP!n>9(Z}>yemNJ%|6Q)a^ z7XkJ>UGcnO%*P@2tDg6A?1$ON36Vuu#+dW(S~8B4*3f?uL_6MIkPN(j%tdRs*Z-z9 zj)LUdR_H(Xq)24k%B(&z5!l$o0{ki^OpEyy^y#n+$}z1Ng+&jv3W94r@n^ zM?xaO0np-kK(?(!xGn48T^EN!DR}*a>ayqcLtiP+%XluqxLg1OYRjmar*%5{{Pvx$ zO?@~Lsg*cW7SP~2x_05AFTybQVp6(!c~}Tl4K}E^j-?*Hp5Wt_~Fm-E73>3@`k&}IaCh; z80H8bOyum?2F55bX3a3hIueiYWWh~j00%LN-65=t_a@6WeTP_1aWjLIY^qq zcovYF#!S>7z{{sF&m_U{g6JAAd1aQ^fofdYkp5_pm1Bv#%xLzV@IQkABWNRBMAsI^ zn6arZ!Q@4g(;nki&*rS>@S-W94lf*)dj(TMi%{rFy51FX7({;9jpMDMABLw`I8QN{ zQ!FH%KH9vYa1i6L8i7gS<1(glHMR14N?#DUih0bl2+DU3oPP)xAGV!2FFhRbw`maNJBt6@$R*|a;* znx!knUzeWz^6baaKU&-qZHx#?!8;9P$BpnvvbRj{+V?ERYI|E`G1^ zw`56t4(@ch{kktk<~*Tf%qPQCREae~;sKDj7c*hT!&KP1^nLc9;|B__DXb}VQfGAs KeucWzVE+R`IP|;# literal 0 HcmV?d00001 diff --git a/retro/__pycache__/game.cpython-310.pyc b/retro/__pycache__/game.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0562a8319159c03fb50fa3122462f068e502bad9 GIT binary patch literal 7116 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 literal 0 HcmV?d00001 diff --git a/retro/__pycache__/graph.cpython-310.pyc b/retro/__pycache__/graph.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..479253c8500465c57b39ecded6ce573948f3f2bf GIT binary patch literal 6836 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 literal 0 HcmV?d00001 diff --git a/retro/__pycache__/validation.cpython-310.pyc b/retro/__pycache__/validation.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b72699a94951cebdaa46347c82b9c37cb218d6d GIT binary patch literal 1680 zcmZuyOK&4Z5bo}o@r=Dr*xiKi+C>A=9xSbFP%a#z0OD~#!dfZO9)QRiyF1AwV~@AH zy&;w(0p?$zMR4RV^_5fp00%DcRgZT_vW!&ysIIQ={=TZ3a&4`}@Tv8`j(&|8`;#Wu zKY__75c3#~WRm~PzF|_x06mwXjL-|&kTLo|CbEe>l&yW%xijL}^75l`2>A?RzCvT! z0^XKFw=7?JCN`aKrQ?~g=}GonrCD09cVZWxWG1s#yN%~rk;}}g zK}LH9!Ne8xFLxy6jc$>L{GionE;0`eg5yh%xJETD-;E zkQTqsL$24Lx`BKEMFZlIe}VxaZ-Kmd%?im0^CbtO;w7TLg}<}U+2Q@~#3jEJHd=ri z{Rr3x3$hCr!2d-^=s(-=_(%PDb06@iG&kw|^~dROW-G9V1hvdZwmaXhEIbEL=i~9r zX2U{N)`qr!JC6>@1t@9dCZ9k#87UW%1J^vbm{!ob&|>JK+^Vr5%fZT(ZsK*Fz(wAh z3(nMqi-6ipc?zng4V!CNdQ&23h2zWz~$u=N{_-o-_;Fol-+^Np2Q>@l^ z`d@x2ua;Z}XTm(RkwgX&rxo`;C!@ERZ6G>!{H#&U^bWo6y6r0S=Itk|Jn-ai-6r48 zriJQWQMAtA@1|c<3i_z<3u$?j+Ve8aCsrLRZMs*mV^;)oC$2DxyrD-)--F%-g_?M# zU4U&sE`jzoaxrvoLTXmq;Pn9n2-4as2$IsX`Vj^df>zkThLRmcs8Vgo_bWZk-bOa~ z8n{5FEW!IeFhnJa`d{AgOwXoxF(h=KACY^9@g>Iz*!7fg_Do2sVZ4u*5Z4{yME3(1 zt3r)YgKj;{HFr^&)b~@p_lSHF@#2RqHh=hE8gzGya+DQjuUp$5tkBf&!rnzkMVVO_ z4$HF8bSwRU)InAhC|f}}JiT?Qzu0@QG4!+81c<)a2(RyVB42cTw)pV$2>Z2bekjMZ ZPrhV#T*_IY_K0ThG2uxwNs_4D{1>33ej)$> literal 0 HcmV?d00001 diff --git a/retro/__pycache__/view.cpython-310.pyc b/retro/__pycache__/view.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..120b5df0a841bdaa868fda0ff1f4c70858a9a033 GIT binary patch literal 4794 zcmbVQTW=f372cWMC6^ab^i}dTjO(Ul(UMIIw2kY!O=`h5iYl$8IxQPxyW*^Q@si8T zt}KgK0!8TdEx#ZLAU*muzo7r3z`XWJ&jku34|TsYOHw4oyp+P+FMH-Y-#K$e;o4f> zz|SxJ=b3fEF#bi2`Jai#uaKf2ArppRfze|P*5eJ{GaDwOJ|0*-yJ0iqfgwy`y)uL~ z=8cT7cZ_Q01nZ2dsWMM|8T&7lwIy1?_vD3Pdnz$$~p)85ZVgu#6*c4Y#mc>J?Z!d zCmbT3kboyPWp|OO(m}fu`)=5G_X4l^yh+2;AJu8iuaTlMvVw&b4E_X4_S+ zoOl9?vYTNLO2B7De(+4LVTmjudu>Qs{`Grx8HRDa=RNQATkb(8ZoAD;`1RgF^WFM> zzZ3ts9(a4M^kW&;)BFVluMG~BK?wt{s(tnZ02oW|G-3j(LI|y6a=MVT>Wo zC%3*~7UoQ65Zcx3c9Y>8(;w9dh#jP;h%CmzNZ7H7$>z3VLrHVM+ic5^y*QSrGEeIw z&CX$1Id=oEw7Vy5yXOh34d?DrOJ~|-ExDlr7}?n zGZZT0wfue@Ns>@GgD~pEov^R6Z7-r@sT_SrlTQnx?1Aj`V@*Jn?S(IV_vN9=HQS!_ znqX_yk?XXR)$)42;^9lALuKoaDDI)@9qNsip~|EZ5~ri<$nvZN77`OLvm)|5@^@H; z^S@eO4GVJ+E-;g8PRlrt&4QLlLzJ|L z-$Nx8F=1mKttaernA#WcA2Cn(n0?A(5*xZ3a~L1r9zL#gqDnuED_$kq9}F~)E1IIU z9pA6C<9HC=sn_=cKZ<-&lfEb7wlBhFRO^IwzYk9s`%zriOQKpk?gdp#z709jqvxM^ z!M?vGWhk{-RLzvrMZLg{Vpu>+nZ2mBL}Crscd(DvS&=!Mzj3T08OEM-ApYh=e9K(MZLWFJFsV+NP`7Dh6oY?39&Xe`NIF+MlGFa@rXzws-^$cZxm zFMH%9R$@5i*{fh-(6`j&X z5(}^w`2fvz`7YHcW{?Do{4O%ZI&y;=Kcj4kO4rc$LnQ5X$q8I#*YRilcS5Rp@3w$r z0gGlh4$+vyQ4?sxfT2$=q{+pi@l31&Y=F?3VVcFbGs-27#x*xV6b?dTnxA0`M3bz( zcJdQUugx&6%`h$Q?7qLdr zDr_^=!=-r#G`mpF=z`pqHt;WTQ_krEro2KAwqTn^f`DMt7!%L!gu_dnX(uLdo*Soj zVom$fJ-(@L%v-RP7&aoHYvc?X=ems=+UBf{8rs&ZjT+katc@DlnOPe(w249Kera3p z*UG{MZGS{x zD0=m%vvQP9M|;eT!qMioPbkFEagHRzk`(rxP*^0%XPsmyqk(Ec`wn@JhOSaZk&Yw} zFZFb~&*l5bl=A|?f2Rp*XS~4xA&|0tJOd(ZvBG629<}E<{D`*s4Q*rV+f{LKu6S z_+L<&BRDSFF0NRRoZJr4*yw_$BO9gFwG->PAuHO2=$2rg@lO%`V=SB47^_Xj&=Q=+ zv`aDRJzyVnaFwZ|F5JHF3tyl+lbX9)OUvnile%>0gAX6y-+Ju+QB7-qN>!yF5g$T$ zN%4aGJ+f+Ml^*2pX#6o{OWwMMJ{L*9VaQLE5vHZddNbTi!+^7K(*l-+;|xop3Y;|X zJbgoS8D0@QF^_Tor;(YN(o7%VHdf&Jp3z6Kmx>)O395=|B$s;JHc3pMEP)=Adu4un zK|E45@mZ^yR?ew9jjK#}E~t1DckoqGJMLlmZS*iGo9nTR&a;ylHoTkl42M~mb7mBw zj0nc5E`#>-Yv|3&C~Oe zCwaY^kF!u>`3Ub}%|6nVHa=Q+ZZI0o#bMDUzvPq<48Ak0{qwdDrcQVn3jI(RDxB_kwgpYdxidcD|Yk=jluO z;XzLbS*J|L@f2D~y4fXpNlCUVKcY< NDWYi?jUqeG{|B;n2&4c2 literal 0 HcmV?d00001 diff --git a/retro/agent.py b/retro/agent.py new file mode 100644 index 0000000..4dd57cf --- /dev/null +++ b/retro/agent.py @@ -0,0 +1,99 @@ +class Agent: + """Represents a character in the game. To create an Agent, define a new + class with some of the attributes and methods below. You may change any of + the Agent's attributes at any time, and the result will immediately be + visible in the game. + + After you create your Agents, add them to the ``Game``, either when it is created + or using ``Game.add_agent`` later on. Then the Game will take care of calling + the Agent's methods at the appropriate times. + + Attributes: + position: (Required) The character's ``(int, int)`` position on the game + board. + character: (Required unless display is ``False``.) A one-character string + which will be displayed at the Agent's position on the game board. + name: (Optional) If an agent has a name, it must be unique within the game. + Agent names can be used to look up agents with + :py:meth:`retro.game.Game.get_agent_by_name`. + color (str): (Optional) The agent's color. + `Available colors `_. + display: (Optional) When ``False``, the Agent will not be displayed on the + board. This is useful when you want to create an agent which will be displayed + later, or when you want to create an agent which acts on the Game indirectly, + for example by spawning other Agents. Defaults to True. + z: (Optional) When multiple Agents have the same position on the board, the + Agent with the highest ``z`` value will be displayed. + The Game is played on a two-dimensional (x, y) board, but you can think of + ``z`` as a third "up" dimension. Defaults to 0. + """ + character = "*" + position = (0, 0) + name = "agent" + color = "white_on_black" + display = True + z = 0 + + def play_turn(self, game): + """If an Agent has this method, it will be called once + each turn. + + Arguments: + game (Game): The game which is currently being played will be + passed to the Agent, in case it needs to check anything about + the game or make any changes. + """ + pass + + def handle_keystroke(self, keystroke, game): + """If an Agent has a this method, it will be called every + time a key is pressed in the game. + + Arguments: + keystroke (blessed.keyboard.Keystroke): The key which was pressed. You can + compare a Keystroke with a string (e.g. ``if keystroke == 'q'``) to check + whether it is a regular letter, number, or symbol on the keyboard. You can + check special keys using the keystroke's name + (e.g. ``if keystroke.name == "KEY_RIGHT"``). Run your game in debug mode to + see the names of keystrokes. + game (Game): The game which is currently being played will be + passed to the Agent, in case it needs to check anything about + the game or make any changes. + + """ + pass + +class ArrowKeyAgent: + """A simple agent which can be moved around with the arrow keys. + """ + name = "ArrowKeyAgent" + character = "*" + position = (0,0) + display = True + z = 0 + + def play_turn(self, game): + pass + + def handle_keystroke(self, keystroke, game): + """Moves the agent's position if the keystroke is one of the arrow keys. + One by one, checks the keystroke's name against each arrow key. + Then uses :py:meth:`try_to_move` to check whether the move is on the + game's board before moving. + """ + x, y = self.position + if keystroke.name == "KEY_RIGHT": + self.try_to_move((x + 1, y), game) + elif keystroke.name == "KEY_UP": + self.try_to_move((x, y - 1), game) + elif keystroke.name == "KEY_LEFT": + self.try_to_move((x - 1, y), game) + elif keystroke.name == "KEY_DOWN": + self.try_to_move((x, y + 1), game) + + def try_to_move(self, position, game): + """Moves to the position if it is on the game board. + """ + if game.on_board(position): + self.position = position + game.log(f"Position: {self.position}") diff --git a/retro/agent.py:Zone.Identifier b/retro/agent.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/errors.py b/retro/errors.py new file mode 100644 index 0000000..d95a2d6 --- /dev/null +++ b/retro/errors.py @@ -0,0 +1,40 @@ +class GameError(Exception): + pass + +class AgentWithNameAlreadyExists(GameError): + def __init__(self, name): + message = f"There is already an agent named {agent.name} in the game" + super().__init__(message) + +class AgentNotFoundByName(GameError): + def __init__(self, name): + message = f"There is no agent named {agent.name} in the game" + super().__init__(message) + +class AgentNotInGame(GameError): + def __init__(self, agent): + name = agent.name or f"anonymous {agent.__class__.__name__}" + message = f"Agent {name} is not in the game" + super().__init__(message) + +class IllegalMove(GameError): + def __init__(self, agent, position): + message = f"Agent {agent.name} tried to move to {position}" + super().__init__(message) + +class GraphError(GameError): + pass + +class TerminalTooSmall(GameError): + BORDER_X = 2 + BORDER_Y = 3 + STATE_HEIGHT = 5 + + def __init__(self, width=None, width_needed=None, height=None, height_needed=None): + if width is not None and width_needed is not None and width_needed < width: + err = f"The terminal width ({width}) is less than the required {width_needed}." + super().__init__(err) + elif height is not None and height_needed is not None and height_needed < height: + err = f"The terminal height ({height}) is less than the required {height_needed}." + else: + raise ValueError(f"TerminalTooSmall called with illegal values.") diff --git a/retro/errors.py:Zone.Identifier b/retro/errors.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/examples/__pycache__/debug.cpython-310.pyc b/retro/examples/__pycache__/debug.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3174801c16a65bc6bef179e2f975fbe7cf2ccae2 GIT binary patch literal 300 zcmYjKu};J=40W3HbQ~=Mf6?V4gv5jpCk6&27MQwJxr-?kNmHe*N+$+p{sBgQsVfsd zz`%slTd?H!?B{3OrRfSN@aX5^h4?Ep|0Aa6lCnJ`(4d(C%W}p-9hu1ToHIa%{1r-8 zf#zDg@m!#Ik3WE1@f~D~Z`y^7P2yTvBu+QpyWs}MO^J1PPfDzD+n2Z5Vp@6Zyjz## z^bxI1hkHQarNB?y=Z8jnWRg6WP~QcPfqA m7V|y1M|@VcG1$ua|GF8c<+*jbH+aG5Zo4E%AYsQ*!u|lX0!`Nd literal 0 HcmV?d00001 diff --git a/retro/examples/debug.py b/retro/examples/debug.py new file mode 100644 index 0000000..afbd490 --- /dev/null +++ b/retro/examples/debug.py @@ -0,0 +1,6 @@ +from retro.game import Game +from retro.agent import ArrowKeyAgent + +game = Game([ArrowKeyAgent()], {}, debug=True) +game.play() + diff --git a/retro/examples/debug.py:Zone.Identifier b/retro/examples/debug.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/examples/nav.py b/retro/examples/nav.py new file mode 100644 index 0000000..1c06e31 --- /dev/null +++ b/retro/examples/nav.py @@ -0,0 +1,139 @@ +from random import randint +from retro.game import Game + +HEIGHT = 25 +WIDTH = 25 + +class Spaceship: + """A player-controlled agent which moves left and right, dodging asteroids. + Spaceship is a pretty simple class. The ship's character is ``^``, and + its position starts at the bottom center of the screen. + """ + name = "ship" + character = '^' + position = (WIDTH // 2, HEIGHT - 1) + color = "black_on_skyblue1" + + def handle_keystroke(self, keystroke, game): + """When the + left or arrow key is pressed, it moves left or right. If the ship's + new position is empty, it moves to that position. If the new position + is occupied (by an asteroid!) the game ends. + """ + x, y = self.position + if keystroke.name in ("KEY_LEFT", "KEY_RIGHT"): + if keystroke.name == "KEY_LEFT": + new_position = (x - 1, y) + else: + new_position = (x + 1, y) + if game.on_board(new_position): + if game.is_empty(new_position): + self.position = new_position + else: + self.explode() + game.end() + + def explode(self): + """Sets the ship's character to ``*`` and its color to red. + """ + self.color = "crimson_on_skyblue1" + self.character = '*' + +class Asteroid: + """When Asteroids are spawned, they fall down the screen until they + reach the bottom row and are removed. + An Asteroid's position is set when it is created. + Whenever an asteroid moves, it + checks whether it has it the ship. + """ + character = 'O' + color = "deepskyblue1_on_skyblue1" + + def __init__(self, position): + self.position = position + + def play_turn(self, game): + """Nothing happens unless + ``game.turn_number`` is divisible by 2. The result is that asteroids + only move on even-numbered turns. If the asteroid is at the bottom of + the screen, it has run its course and should be removed from the game. + Otherwise, the asteroid's new position is one space down from its old + position. If the asteroid's new position is the same as the ship's + position, the game ends. + """ + if game.turn_number % 2 == 0: + self.set_color() + x, y = self.position + if y == HEIGHT - 1: + game.remove_agent(self) + else: + ship = game.get_agent_by_name('ship') + new_position = (x, y + 1) + if new_position == ship.position: + ship.explode() + game.end() + else: + self.position = new_position + + def set_color(self): + """To add to the game's drama, asteroids gradually become visible as they + fall down the screen. This method calculates the ratio of the asteroid's + position compared to the screen height--0 is the top of the screen and 1 is + the bottom ot the screen. Then sets the asteroid's color depending on the + ratio. (`Available colors `_) + """ + x, y = self.position + ratio = y / HEIGHT + if ratio < 0.2: + self.color = "deepskyblue1_on_skyblue1" + elif ratio < 0.4: + self.color = "deepskyblue2_on_skyblue1" + elif ratio < 0.6: + self.color = "deepskyblue3_on_skyblue1" + else: + self.color = "deepskyblue4_on_skyblue1" + +class AsteroidSpawner: + """An agent which is not displayed on the board, but which constantly spawns + asteroids. + """ + display = False + + def play_turn(self, game): + """Adds 1 to the game score and then uses + :py:meth:`~retro.examples.nav.should_spawn_asteroid` to decide whether to + spawn an asteroid. When :py:meth:`~retro.examples.nav.should_spawn_asteroid` + comes back ``True``, creates a new instance of + :py:class:`~retro.examples.nav.Asteroid` at a random position along the + top of the screen and adds the asteroid to the game. + """ + game.state['score'] += 1 + if self.should_spawn_asteroid(game.turn_number): + asteroid = Asteroid((randint(0, WIDTH - 1), 0)) + game.add_agent(asteroid) + + def should_spawn_asteroid(self, turn_number): + """Decides whether to spawn an asteroid. + Uses a simple but effective algorithm to make the game get + progressively more difficult: choose a random number and return + ``True`` if the number is less than the current turn number. At + the beginning of the game, few asteroids will be spawned. As the + turn number climbs toward 1000, asteroids are spawned almost + every turn. + + Arguments: + turn_number (int): The current turn in the game. + """ + return randint(0, 1000) < turn_number + +if __name__ == '__main__': + ship = Spaceship() + spawner = AsteroidSpawner() + game = Game( + [ship, spawner], + {"score": 0}, + board_size=(WIDTH, HEIGHT), + color="deepskyblue4_on_skyblue1", + ) + game.play() + diff --git a/retro/examples/nav.py:Zone.Identifier b/retro/examples/nav.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/examples/simple.py b/retro/examples/simple.py new file mode 100644 index 0000000..2af4218 --- /dev/null +++ b/retro/examples/simple.py @@ -0,0 +1,7 @@ +from retro.game import Game +from retro.agent import ArrowKeyAgent + +agent = ArrowKeyAgent() +state = {} +game = Game([agent], state) +game.play() diff --git a/retro/examples/simple.py:Zone.Identifier b/retro/examples/simple.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/examples/snake.py b/retro/examples/snake.py new file mode 100644 index 0000000..173baaf --- /dev/null +++ b/retro/examples/snake.py @@ -0,0 +1,198 @@ +from random import randint +from retro.game import Game + +class Apple: + """An agent representing the Apple. + Note how Apple doesn't have ``play_turn`` or + ``handle_keystroke`` methods: the Apple doesn't need to do + anything in this game. It just sits there waiting to get + eaten. + + Attributes: + name: "Apple" + character: '@' + color: "red_on_black" (`Here's documentation on how colors + work `_ + position: (0, 0). The Apple will choose a random position + as soon as the game starts, but it needs an initial + position to be assigned. + + """ + name = "Apple" + character = '@' + color = "red_on_black" + position = (0, 0) + + def relocate(self, game): + """Sets position to a random empty position. This method is + called whenever the snake's head touches the apple. + + Arguments: + game (Game): The current game. + """ + self.position = self.random_empty_position(game) + + def random_empty_position(self, game): + """Returns a randomly-selected empty position. Uses a very + simple algorithm: Get the game's board size, choose a + random x-value between 0 and the board width, and choose + a random y-value between 0 and the board height. Now use + the game to check whether any Agents are occupying this + position. If so, keep randomly choosing a new position + until the position is empty. + """ + bw, bh = game.board_size + occupied_positions = game.get_agents_by_position() + while True: + position = (randint(0, bw-1), randint(0, bh-1)) + if position not in occupied_positions: + return position + +class SnakeHead: + """An Agent representing the snake's head. When the game starts, you control + the snake head using the arrow keys. The SnakeHead always has a direction, and + will keep moving in that direction every turn. When you press an arrow key, + you change the SnakeHead's direction. + + Attributes: + name: "Snake head" + position: (0,0) + character: ``'v'`` Depending on the snake head's direction, its character + changes to ``'<'``, ``'^'``, ``'>'``, or ``'v'``. + next_segment: Initially ``None``, this is a reference to a SnakeBodySegment. + growing: When set to True, the snake will grow a new segment on its next move. + """ + RIGHT = (1, 0) + UP = (0, -1) + LEFT = (-1, 0) + DOWN = (0, 1) + name = "Snake head" + position = (0, 0) + direction = DOWN + character = 'v' + next_segment = None + growing = False + + def play_turn(self, game): + """On each turn, the snake head uses its position and direction to figure out + its next position. If the snake head is able to move there (it's on the board and + not occuppied by part of the snake's body), it moves. + + Then, if the snake head is on the Apple, the Apple moves to a new random position + and ``growing`` is set to True. + + Now we need to deal with two situations. First, if ``next_segment`` is not None, there is + a SnakeBodySegment attached to the head. We need the body to follow the head, + so we call ``self.next_segment.move``, passing the head's old position + (this will be the body's new position), a reference to the game, and a value for + ``growing``. If the snake needs to grow, we need to pass this information along + the body until it reaches the tail--this is where the next segment will be attached. + + If there is no ``next_segment`` but ``self.growing`` is True, it's time to add + a body! We set ``self.next_segment`` to a new SnakeBodySegment, set its + position to the head's old position, and add it to the game. We also add 1 to the + game's score. + """ + x, y = self.position + dx, dy = self.direction + if self.can_move((x+dx, y+dy), game): + self.position = (x+dx, y+dy) + if self.is_on_apple(self.position, game): + apple = game.get_agent_by_name("Apple") + apple.relocate(game) + self.growing = True + if self.next_segment: + self.next_segment.move((x, y), game, growing=self.growing) + elif self.growing: + self.next_segment = SnakeBodySegment(1, (x, y)) + game.add_agent(self.next_segment) + game.state['score'] += 1 + self.growing = False + + def handle_keystroke(self, keystroke, game): + """Checks whether one of the arrow keys has been pressed. + If so, sets the SnakeHead's direction and character. + """ + x, y = self.position + if keystroke.name == "KEY_RIGHT": + self.direction = self.RIGHT + self.character = '>' + elif keystroke.name == "KEY_UP": + self.direction = self.UP + self.character = '^' + elif keystroke.name == "KEY_LEFT": + self.direction = self.LEFT + self.character = '<' + elif keystroke.name == "KEY_DOWN": + self.direction = self.DOWN + self.character = 'v' + + def can_move(self, position, game): + on_board = game.on_board(position) + empty = game.is_empty(position) + on_apple = self.is_on_apple(position, game) + return on_board and (empty or on_apple) + + def is_on_apple(self, position, game): + apple = game.get_agent_by_name("Apple") + return apple.position == position + +class SnakeBodySegment: + """Finally, we need an Agent for the snake's body segments. + SnakeBodySegment doesn't have ``play_turn`` or ``handle_keystroke`` methods because + it never does anything on its own. It only moves when the SnakeHead, or the previous + segment, tells it to move. + + Arguments: + segment_id (int): Keeps track of how far back this segment is from the head. + This is used to give the segment a unique name, and also to keep track + of how many points the player earns for eating the next apple. + position (int, int): The initial position. + + Attributes: + character: '*' + next_segment: Initially ``None``, this is a reference to a SnakeBodySegment + when this segment is not the last one in the snake's body. + + """ + character = '*' + next_segment = None + + def __init__(self, segment_id, position): + self.segment_id = segment_id + self.name = f"Snake body segment {segment_id}" + self.position = position + + def move(self, new_position, game, growing=False): + """When SnakeHead moves, it sets off a chain reaction, moving all its + body segments. Whenever the head or a body segment has another segment + (``next_segment``), it calls that segment's ``move`` method. + + This method updates the SnakeBodySegment's position. Then, if + ``self.next_segment`` is not None, calls that segment's ``move`` method. + If there is no next segment and ``growing`` is True, then we set + ``self.next_segment`` to a new SnakeBodySegment in this segment's old + position, and update the game's score. + + Arguments: + new_position (int, int): The new position. + game (Game): A reference to the current game. + growing (bool): (Default False) When True, the snake needs to + add a new segment. + """ + old_position = self.position + self.position = new_position + if self.next_segment: + self.next_segment.move(old_position, game, growing=growing) + elif growing: + self.next_segment = SnakeBodySegment(self.segment_id + 1, old_position) + game.add_agent(self.next_segment) + game.state['score'] += self.segment_id + 1 + +if __name__ == '__main__': + head = SnakeHead() + apple = Apple() + game = Game([head, apple], {'score': 0}, board_size=(32, 16), framerate=12) + apple.relocate(game) + game.play() + diff --git a/retro/examples/snake.py:Zone.Identifier b/retro/examples/snake.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/game.py b/retro/game.py new file mode 100644 index 0000000..ea2fafb --- /dev/null +++ b/retro/game.py @@ -0,0 +1,216 @@ +from collections import defaultdict +from signal import signal, SIGWINCH +from time import sleep, perf_counter +from blessed import Terminal +from retro.view import View +from retro.validation import ( + validate_agent, + validate_state, + validate_agent_name, + validate_position, +) +from retro.errors import ( + AgentWithNameAlreadyExists, + AgentNotFoundByName, + IllegalMove, +) + +class Game: + """ + Creates a playable game. + You will use Game to create games, but don't need to read or understand how + this class works. The main work in creating a + + Arguments: + agents (list): A list of agents to add to the game. + state (dict): A dict containing the game's initial state. + board_size (int, int): (Optional) The two-dimensional size of the game board. D + debug (bool): (Optional) Turn on debug mode, showing log messages while playing. + framerate (int): (Optional) The target number of frames per second at which the + game should run. + color (str): (Optional) The game's background color scheme. `Available colors `_. + + :: + + # This example will create a simple game. + from retro.game import Game + from retro.agent import ArrowKeyAgent + + agents = [ArrowKeyAgent()] + state = {} + game = Game(agents, state) + game.play() + + """ + STATE_HEIGHT = 5 + EXIT_CHARACTERS = ("KEY_ENTER", "KEY_ESCAPE") + + def __init__(self, agents, state, board_size=(64, 32), debug=False, framerate=24, + color="white_on_black"): + self.log_messages = [] + self.agents_by_name = {} + self.agents = [] + self.state = validate_state(state) + self.board_size = board_size + self.debug = debug + self.framerate = framerate + self.turn_number = 0 + self.color = color + for agent in agents: + self.add_agent(agent) + + def play(self): + """Starts the game. + """ + self.playing = True + terminal = Terminal() + with terminal.fullscreen(), terminal.hidden_cursor(), terminal.cbreak(): + view = View(terminal, color=self.color) + while self.playing: + turn_start_time = perf_counter() + self.turn_number += 1 + self.keys_pressed = self.collect_keystrokes(terminal) + if self.debug and self.keys_pressed: + self.log("Keys: " + ', '.join(k.name or str(k) for k in self.keys_pressed)) + for agent in self.agents: + if hasattr(agent, 'handle_keystroke'): + for key in self.keys_pressed: + agent.handle_keystroke(key, self) + if hasattr(agent, 'play_turn'): + agent.play_turn(self) + if getattr(agent, 'display', True): + if not self.on_board(agent.position): + raise IllegalMove(agent, agent.position) + view.render(self) + turn_end_time = perf_counter() + time_elapsed_in_turn = turn_end_time - turn_start_time + time_remaining_in_turn = max(0, 1/self.framerate - time_elapsed_in_turn) + sleep(time_remaining_in_turn) + while True: + if terminal.inkey().name in self.EXIT_CHARACTERS: + break + + def collect_keystrokes(self, terminal): + keys = set() + while True: + key = terminal.inkey(0.001) + if key: + keys.add(key) + else: + break + return keys + + def log(self, message): + """Write a log message. + Log messages are only shown when debug mode is on. + They can be very useful for debugging. + + Arguments: + message (str): The message to log. + """ + self.log_messages.append((self.turn_number, message)) + + def end(self): + """Ends the game. No more turns will run. + """ + self.playing = False + + def add_agent(self, agent): + """Adds an agent to the game. + Whenever you want to add a new agent during the game, you must add it to + the game using this method. + + Arguments: + agent: An instance of an agent class. + """ + validate_agent(agent) + if getattr(agent, "display", True) and not self.on_board(agent.position): + raise IllegalMove(agent, agent.position) + if hasattr(agent, "name"): + if agent.name in self.agents_by_name: + raise AgentWithNameAlreadyExists(agent.name) + self.agents_by_name[agent.name] = agent + self.agents.append(agent) + + def get_agent_by_name(self, name): + """Looks up an agent by name. + This is useful when one agent needs to interact with another agent. + + Arguments: + name (str): The agent's name. If there is no agent with this name, + you will get an error. + + Returns: + An agent. + """ + validate_agent_name(name) + if name in self.agents_by_name: + return self.agents_by_name[name] + else: + raise AgentNotFoundByName(name) + + def is_empty(self, position): + """Checks whether a position is occupied by any agents. + + Arguments: + position (int, int): The position to check. + + Returns: + A bool + """ + return position not in self.get_agents_by_position() + + def get_agents_by_position(self): + """Returns a dict where each key is a position (e.g. (10, 20)) and + each value is a list containing all the agents at that position. + This is useful when an agent needs to find out which other agents are + on the same space or nearby. + """ + positions = defaultdict(list) + for agent in self.agents: + if getattr(agent, "display", True): + validate_position(agent.position) + positions[agent.position].append(agent) + return positions + + def remove_agent(self, agent): + """Removes an agent from the game. + + Arguments: + agent (Agent): the agent to remove. + """ + if agent not in self.agents: + raise AgentNotInGame(agent) + else: + self.agents.remove(agent) + if hasattr(agent, "name"): + self.agents_by_name.pop(agent.name) + + def remove_agent_by_name(self, name): + """Removes an agent from the game. + + Arguments: + name (str): the agent's name. + """ + validate_agent_name(name) + if name not in self.agents_by_name: + raise AgentNotFoundByName(name) + agent = self.agents_by_name.pop(name) + self.agents.remove(agent) + + def on_board(self, position): + """Checks whether a position is on the game board. + + Arguments: + position (int, int): The position to check + + Returns: + A bool + """ + validate_position(position) + x, y = position + bx, by = self.board_size + return x >= 0 and x < bx and y >= 0 and y < by + + + diff --git a/retro/game.py:Zone.Identifier b/retro/game.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/graph.py b/retro/graph.py new file mode 100644 index 0000000..994212e --- /dev/null +++ b/retro/graph.py @@ -0,0 +1,162 @@ +from retro.errors import GraphError + +class Graph: + def __init__(self, vertices=None, edges=None): + self.vertices = vertices or [] + self.edges = edges or [] + + def __str__(self): + return '\n'.join(str(e) for e in self.edges) + + def get_or_create_vertex(self, x, y): + for v in self.vertices: + if x == v.x and y == v.y: + return v + for e in self.edges: + if e.crosses(x, y): + return self.split_edge(e, x, y) + v = Vertex(x, y) + self.vertices.append(v) + return v + + def get_or_create_edge(self, x0, y0, x1, y1): + v0 = self.get_or_create_vertex(x0, y0) + v1 = self.get_or_create_vertex(x1, y1) + new_edge = Edge(v0, v1) + for e in self.edges: + if e == new_edge: + new_edge.remove() + return e + return new_edge + + def split_edge(self, edge, x, y): + """ + Splits an edge by inserting a new vertex along the edge. + """ + if not edge.crosses(x, y): + raise GraphError(f"Can't split edge {edge} at ({x}, {y})") + self.remove_edge(edge) + v = Vertex(x, y) + self.vertices.append(v) + self.edges.append(Edge(edge.begin, v)) + self.edges.append(Edge(v, edge.end)) + + def remove_edge(self, edge): + if edge not in self.edges: + raise GraphError(f"Edge {edge} is not in the graph") + self.edges.remove(edge) + edge.begin.edges.remove(edge) + edge.end.edges.remove(edge) + + def render(self, terminal): + for v in self.vertices: + v.render(terminal) + for e in self.edges: + e.render(terminal) + +class Vertex: + CHARACTERS = { + "0000": " ", + "0001": "═", + "0010": "║", + "0011": "╗", + "0100": "═", + "0101": "═", + "0110": "╔", + "0111": "╦", + "1000": "║", + "1001": "╝", + "1010": "║", + "1011": "╣", + "1100": "╚", + "1101": "╩", + "1110": "╠", + "1111": "╬", + } + def __init__(self, x, y): + self.x = x + self.y = y + self.edges = [] + + def __str__(self): + return f"({self.x}, {self.y})" + + def __eq__(self, other): + return self.x == other.x and self.y == other.y + + def neighbors(self): + vertices = [] + for edge in self.edges: + if self == edge.begin: + vertices.append(edge.end) + else: + vertices.append(edge.begin) + return vertices + + def render(self, terminal): + print(terminal.move_xy(self.x, self.y) + self.get_character()) + + def get_character(self): + u = self.has_up_edge() + r = self.has_right_edge() + d = self.has_down_edge() + l = self.has_left_edge() + code = ''.join([str(int(direction)) for direction in [u, r, d, l]]) + return self.CHARACTERS[code] + + def has_up_edge(self): + return any([v.x == self.x and v.y < self.y for v in self.neighbors()]) + + def has_right_edge(self): + return any([v.y == self.y and self.x < v.x for v in self.neighbors()]) + + def has_down_edge(self): + return any([v.x == self.x and self.y < v.y for v in self.neighbors()]) + + def has_left_edge(self): + return any([v.y == self.y and v.x < self.x for v in self.neighbors()]) + +class Edge: + def __init__(self, begin, end): + if not isinstance(begin, Vertex) or not isinstance(end, Vertex): + raise ValueError("Tried to initialize an Edge with a non-vertex") + if begin.x < end.x or begin.y < end.y: + self.begin = begin + self.end = end + else: + self.begin = end + self.end = begin + if not (self.is_horizontal() or self.is_vertical()): + raise ValueError("Edges must be horizontal or vertical.") + if self.is_horizontal() and self.is_vertical(): + raise ValueError("Self-edges are not allowed.") + self.begin.edges.append(self) + self.end.edges.append(self) + + def __str__(self): + return f"{self.begin} -> {self.end}" + + def render(self, terminal): + if self.is_horizontal(): + with terminal.location(self.begin.x + 1, self.begin.y): + line = "═" * (self.end.x - self.begin.x - 1) + print(line) + else: + for y in range(self.begin.y + 1, self.end.y): + print(terminal.move_xy(self.begin.x, y) + "║") + + def is_horizontal(self): + return self.begin.y == self.end.y + + def is_vertical(self): + return self.begin.x == self.end.x + + def crosses(self, x, y): + if self.is_horizontal(): + return self.begin.y == y and self.begin.x < x and x < self.end.x + else: + return self.begin.x == x and self.begin.y < y and y < self.end.y + + def remove(self): + self.begin.edges.remove(self) + self.end.edges.remove(self) diff --git a/retro/graph.py:Zone.Identifier b/retro/graph.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/grid.py b/retro/grid.py new file mode 100644 index 0000000..03742b6 --- /dev/null +++ b/retro/grid.py @@ -0,0 +1,5 @@ +from retro.graph import Vertex, Edge, Graph + +class Grid: + def __init__(self): + self.graph = Graph diff --git a/retro/grid.py:Zone.Identifier b/retro/grid.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/validation.py b/retro/validation.py new file mode 100644 index 0000000..51377e0 --- /dev/null +++ b/retro/validation.py @@ -0,0 +1,44 @@ + +def validate_agent(agent): + if hasattr(agent, "name"): + validate_agent_name(agent.name) + if getattr(agent, 'display', True): + validate_position(agent.position) + if not hasattr(agent, "character"): + raise ValueError(f"Agent {agent.name} must have a character") + return agent + +def validate_state(state): + if not isinstance(state, dict): + raise TypeError(f"State is {type(state)}, but must be a dict.") + for key, value in state.items(): + if is_mutable(value): + raise ValueError(f"State must be immutable, but state[{key}] is {value}") + return state + +def validate_agent_name(name): + if not isinstance(name, str): + raise TypeError(f"Agent names must be strings") + return name + +def validate_position(position): + if not isinstance(position, tuple): + raise TypeError(f"Position is {type(position)}, but must be a tuple.") + if not len(position) == 2: + raise ValueError(f"Position is {position}. Must be a tuple of two integers.") + if not isinstance(position[0], int) and isinstance(position[1], int): + raise TypeError(f"Position is {position}. Must be a tuple of two integers.") + return position + +def is_mutable(obj): + if isinstance(obj, (int, float, bool, str, None)): + return False + elif isinstance(obj, tuple): + return all(is_mutable(element) for element in obj) + else: + return True + + + + + diff --git a/retro/validation.py:Zone.Identifier b/retro/validation.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/retro/view.py b/retro/view.py new file mode 100644 index 0000000..5c37665 --- /dev/null +++ b/retro/view.py @@ -0,0 +1,127 @@ +from retro.graph import Vertex, Edge, Graph +from retro.errors import TerminalTooSmall + +class View: + BORDER_X = 2 + BORDER_Y = 3 + STATE_HEIGHT = 5 + DEBUG_WIDTH = 60 + + def __init__(self, terminal, color='white_on_black'): + self.terminal = terminal + self.color = color + + def render(self, game): + self.render_layout(game) + ox, oy = self.get_board_origin_coords(game) + self.render_state(game) + if game.debug: + self.render_debug_log(game) + for agent in sorted(game.agents, key=lambda a: getattr(a, 'z', 0)): + if getattr(agent, 'display', True): + ax, ay = agent.position + if hasattr(agent, 'color'): + color = self.get_color(agent.color) + print(self.terminal.move_xy(ox + ax, oy + ay) + color(agent.character)) + else: + print(self.terminal.move_xy(ox + ax, oy + ay) + agent.character) + + def render_layout(self, game): + bw, bh = game.board_size + self.check_terminal_size(game) + self.clear_screen() + layout_graph = self.get_layout_graph(game) + layout_graph.render(self.terminal) + + def clear_screen(self): + print(self.terminal.home + self.get_color(self.color) + self.terminal.clear) + + def get_color(self, color_string): + if not hasattr(self.terminal, color_string): + msg = ( + f"{color_string} is not a supported color." + "See https://blessed.readthedocs.io/en/latest/colors.html" + ) + raise ValueError(msg) + return getattr(self.terminal, color_string) + + def render_state(self, game): + bw, bh = game.board_size + ox, oy = self.get_state_origin_coords(game) + for i, key in enumerate(sorted(game.state.keys())): + msg = f"{key}: {game.state[key]}"[:bw] + print(self.terminal.move_xy(ox, oy + i) + msg) + + def render_debug_log(self, game): + bw, bh = game.board_size + debug_height = bh + self.STATE_HEIGHT + ox, oy = self.get_debug_origin_coords(game) + for i, (turn_number, message) in enumerate(game.log_messages[-debug_height:]): + msg = f"{turn_number}. {message}"[:self.DEBUG_WIDTH] + print(self.terminal.move_xy(ox, oy + i) + msg) + + def get_layout_graph(self, game): + bw, bh = game.board_size + sh = self.STATE_HEIGHT + ox, oy = self.get_board_origin_coords(game) + + vertices = [ + Vertex(ox - 1, oy - 1), + Vertex(ox + bw, oy - 1), + Vertex(ox + bw, oy + bh), + Vertex(ox + bw, oy + bh + sh), + Vertex(ox - 1, oy + bh + sh), + Vertex(ox - 1, oy + bh) + ] + edges = [ + Edge(vertices[0], vertices[1]), + Edge(vertices[1], vertices[2]), + Edge(vertices[2], vertices[3]), + Edge(vertices[3], vertices[4]), + Edge(vertices[4], vertices[5]), + Edge(vertices[5], vertices[0]), + Edge(vertices[5], vertices[2]), + ] + graph = Graph(vertices, edges) + if game.debug: + dw = self.DEBUG_WIDTH + graph.vertices.append(Vertex(ox + bw + dw, oy - 1)) + graph.vertices.append(Vertex(ox + bw + dw, oy + bh + sh)) + graph.edges.append(Edge(graph.vertices[1], graph.vertices[6])) + graph.edges.append(Edge(graph.vertices[6], graph.vertices[7])) + graph.edges.append(Edge(graph.vertices[3], graph.vertices[7])) + return graph + + def check_terminal_size(self, game): + bw, bh = game.board_size + width_needed = bw + self.BORDER_X + height_needed = bh + self.BORDER_Y + self.STATE_HEIGHT + if self.terminal.width < width_needed: + raise TerminalTooSmall(width=self.terminal.width, width_needed=width_needed) + elif self.terminal.height < height_needed: + raise TerminalTooSmall(height=self.terminal.height, height_needed=height_needed) + + def board_origin(self, game): + x, y = self.get_board_origin_coords(game) + return self.terminal.move_xy(x, y) + + def get_board_origin_coords(self, game): + bw, bh = game.board_size + margin_top = (self.terminal.height - bh - self.BORDER_Y) // 2 + if game.debug: + margin_left = (self.terminal.width - bw - self.DEBUG_WIDTH - self.BORDER_X) // 2 + else: + margin_left = (self.terminal.width - bw - self.BORDER_X) // 2 + return margin_left, margin_top + + def get_state_origin_coords(self, game): + bw, bh = game.board_size + ox, oy = self.get_board_origin_coords(game) + return ox, oy + bh + 1 + + def get_debug_origin_coords(self, game): + bw, bh = game.board_size + ox, oy = self.get_board_origin_coords(game) + return ox + bw + 1, oy + + diff --git a/retro/view.py:Zone.Identifier b/retro/view.py:Zone.Identifier new file mode 100644 index 0000000..e69de29 diff --git a/spaceship.py b/spaceship.py index 9f3552a..36538a9 100644 --- a/spaceship.py +++ b/spaceship.py @@ -2,3 +2,24 @@ # ------------ # By MWC Contributors # This module defines a spaceship agent class. + +class Spaceship: + name = "ship" + character = '^' + + def __init__(self, board_size): + board_width, board_height = board_size + self.position = (board_width // 2, board_height - 1) + + def handle_keystroke(self, keystroke, game): + x, y = self.position + if keystroke.name in ("KEY_LEFT", "KEY_RIGHT"): + if keystroke.name == "KEY_LEFT": + new_position = (x - 1, y) + else: + new_position = (x + 1, y) + if game.on_board(new_position): + if game.is_empty(new_position): + self.position = new_position + else: + game.end() \ No newline at end of file