pax_global_header00006660000000000000000000000064147657705450014536gustar00rootroot0000000000000052 comment=d6d6bc1cdfcd81af019df3d4c9502a5766bf4f55 golang-sourcehut-rockorager-vaxis-0.13.0/000077500000000000000000000000001476577054500203715ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/.builds/000077500000000000000000000000001476577054500217315ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/.builds/alpine_edge.yml000066400000000000000000000011751476577054500247140ustar00rootroot00000000000000--- image: alpine/edge packages: - git - go sources: - "https://git.sr.ht/~rockorager/vaxis" secrets: - d48e3c42-9d65-452f-9693-68653a855858 # ssh key for mirroring environment: GIT_SSH_COMMAND: ssh -o StrictHostKeyChecking=no tasks: - build: | cd vaxis go build - test: | cd vaxis go test ./... - mirror: | # Don't run on GitHub PRs if [ "$BUILD_REASON" = 'github-pr' ]; then exit fi # Don't run on patchsets if [ "$BUILD_REASON" = 'patchset' ]; then exit fi cd vaxis git push --force --mirror git@github.com:rockorager/vaxis golang-sourcehut-rockorager-vaxis-0.13.0/.builds/openbsd.yml000066400000000000000000000002771476577054500241140ustar00rootroot00000000000000--- image: openbsd/latest packages: - go sources: - "https://git.sr.ht/~rockorager/vaxis" tasks: - build: | cd vaxis go build - test: | cd vaxis go test ./... golang-sourcehut-rockorager-vaxis-0.13.0/.gitignore000066400000000000000000000000061476577054500223550ustar00rootroot00000000000000*.log golang-sourcehut-rockorager-vaxis-0.13.0/LICENSE000066400000000000000000000261361476577054500214060ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. golang-sourcehut-rockorager-vaxis-0.13.0/README.md000066400000000000000000000073541476577054500216610ustar00rootroot00000000000000# Vaxis ``` It begins with them, but ends with me. Their son, Vaxis ``` Vaxis is a Terminal User Interface (TUI) library for go. Vaxis supports modern terminal features, such as styled underlines and graphics. A widgets package is provided with some useful widgets. Vaxis is _blazingly_ fast at rendering. It might not be as fast or efficient as [notcurses](https://notcurses.com/), but significant profiling has been done to reduce all render bottlenecks while still maintaining the feature-set. All input parsing is done using a real terminal parser, based on the excellent state machine by [Paul Flo Williams](https://vt100.net/emu/dec_ansi_parser). Some modifications have been made to allow for proper SGR parsing (':' separated sub-parameters) Vaxis **does not use terminfo**. Support for features is detected through terminal queries. Vaxis assumes xterm-style escape codes everywhere else. Contributions are welcome. ## Usage ### Minimal example ```go package main import "git.sr.ht/~rockorager/vaxis" func main() { vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(err) } defer vx.Close() for ev := range vx.Events() { switch ev := ev.(type) { case vaxis.Key: switch ev.String() { case "Ctrl+c": return } } win := vx.Window() win.Clear() win.Print(vaxis.Segment{Text: "Hello, World!"}) vx.Render() } } ``` ## Support Questions are welcome in #vaxis on libera.chat, or on the [mailing list](mailto:~rockorager/vaxis@lists.sr.ht). Issues can be reported on the [tracker](https://todo.sr.ht/~rockorager/vaxis). ## TUI Library Roundup Notcurses is included because it's the most advanced, most efficient, most dank TUI library | Feature | Vaxis | tcell | bubbletea | notcurses | | ------------------------------ | :---: | :---: | :-------: | :-------: | | RGB | ✅ | ✅ | ✅ | ✅ | | Hyperlinks | ✅ | ✅ | ❌ | ❌ | | Bracketed Paste | ✅ | ✅ | ❌ | ❌ | | Kitty Keyboard | ✅ | ❌ | ❌ | ✅ | | Styled Underlines | ✅ | ❌ | ❌ | ✅ | | Application IDs (OSC 176) | ✅ | ❌ | ❌ | ❌ | | Foreground color query (OSC 10)| ✅ | ❌ | ❌ | ✅ | | Background color query (OSC 11)| ✅ | ❌ | ❌ | ✅ | | Mouse Shapes (OSC 22) | ✅ | ❌ | ❌ | ❌ | | System Clipboard (OSC 52) | ✅ | ❌ | ❌ | ❌ | | System Notifications (OSC 9) | ✅ | ❌ | ❌ | ❌ | | System Notifications (OSC 777) | ✅ | ❌ | ❌ | ❌ | | Synchronized Output (DEC 2026) | ✅ | ❌ | ❌ | ✅ | | Unicode Core (DEC 2027) | ✅ | ❌ | ❌ | ❌ | | Color Mode Updates (DEC 2031) | ✅ | ❌ | ❌ | ❌ | | Explicit Width | ✅ | ❌ | ❌ | ❌ | | Images (full/space) | ✅ | ❌ | ❌ | ✅ | | Images (half block) | ✅ | ❌ | ❌ | ✅ | | Images (quadrant) | ❌ | ❌ | ❌ | ✅ | | Images (sextant) | ❌ | ❌ | ❌ | ✅ | | Images (sixel) | ✅ | ✅ | ❌ | ✅ | | Images (kitty) | ✅ | ❌ | ❌ | ✅ | | Images (iterm2) | ❌ | ❌ | ❌ | ✅ | | Video | ❌ | ❌ | ❌ | ✅ | | Dank | 🆗 | ❌ | ❌ | ✅ | golang-sourcehut-rockorager-vaxis-0.13.0/_examples/000077500000000000000000000000001476577054500223465ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/color_theme_updates/000077500000000000000000000000001476577054500263735ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/color_theme_updates/main.go000066400000000000000000000012471476577054500276520ustar00rootroot00000000000000package main import ( "git.sr.ht/~rockorager/vaxis" ) var colorTheme = "Color mode detection not supported" func main() { vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(err) } defer vx.Close() for ev := range vx.Events() { switch ev := ev.(type) { case vaxis.Key: switch ev.String() { case "Ctrl+c": return } case vaxis.ColorThemeUpdate: switch ev.Mode { case vaxis.DarkMode: colorTheme = "Dark Mode" case vaxis.LightMode: colorTheme = "Light Mode" } } draw(vx.Window()) vx.Render() } } func draw(win vaxis.Window) { win.Clear() win.Print(vaxis.Segment{ Text: "Current Color Theme: " + colorTheme, }) } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/graphics/000077500000000000000000000000001476577054500241465ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/graphics/main.go000066400000000000000000000023321476577054500254210ustar00rootroot00000000000000package main import ( "image" "os" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/widgets/align" ) func main() { vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(err) } defer vx.Close() img, err := newImage() if err != nil { panic(err) } vImg, err := vx.NewImage(img) if err != nil { panic(err) } defer vImg.Destroy() for ev := range vx.Events() { switch ev := ev.(type) { case vaxis.Resize: w, h := vx.Window().Size() vImg.Resize(w/2, h/2) case vaxis.Key: switch ev.String() { case "space": // Makes the image disappear vx.Window().Clear() vx.Render() continue case "Ctrl+c": return case "Ctrl+l": // Refreshes the entire screen win := vx.Window() win.Clear() w, h := vImg.CellSize() win = align.Center(win, w, h) vImg.Draw(win) vx.Refresh() continue } } win := vx.Window() win.Clear() w, h := vImg.CellSize() win = align.Center(win, w, h) vImg.Draw(win) vx.Render() } } func newImage() (image.Image, error) { f, err := os.Open("./_examples/graphics/vaxis.png") if err != nil { return nil, err } graphic, _, err := image.Decode(f) if err != nil { return nil, err } return graphic, nil } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/graphics/vaxis.png000066400000000000000000000371631476577054500260200ustar00rootroot00000000000000PNG  IHDRƽ@iCCPICC profile(}=H@_[RvP좢 EjVL.4$).kŪ "%/)=zfGP5H'B6"_хa"i(9]gys(y>8t"^'ڴtVs1.Hu7E<3bdsbrOGU|eg\e{i!,BUl 1Z5RLi?t"drmc?,LIb#@phlض'@ZJ$ҢG@6pq=rx%CrM7[ R7!0Z5wwf?{r7bKGD pHYs   IDATxy\Uǿ3$d@  #}@Y<! ([Ep'[@ kVQul3'Ke* $p_V#jUW3PMryIe0HwPJݘQR`.&3U Zx^Jt6H- y2mA}PFPA`pÃktÿkj,. YݦP3VB~Q4mǁ ֱOgqj}:GThj07!P)C0_W)2˧ot+e2^ 6D WsޅRr݅^~˫:gC+6HϥaW6U= Sy}} )8ۡYxdKL&s9[Tڞq|^Fs" ݠk&~90]7jm|9J' `ef'\6C>\Z8z.ρt;:_C{{t#eSVkHr]@-:AT/;=꽞@@Uz|8X%*y~CI1~ƥ۴:u_E QF|3wnIZ9U]X4u+ ۦsuT |CDs=Qzm`Msn2Ł-M2ʌ NĺmJ/ Ŏ"UZI`v,j k U;k 7UUSժH0U- g<\WvD߳&+۳1⸅Y`x~gC3"b6Gs|!)g.M}q*[+5Yم\߫dH?DTB3pt!D%斺Y L3um?-:1ji5/Up-7)oMRuΪ]gyPhsJvret ɶ Gq<إJU[(!R8PҵP7Q+.5<%D">m[G4V·Q5s_kZ9HwSuy0վ-$Ԥoej;Lcwo$)~HCs 壊M lwB"!5+<ݝ2w\ ~[[].L[i&6-ij\MQSp5P״ؓRIK `&n9pRw`BUഭ@CU\ƶiյLi4 C"D@"t3\GHxcp-W8 J<iӹLr\;P)7ӐTG^H uzP[LJե%`<6qtH:vrVHUr1*Raѥt>~ht1p>#eUrժx9kYVv`ϵzYCrb& +{.ޫd:/b׫:,";HHUj=D>[F*@CxD;"GW+SБ=\HU9@)&WٝL>)1̔5HSx,S,"wEk$8Dj< f2>I2`[dMUڔCEcCceIqubk`Nϣh2㈖g簗 @|#uQpR(J$W|wUrN.# jt9^ jT, aV&MHxQʶ_*Ewl\ve0U/͸"Sl ,+ʄ9j\cTKcP(ۈHߔ^B RG}>HeŔGFJTue1/Y]&jU`Qދ9G"qH!;OUӑ !~{^F.LzcΔBjڐqyοV(nnb6*ʶp_2 E,kvJfV=* ]CyzA,Hk.d}Bk6\z0*˓hUli*)7WUrpF]I8RVĉ7d9ˍORRŚE*qQ}%}x*\3vC H80ыGuxXv9ǎ7ny 85mk0Bo={DGdΥ|GX8g}"{#NWzWF$<*YHA*)04LBn,8լŽYC,u-1C;õ\R(w6; -}pYHo{v6ܽUdzI8i~eej(K6ǽLyK),bÊ5AHn= Lz9PA"E 0j*$`W*#4E*9x]@t#F xEn@Rj쪿FĦC_@5B9eA:zP} ol:s>N 6Cri?Atf1ܷ {}4kO c5>1r9̼0:UtYf@qN1-FMafgFH,wYB4HZɟE:UEM*]Ѥat. ntJ 5!F^<#>D3擲n.7q_<9'r|`P@ wqfKu̶Y߂̢nR.JWD1ݣiFR>D6$[!!qCXûi,I2xٍ94l5癔v!>=[䔮ް!dnxZ OMlʷaf)o@lgۑC%͉|)Ja5RW̕eBuFcy54˧%8YHO#_d>?ʼ?0n[q%R( cYpH 2aFBĕ9r&KqQ̩k?-A oDglCc3{A72{wԐt ڟã;W:^yc">O36;;ܠNϮ;ٻ0=/c@OYq}x$_K /q^[Q|KhR&c4܄6wQ)7yH K1*ǑFU%@"3?9N%-$:ڡ/>~|UԗRzZtJ45beV$b?7C/#qm`y~|q*knPZ v3`Vp_Qp|^vb6ڗj' Ƙ/9*s >AFF ֫de/F.sTg P~^vi>l-JnjSYUD\tzzA}9l6qYTܹ9z6P[76,J'G晣„jUincu$5f3:?KDƨ^'^d׻¨.i2Q{$KuhFEE~>d#|ϓ_{ܼJ yCb+ʈAoD3Pg/WǍxrcsPSg^n>/fmqc(u\):XʨkiL$lwD$v;:g!\- }MhsEUlOI;x!ߋ6v/DwNqF{% (2ңcn/[q1sqZ=p)Jn"C>b»Sn٩泮!/Tr 7W]+ K.*v2t=JbsB0+t[/0->4uDwoxa5H身Z?;h2xRc|UuF-ZN' h2 ✜;"NǴw2Hg|vlr7ݯ7'iԄp @ڮiۊtٸ+[ǩ`A7Rԡo32/2z}T⋋YDG/[.ƈ})Z[դH 2ߠ )B vWwy bdjabpuE K"=D衣!h!wTcL'--7*!Q.YN{zwnyqPhd@1׸ﻢcUMWHdb㝛Q~/|K@o6?/ n B WZ߼TOGz#"$ݻ~dCb8Ñd6aX l}xtṴX%?B͘lbrlN /G|HgoVw P!ϋ oFb&EltAҺf'L"BWlO{Z7'8E_+[#ʹ"\|*GL}Ƴ]{^)2 C L4FsG$s<gAB_7&@4U" 71f_}|w>'lbNШg}4=]nN[)vKT.Y0G -zݚ92d{}1ۚ1#?ʦ }?ɡ4.,Jp(Üe~Ջ& ,[l*QIf끮4Cj4óƘ9aTjH:}- ]c]tѤR ;EjG@و]D/MjB|6_ $4w: N->-Ψ/5` 8/t5{ӽ1) ]Ot8܌w(nVZAuQ NDҝ; !=1*֓EU±j3O4˗S[9h^2ῇ#.hNo' _0ƒЈK( D6 D%VRo")]*.1jtR,F艫<uFTC\iNd ngJRHGq]ag JdpJv17w`=B WMQnp>l ? 1uF7(tL\ԁ xܜ<=PTo^[#t#YF%<;OEWwy8g,M3Fm:uv*FwhN#^~潕H"4}tiՁdr ^@%8"7}xB$5ŤGQJ>@̃8<q1@ȿoN[qӒdHZskf (VץH5Sua_6e$kNKAkvAmY>/5{YFLJo7qjptSL6ꅣEJAHIScpc?׬kA!)m#ۨ>_Vkߡ1I1H60]`Vt88B TO :٠9AZtqއv60|`Ng,+<0dY^X6E1*#A%4 37x,gtZpHG5s{ï;WwIDATŷA|au:;o*Qk6NO20h6Y6hPTR ce1eQ-gUꯇWi_FέC#/|{nZ|;"c 0*Ns0F.܍oI\ԐAϞKh7A $ҵ9R~ "Q̝?IJvxg7z#YMxH60EC"&uy{q40ΚY_@F)l Ek1\ͷi_Ueo_Y)њ(RR)<am&r1lHPBkc$ pCj 7n5+ لqxiJ?q΢@U+4RlQPkxWV:f~mσ lɉEX/B\w `6m׋Y~+Ņ]V|!xԄ*⬎UVUwo1q7':9)A]fDH[+i *$Z.Z{#|A96?hWWwcԞ'uf. tGALxq1LEN1'$7 !avquĎ+ ́BxUwA\XDt_HrunNhWv>SUIC%ՑY҈qS$Dmާ rbu&l :oEQd\\'4Xr>Hh,%گpa'|0G'*38>a$Gf *ce;L}?J?l&&IGE*k[6X+NLaE*J8\5$Q/a;Նaȱ?4ڂN6?D-\W3iEWoՈ lAD&Q|ڜ+hߒ.oS}|@Z$zʌ# r5#l4Pۘކim}Á/ĈH9;%8٣w52Pcȵ>酤FˑBlBBbPMqfI0 V4W3NC!V>PxSA]n Tplt$?cR[]>RHλSa;,ݡ&G{'`틇|Kd=s*9;s BhA35}/EDJ|^6д7lM}n\J\C 9Uk^(tN3bu.&cm fD}żwQNPi!#۸t0T'-.8[7)lw55&nG¸ىߟzp ̏0VJiWsWtޗ̲7ѓ\U~8R|fS @|Mo'X8'[Dt~;n7 { }:o^BX9P267t;h$ܘ3 437f$blpc_`أw蘣kF,#Y!3N0}0ډ9oU~!cci_Qx@ǰ+vgƨyz+*WU&hD|u47>^4ZA:]z+훭WY=i~\(p3#VHnr dΡ fZ lŤ^Q>R֮Lwj ]HX"3֛B IUBn/ē?[QRoGYF:{]_"3F30X5,R) w'C@Ս_xWiPdbGxeUˡ_w|d Ha;[`+fV) Շ{f=(DRAAzVER7r`1ZtiX l *0Rv<)|ڈ. AdW]B6. )Qu u=F:[&#z I_4tT}z:\6a%S#. dmĂ 1Um!b4\GiWm#6 fcXήu/ FjȀ1*7?M=U/,Ni!WvJu@4}XoUUm$s(iw2{{IÇ.7{flgot/ATѸwj ]cjrϨ?i_XG%3UE|ƩZTՕ~x{$S|i ʏ 8il6ڔ2йὢņY1eif+O"P[D%8[#TuIfW\3ŌirWnVctct3mop9368Hx;W)~RQ:ÿC*+`Qf74ᅭf1{I7:lȨD?L@ KOpOpMCt Ar9l?lfw$0qAm5?;n`Q!г MN`"Lj˃=ɛGvbD}a;"Р/mJ?6q@56Ц1wۚc1L!>G165zX\bv3F| e^iS$G>cT~XE$:c#⢄'[]cNH6q<> [3lω+Wxꇯg&}S)$ \\E9P)Ej1S$ Kfq4"'*qh"EJDÌ}IDžDhGyw 5ʹo`tM/ywg/G!z=ÞJ>1e6ܑ1U_1d7zN.Ɩ[A/1]SefOiGk bkxhX|נgI>ԈWnsu2t1rg86A4}y3)\ǯ3Sc -=0VHԱENxl2ar}ieYgķsjE"R h҂IWc mNb'IN[yQJ1·$ Νsh$mͤ#ڶGd`l&iVS]ၬ *)}*3k8!vvo6ZÎI*~ 5]ţZ?Hhl#d YnKH%(7׹q7R#B>bR!6j]iu\_BcgvLb%ɦps!c*<_e'ZP'nb#ݫEÌ%K7{?IPGg&ɩ[V:% l3/\9:3H]s5/D;!m]3E?sᆑAcmž)@z#6Z~% @%H}̡ٛrRj$8m c~sݡf N?aJj\`lSІL\c">L~լQlt/%8!&+ WT:ȠZ CU\ o>M'{Fz*d9zlTqRv Edv:=j|F|c讶HY6k~dح qYeZ-32IIjW_}9*$63 _ OK;\R%~%L:;˽mXP}ʘ13}Vkb+ /VY~S°8|{$-= I~nrQN[8;dw 1<˛*ۘkPIx_3YTݑf- p2qF}3 36Db,u+i"Y1e[RaU:kUU2z!-,Xnt>=z6u|{xX66]h6݋$_a$``Ez#`$lD1uߔlSKo3 >aeruD}7c6N\|M<_Btijj7*K+y(Il^~M:C"ʕݯH|,h<2]?8Uv}JҪlgES+8wE7ؙ:>[o7F0Ei1Tl4Y(V@Ɇ9=s8LͥN &%& UÛlW1L)5>nPYvsH#Ϯׯ;QfNu FEժJ0J"#fAPl{/GK1 x 9/0pېNW CU1A(RkvZz(83tRplڀn/CFdOʎ0BcCjZ">ӻ)7tnRi8Kq,}=ScM9z_Ys,jZJcF@ZnZyQlَxJԘ]kN,F X$3ZzC9,IA)FԛHxx)ԩ jTm\Aq5/ 4 B*6Cr_ZHK̾ nBa eWP3G(||i+16g$r*FhCr1Q4r4E0o{5q5jV287֊#O t#㮟߂9ufT9 /u/U㬿"0*6v]:LV`n?'{GHv=2j'k)CSΡnQj+hplVi!w b&+!)#6|t7bIhBi \$3nJ()T*T$bn*"Քg*dftEf֐Һ:N֢Bjۑa;ILSRz$}צF浈B+S)96߈InggTj$Ef+әI).z1I!{bpŲ5~mVErlTFPb HlP],8NU{Iߝc[UD|zs}wbT|Z+R(WC}JB̶R3AJ׼R+s z_C*"Ѭ Ʉ/8!=1vFX"H屮tEw#3i oK @b}lb"]ΔFB!o3 U Nn6wQEUSЖym,w0OI=XO1@"ynbtM+-[|9.B I7݉ EHxs* o\j6]q- {ӝwvQLfz+R4Ŀ.is|&{}5kVp Eruyi2='f-6HT:2)$\[HoX uaʷ6l:]gOctBu4 R<#e 3֍1 g$29_;aνU>}y %Lk"&C:$8!꭪w߉xǗqYLEBJ=Us@RB fHϦ@o>hѓd6u>~ |]R+CR)'RDn2yXI\#`:ssEJT | M߫G _"tk#YO`'"pB53Hj4SOAKI:[ s0Gt{:+ys2PgH>yK7(,Z_'H0lbݬf)W/ĹVkU_~;\*{xxRRl;H=g5#}W.TkwLHz' Kjln5LfewU=6Oѕ~:GaF]o}\ @p*=7B$GG7@T0qfZ/,$}}R twG)L"rnBC׉v]Haj==5HOw~ _U:|Zm/<ڢa2R:C)&!=HDm*%+P< y~~2.]vQh/йًJ(:OJ޻ު'+ g2`+:)۬bh@W#tdEnE| u9k+$bW77bs܉K UR)"~vy"]@fA_=P+?Z~R\os$i <$=@ U[ HH~dw(,Ӎ@%2ݬy\g4 U2 Vx𳅽4=^@QIjםUlB{[ s #WPAJj@F A}C@}#_uFu¨b]Rg1׿@M^}{XIENDB`golang-sourcehut-rockorager-vaxis-0.13.0/_examples/hello_world/000077500000000000000000000000001476577054500246605ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/hello_world/main.go000066400000000000000000000015511476577054500261350ustar00rootroot00000000000000package main import ( "os" "os/exec" "time" "git.sr.ht/~rockorager/vaxis" ) func main() { vx, err := vaxis.New(vaxis.Options{ DisableMouse: true, }) if err != nil { panic(err) } defer vx.Close() for ev := range vx.Events() { switch ev := ev.(type) { case vaxis.Resize: win := vx.Window() win.Clear() win.Print(vaxis.Segment{ Text: "Hello, World!", }, ) truncWin := win.New(0, 1, 10, -1) truncWin.PrintTruncate(0, vaxis.Segment{ Text: "This line should be truncated at 6 characters", }, ) vx.Refresh() case vaxis.Key: switch ev.String() { case "Ctrl+c": return case "space": vx.Suspend() cmd := exec.Command("ls", "-al") cmd.Stdout = os.Stdout cmd.Stdin = os.Stdin cmd.Stderr = os.Stderr cmd.Run() time.Sleep(2 * time.Second) vx.Resume() vx.Render() } } } } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/list/000077500000000000000000000000001476577054500233215ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/list/main.go000066400000000000000000000026211476577054500245750ustar00rootroot00000000000000package main import ( "fmt" "os" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/widgets/list" ) func ProduceLines(path string) ([]string, error) { dir, err := os.ReadDir(path) if err != nil { return nil, fmt.Errorf("cannot list directory: %v", err) } messages := make([]string, len(dir)) for i, entry := range dir { messages[i] = entry.Name() if err != nil { return nil, err } } return messages, nil } func main() { vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(fmt.Errorf("failed to initialise: %v", err)) } defer vx.Close() var path string if len(os.Args) < 2 { path, err = os.Getwd() if err != nil { panic(fmt.Errorf("failed to determine current working directory")) } } else { path = os.Args[1] } lines, err := ProduceLines(path) if err != nil { panic(fmt.Errorf("could not read messages: %v", err)) } list := list.New(lines) for ev := range vx.Events() { win := vx.Window() win.Clear() width, height := win.Size() listWin := win.New(0, 1, width, height-2) switch ev := ev.(type) { case vaxis.Key: switch ev.String() { case "Ctrl+c", "q": return case "Down", "j": list.Down() case "Up", "k": list.Up() case "End": list.End() case "Home": list.Home() case "Page_Down": list.PageDown(win) case "Page_Up": list.PageUp(win) } } list.Draw(listWin) vx.Render() } } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/mouse_shapes/000077500000000000000000000000001476577054500250415ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/mouse_shapes/main.go000066400000000000000000000037601476577054500263220ustar00rootroot00000000000000package main import ( "git.sr.ht/~rockorager/vaxis" ) type model struct { vertical int horizontal int resizeVertical bool resizeHorizontal bool vx *vaxis.Vaxis } func (m *model) Update(msg vaxis.Event) { switch msg := msg.(type) { case vaxis.Key: switch msg.String() { case "Ctrl+c", "q": m.vx.Close() } case vaxis.Mouse: m.vx.SetMouseShape(vaxis.MouseShapeDefault) if msg.EventType == vaxis.EventRelease { m.resizeHorizontal = false m.resizeVertical = false } if m.resizeVertical { m.horizontal = msg.Row } if m.resizeHorizontal { m.vertical = msg.Col } if msg.Row == m.horizontal { m.vx.SetMouseShape(vaxis.MouseShapeResizeVertical) if msg.EventType == vaxis.EventPress { m.resizeVertical = true } } if msg.Col == m.vertical { m.vx.SetMouseShape(vaxis.MouseShapeResizeHorizontal) if msg.EventType == vaxis.EventPress { m.resizeHorizontal = true } } default: } } func (m *model) Draw(win vaxis.Window) { w, h := win.Size() if m.vertical == 0 { m.vertical = w / 2 } if m.horizontal == 0 { m.horizontal = h / 2 } nw := win.New(0, 0, m.vertical, m.horizontal) ne := win.New(m.vertical, 0, -1, m.horizontal) se := win.New(m.vertical, m.horizontal, -1, -1) sw := win.New(0, m.horizontal, m.vertical, -1) char := vaxis.Character{Grapheme: " ", Width: 1} nw.Fill(vaxis.Cell{ Character: char, Style: vaxis.Style{ Background: vaxis.IndexColor(1), }, }) ne.Fill(vaxis.Cell{ Character: char, Style: vaxis.Style{ Background: vaxis.IndexColor(2), }, }) se.Fill(vaxis.Cell{ Character: char, Style: vaxis.Style{ Background: vaxis.IndexColor(3), }, }) sw.Fill(vaxis.Cell{ Character: char, Style: vaxis.Style{ Background: vaxis.IndexColor(4), }, }) } func main() { vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(err) } defer vx.Close() model := &model{vx: vx} for ev := range vx.Events() { model.Update(ev) model.Draw(vx.Window()) vx.Render() } } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/notification/000077500000000000000000000000001476577054500250345ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/notification/main.go000066400000000000000000000005011476577054500263030ustar00rootroot00000000000000package main import ( "git.sr.ht/~rockorager/vaxis" ) func main() { vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(err) } defer vx.Close() vx.SetTitle("VAXIS") for ev := range vx.Events() { vx.Notify("Vaxis", "Can you hear us with your ears?") switch ev.(type) { case vaxis.Resize: } } } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/readme/000077500000000000000000000000001476577054500236035ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/readme/main.go000066400000000000000000000006161476577054500250610ustar00rootroot00000000000000package main import "git.sr.ht/~rockorager/vaxis" func main() { vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(err) } defer vx.Close() for ev := range vx.Events() { switch ev := ev.(type) { case vaxis.Key: switch ev.String() { case "Ctrl+c": return } } win := vx.Window() win.Clear() win.Print(vaxis.Segment{Text: "Hello, World!"}) vx.Render() } } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/spinner/000077500000000000000000000000001476577054500240245ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/spinner/main.go000066400000000000000000000010651476577054500253010ustar00rootroot00000000000000package main import ( "time" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/widgets/spinner" ) func main() { vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(err) } defer vx.Close() spinner := spinner.New(vx, 100*time.Millisecond) spinner.Start() for ev := range vx.Events() { switch ev := ev.(type) { case vaxis.Key: switch ev.String() { case "Ctrl+c": return case "space": spinner.Toggle() } case vaxis.SyncFunc: ev() } win := vx.Window() win.Clear() spinner.Draw(win) vx.Render() } } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/term/000077500000000000000000000000001476577054500233155ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/term/main.go000066400000000000000000000013221476577054500245660ustar00rootroot00000000000000package main import ( "os" "os/exec" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/widgets/term" ) func main() { vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(err) } defer vx.Close() vt := term.New() vt.Attach(vx.PostEvent) vt.Focus() err = vt.Start(exec.Command(os.Getenv("SHELL"))) if err != nil { panic(err) } defer vt.Close() for ev := range vx.Events() { switch ev := ev.(type) { case vaxis.Key: switch ev.String() { case "Ctrl+c": return } case term.EventClosed: return case vaxis.Redraw: vx.HideCursor() vt.Draw(vx.Window()) vx.Render() continue case term.EventNotify: vx.Notify(ev.Title, ev.Body) } vt.Update(ev) } } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/textinput/000077500000000000000000000000001476577054500244125ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/textinput/main.go000066400000000000000000000165601476577054500256750ustar00rootroot00000000000000package main import ( "strings" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/widgets/textinput" ) // 1,000 most common English words. const wordList = "ability,able,about,above,accept,according,account,across,act,action,activity,actually,add,address,administration,admit,adult,affect,after,again,against,age,agency,agent,ago,agree,agreement,ahead,air,all,allow,almost,alone,along,already,also,although,always,American,among,amount,analysis,and,animal,another,answer,any,anyone,anything,appear,apply,approach,area,argue,arm,around,arrive,art,article,artist,as,ask,assume,at,attack,attention,attorney,audience,author,authority,available,avoid,away,baby,back,bad,bag,ball,bank,bar,base,be,beat,beautiful,because,become,bed,before,begin,behavior,behind,believe,benefit,best,better,between,beyond,big,bill,billion,bit,black,blood,blue,board,body,book,born,both,box,boy,break,bring,brother,budget,build,building,business,but,buy,by,call,camera,campaign,can,cancer,candidate,capital,car,card,care,career,carry,case,catch,cause,cell,center,central,century,certain,certainly,chair,challenge,chance,change,character,charge,check,child,choice,choose,church,citizen,city,civil,claim,class,clear,clearly,close,coach,cold,collection,college,color,come,commercial,common,community,company,compare,computer,concern,condition,conference,Congress,consider,consumer,contain,continue,control,cost,could,country,couple,course,court,cover,create,crime,cultural,culture,cup,current,customer,cut,dark,data,daughter,day,dead,deal,death,debate,decade,decide,decision,deep,defense,degree,Democrat,democratic,describe,design,despite,detail,determine,develop,development,die,difference,different,difficult,dinner,direction,director,discover,discuss,discussion,disease,do,doctor,dog,door,down,draw,dream,drive,drop,drug,during,each,early,east,easy,eat,economic,economy,edge,education,effect,effort,eight,either,election,else,employee,end,energy,enjoy,enough,enter,entire,environment,environmental,especially,establish,even,evening,event,ever,every,everybody,everyone,everything,evidence,exactly,example,executive,exist,expect,experience,expert,explain,eye,face,fact,factor,fail,fall,family,far,fast,father,fear,federal,feel,feeling,few,field,fight,figure,fill,film,final,finally,financial,find,fine,finger,finish,fire,firm,first,fish,five,floor,fly,focus,follow,food,foot,for,force,foreign,forget,form,former,forward,four,free,friend,from,front,full,fund,future,game,garden,gas,general,generation,get,girl,give,glass,go,goal,good,government,great,green,ground,group,grow,growth,guess,gun,guy,hair,half,hand,hang,happen,happy,hard,have,he,head,health,hear,heart,heat,heavy,help,her,here,herself,high,him,himself,his,history,hit,hold,home,hope,hospital,hot,hotel,hour,house,how,however,huge,human,hundred,husband,idea,identify,if,image,imagine,impact,important,improve,in,include,including,increase,indeed,indicate,individual,industry,information,inside,instead,institution,interest,interesting,international,interview,into,investment,involve,issue,it,item,its,itself,job,join,just,keep,key,kid,kill,kind,kitchen,know,knowledge,land,language,large,last,late,later,laugh,law,lawyer,lay,lead,leader,learn,least,leave,left,leg,legal,less,let,letter,level,lie,life,light,like,likely,line,list,listen,little,live,local,long,look,lose,loss,lot,love,low,machine,magazine,main,maintain,major,majority,make,man,manage,management,manager,many,market,marriage,material,matter,may,maybe,me,mean,measure,media,medical,meet,meeting,member,memory,mention,message,method,middle,might,military,million,mind,minute,miss,mission,model,modern,moment,money,month,more,morning,most,mother,mouth,move,movement,movie,Mr,Mrs,much,music,must,my,myself,n't,name,nation,national,natural,nature,near,nearly,necessary,need,network,never,new,news,newspaper,next,nice,night,no,none,nor,north,not,note,nothing,notice,now,number,occur,of,off,offer,office,officer,official,often,oh,oil,ok,old,on,once,one,only,onto,open,operation,opportunity,option,or,order,organization,other,others,our,out,outside,over,own,owner,page,pain,painting,paper,parent,part,participant,particular,particularly,partner,party,pass,past,patient,pattern,pay,peace,people,per,perform,performance,perhaps,period,person,personal,phone,physical,pick,picture,piece,place,plan,plant,play,player,PM,point,police,policy,political,politics,poor,popular,population,position,positive,possible,power,practice,prepare,present,president,pressure,pretty,prevent,price,private,probably,problem,process,produce,product,production,professional,professor,program,project,property,protect,prove,provide,public,pull,purpose,push,put,quality,question,quickly,quite,race,radio,raise,range,rate,rather,reach,read,ready,real,reality,realize,really,reason,receive,recent,recently,recognize,record,red,reduce,reflect,region,relate,relationship,religious,remain,remember,remove,report,represent,Republican,require,research,resource,respond,response,responsibility,rest,result,return,reveal,rich,right,rise,risk,road,rock,role,room,rule,run,safe,same,save,say,scene,school,science,scientist,score,sea,season,seat,second,section,security,see,seek,seem,sell,send,senior,sense,series,serious,serve,service,set,seven,several,sex,sexual,shake,share,she,shoot,short,shot,should,shoulder,show,side,sign,significant,similar,simple,simply,since,sing,single,sister,sit,site,situation,six,size,skill,skin,small,smile,so,social,society,soldier,some,somebody,someone,something,sometimes,son,song,soon,sort,sound,source,south,southern,space,speak,special,specific,speech,spend,sport,spring,staff,stage,stand,standard,star,start,state,statement,station,stay,step,still,stock,stop,store,story,strategy,street,strong,structure,student,study,stuff,style,subject,success,successful,such,suddenly,suffer,suggest,summer,support,sure,surface,system,table,take,talk,task,tax,teach,teacher,team,technology,television,tell,ten,tend,term,test,than,thank,that,the,their,them,themselves,then,theory,there,these,they,thing,think,third,this,those,though,thought,thousand,threat,three,through,throughout,throw,thus,time,to,today,together,tonight,too,top,total,tough,toward,town,trade,traditional,training,travel,treat,treatment,tree,trial,trip,trouble,true,truth,try,turn,TV,two,type,under,understand,unit,until,up,upon,us,use,usually,value,various,very,victim,view,violence,visit,voice,vote,wait,walk,wall,want,war,watch,water,way,we,weapon,wear,week,weight,well,west,western,what,whatever,when,where,whether,which,while,white,who,whole,whom,whose,why,wide,wife,will,win,wind,window,wish,with,within,without,woman,wonder,word,work,worker,world,worry,would,write,writer,wrong,yard,yeah,year,yes,yet,you,young,your,yourself" func main() { vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(err) } defer vx.Close() words := strings.Split(wordList, ",") complete := func(input string) []string { i := strings.LastIndex(input, " ") lastWord := input if i > 0 { lastWord = input[i+1:] } lower := strings.ToLower(lastWord) res := make([]string, 0, len(wordList)) trimmed := strings.TrimSuffix(input, lastWord) for _, word := range words { if strings.HasPrefix(word, lower) { res = append(res, trimmed+word) } } return res } ti := textinput.NewMenuComplete(complete) for ev := range vx.Events() { switch ev := ev.(type) { case vaxis.Key: switch ev.String() { case "Ctrl+c": return case "Ctrl+z": vx.Suspend() } } ti.Update(ev) vx.Window().Clear() ti.Draw(vx.Window()) vx.Render() } } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/vxfw/000077500000000000000000000000001476577054500233405ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/vxfw/button/000077500000000000000000000000001476577054500246535ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/vxfw/button/button.go000066400000000000000000000025221476577054500265160ustar00rootroot00000000000000package main import ( "log" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/vxfw" "git.sr.ht/~rockorager/vaxis/vxfw/button" ) type App struct { b *button.Button } func (a *App) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) { switch ev := ev.(type) { case vaxis.Key: if ev.Matches('c', vaxis.ModCtrl) { return vxfw.QuitCmd{}, nil } if ev.Matches('l', vaxis.ModCtrl) { return []vxfw.Command{vxfw.DebugCmd{}, vxfw.RedrawCmd{}}, nil } } return nil, nil } func (a *App) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) { switch ev.(type) { case vxfw.Init: return vxfw.FocusWidgetCmd(a.b), nil } return nil, nil } func (a *App) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { chCtx := vxfw.DrawContext{ Max: vxfw.Size{ Width: ctx.Max.Width / 2, Height: ctx.Max.Height / 2, }, Characters: ctx.Characters, } s, err := a.b.Draw(chCtx) if err != nil { return vxfw.Surface{}, err } root := vxfw.NewSurface(s.Size.Width, s.Size.Height, a) root.AddChild(0, 0, s) return root, nil } func (a *App) onClick() (vxfw.Command, error) { return vxfw.QuitCmd{}, nil } func main() { app, err := vxfw.NewApp(vaxis.Options{}) if err != nil { log.Fatalf("Couldn't create a new app: %v", err) } root := &App{} root.b = button.New("Click me!", root.onClick) app.Run(root) } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/vxfw/input/000077500000000000000000000000001476577054500244775ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/vxfw/input/main.go000066400000000000000000000022201476577054500257460ustar00rootroot00000000000000package main import ( "log" "math" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/vxfw" "git.sr.ht/~rockorager/vaxis/vxfw/textfield" ) type App struct { input *textfield.TextField } func (a *App) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) { switch ev := ev.(type) { case vaxis.Key: if ev.Matches('c', vaxis.ModCtrl) { return vxfw.QuitCmd{}, nil } } return nil, nil } func (a *App) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) { switch ev.(type) { case vxfw.Init: return vxfw.FocusWidgetCmd(a.input), nil } return nil, nil } func (a *App) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { chCtx := vxfw.DrawContext{ Max: vxfw.Size{Width: 24, Height: math.MaxUint16}, Characters: ctx.Characters, } s, err := a.input.Draw(chCtx) if err != nil { return vxfw.Surface{}, err } root := vxfw.NewSurface(s.Size.Width, s.Size.Height, a) root.AddChild(0, 0, s) return root, nil } func main() { app, err := vxfw.NewApp(vaxis.Options{}) if err != nil { log.Fatalf("Couldn't create a new app: %v", err) } root := &App{ input: &textfield.TextField{}, } app.Run(root) } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/vxfw/list/000077500000000000000000000000001476577054500243135ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/vxfw/list/main.go000066400000000000000000000031541476577054500255710ustar00rootroot00000000000000package main import ( "fmt" "log" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/vxfw" "git.sr.ht/~rockorager/vaxis/vxfw/list" "git.sr.ht/~rockorager/vaxis/vxfw/text" ) type App struct { list list.Dynamic } func redrawAndConsume() vxfw.BatchCmd { return []vxfw.Command{ vxfw.RedrawCmd{}, vxfw.ConsumeEventCmd{}, } } func (a *App) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) { switch ev := ev.(type) { case vaxis.Key: if ev.Matches('c', vaxis.ModCtrl) { return vxfw.QuitCmd{}, nil } if ev.Matches('l', vaxis.ModCtrl) { return []vxfw.Command{vxfw.DebugCmd{}, vxfw.RedrawCmd{}}, nil } } return nil, nil } func (a *App) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) { switch ev.(type) { case vxfw.Init: return vxfw.FocusWidgetCmd(&a.list), nil } return nil, nil } func (a *App) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { s, err := a.list.Draw(ctx) if err != nil { return vxfw.Surface{}, err } root := vxfw.NewSurface(s.Size.Width, s.Size.Height, a) root.AddChild(0, 0, s) return root, nil } func getWidget(i uint, cursor uint) vxfw.Widget { var style vaxis.Style if i == cursor { style.Attribute = vaxis.AttrReverse } content := fmt.Sprintf("Row %d", i) for n := uint(0); n < i; n += 1 { content += "\n Multiline" } return &text.Text{ Content: content, Style: style, } } func main() { app, err := vxfw.NewApp(vaxis.Options{}) if err != nil { log.Fatalf("Couldn't create a new app: %v", err) } root := &App{ list: list.Dynamic{ Builder: getWidget, DrawCursor: true, Gap: 1, }, } app.Run(root) } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/vxfw/main.go000066400000000000000000000044131476577054500246150ustar00rootroot00000000000000package main import ( "log" "math" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/vxfw" "git.sr.ht/~rockorager/vaxis/vxfw/richtext" ) const lorem = `Lorem ipsum odor amet, consectetuer adipiscing elit. Nulla viverra ipsum id curae dui etiam massa? Sagittis non morbi ornare penatibus pharetra inceptos dolor posuere. Placerat netus nascetur tellus nec magnis magna. Convallis accumsan sollicitudin dui sem natoque; tristique nam! Condimentum tristique risus diam nisl cursus suscipit mauris. Penatibus viverra mattis nunc maximus curabitur. Aenean mi tempus vivamus amet vitae urna. Orci at senectus ullamcorper suspendisse augue proin. ` var segments = []vaxis.Segment{ {Text: lorem, Style: vaxis.Style{Foreground: vaxis.IndexColor(1)}}, {Text: lorem, Style: vaxis.Style{Foreground: vaxis.IndexColor(2)}}, {Text: lorem, Style: vaxis.Style{Foreground: vaxis.IndexColor(3)}}, {Text: lorem, Style: vaxis.Style{Foreground: vaxis.IndexColor(4)}}, {Text: lorem, Style: vaxis.Style{Foreground: vaxis.IndexColor(5)}}, {Text: lorem, Style: vaxis.Style{Foreground: vaxis.IndexColor(6)}}, } type App struct { t *richtext.RichText scroll int } func redrawAndConsume() vxfw.BatchCmd { return []vxfw.Command{ vxfw.RedrawCmd{}, vxfw.ConsumeEventCmd{}, } } func (a *App) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) { switch ev := ev.(type) { case vaxis.Key: if ev.Matches('c', vaxis.ModCtrl) { return vxfw.QuitCmd{}, nil } if ev.Matches('j') { a.scroll -= 1 return redrawAndConsume(), nil } if ev.Matches('k') { a.scroll += 1 return redrawAndConsume(), nil } } return nil, nil } func (a *App) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) { return nil, nil } func (a *App) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { chCtx := vxfw.DrawContext{ Max: vxfw.Size{Width: 24, Height: math.MaxUint16}, Characters: ctx.Characters, } s, err := a.t.Draw(chCtx) if err != nil { return vxfw.Surface{}, err } root := vxfw.NewSurface(s.Size.Width, s.Size.Height, a) root.AddChild(0, a.scroll, s) return root, nil } func main() { app, err := vxfw.NewApp(vaxis.Options{}) if err != nil { log.Fatalf("Couldn't create a new app: %v", err) } root := &App{t: richtext.New(segments)} app.Run(root) } golang-sourcehut-rockorager-vaxis-0.13.0/_examples/wrap/000077500000000000000000000000001476577054500233175ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/_examples/wrap/main.go000066400000000000000000000037041476577054500245760ustar00rootroot00000000000000package main import ( "git.sr.ht/~rockorager/vaxis" ) func main() { vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(err) } defer vx.Close() for ev := range vx.Events() { switch ev := ev.(type) { case vaxis.Key: switch { case ev.MatchString("ctrl+c"): return case ev.MatchString("ctrl+l"): vx.Refresh() } } win := vx.Window() win.Clear() win = win.New(0, 0, 80, -1) win.Wrap( vaxis.Segment{ Text: "Lorem ipsum dolor sit amet, officia excepteur ex fugiat reprehenderit enim labore culpa sint ad nisi Lorem pariatur mollit ex esse exercitation amet. Nisi anim cupidatat excepteur officia. Reprehenderit nostrud nostrud ipsum Lorem est aliquip amet voluptate voluptate dolor minim nulla est proident. Nostrud officia pariatur ut officia. Sit irure elit esse ea nulla sunt ex occaecat reprehenderit commodo officia dolor Lorem duis laboris cupidatat officia voluptate. Culpa proident adipisicing id nulla nisi laboris ex in Lorem sunt duis officia eiusmod. Aliqua reprehenderit commodo ex non excepteur duis sunt velit enim. Voluptate laboris sint cupidatat ullamco ut ea consectetur et est culpa et culpa duis.", }, vaxis.Segment{ Text: "\n", }, vaxis.Segment{ Text: "Lorem ipsum dolor sit amet, officia excepteur ex fugiat reprehenderit enim labore culpa sint ad nisi Lorem pariatur mollit ex esse exercitation amet. Nisi anim cupidatat excepteur officia. Reprehenderit nostrud nostrud ipsum Lorem est aliquip amet voluptate voluptate dolor minim nulla est proident. Nostrud officia pariatur ut officia. Sit irure elit esse ea nulla sunt ex occaecat reprehenderit commodo officia dolor Lorem duis laboris cupidatat officia voluptate. Culpa proident adipisicing id nulla nisi laboris ex in Lorem sunt duis officia eiusmod. Aliqua reprehenderit commodo ex non excepteur duis sunt velit enim. Voluptate laboris sint cupidatat ullamco ut ea consectetur et est culpa et culpa duis.", }, ) vx.Render() } } golang-sourcehut-rockorager-vaxis-0.13.0/ansi/000077500000000000000000000000001476577054500213235ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/ansi/parser.go000066400000000000000000001014411476577054500231470ustar00rootroot00000000000000package ansi import ( "bufio" "bytes" "fmt" "io" "strconv" "strings" "sync" "time" "unicode" "github.com/rivo/uniseg" ) const eof rune = -1 // https://vt100.net/emu/dec_ansi_parser // // parser is an implementation of Paul Flo Williams' VT500-series // parser, as seen [here](https://vt100.net/emu/dec_ansi_parser). The // architecture is designed after Rob Pike's text/template parser, with a // few modifications. // // Many of the comments are directly from Paul Flo Williams description of // the parser, licensed undo [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/) type Parser struct { close chan bool closed chan bool r *bufio.Reader sequences chan Sequence state stateFn exit func() intermediate []rune params []rune final rune paramListPool pool[[][]int] paramPool pool[[]int] intermediatePool pool[[]rune] // we turn ignoreST on when we enter a state that can "only" be exited // by ST. This will have the effect of ignore an ST so we don't see // ambiguous "Alt+\" when parsing input ignoreST bool // escTimeout is a timeout for interpretting an Esc keypress vs an // escape sequence escTimeout *time.Timer mu sync.Mutex oscData []rune apcData []rune dcs DCS } func NewParser(r io.Reader) *Parser { parser := &Parser{ close: make(chan bool, 1), closed: make(chan bool, 1), r: bufio.NewReader(r), sequences: make(chan Sequence, 2), state: ground, paramListPool: newPool(newCSIParamList), paramPool: newPool(newCSIParam), intermediatePool: newPool(newIntermediateSlice), } // Rob Pike didn't use concurrency since he wanted templates to be able // to happen in init() functions, but we don't care about that. go parser.run() return parser } // Next returns the next Sequence. Sequences will be of the following types: // // error Sent on any parsing error // Print Print the character to the screen // C0 Execute the C0 code // ESC Execute the ESC sequence // CSI Execute the CSI sequence // OSC Execute the OSC sequence // DCS Execute the DCS sequence // EOF Sent at end of input func (p *Parser) Next() chan Sequence { return p.sequences } func (p *Parser) Finish(seq Sequence) { switch seq := seq.(type) { case ESC: if seq.Intermediate != nil { p.intermediatePool.Put(seq.Intermediate) } case CSI: if seq.Parameters != nil { for _, param := range seq.Parameters { p.paramPool.Put(param) } p.paramListPool.Put(seq.Parameters) } if seq.Intermediate != nil { p.intermediatePool.Put(seq.Intermediate) } case DCS: if seq.Intermediate != nil { p.intermediatePool.Put(seq.Intermediate) } } } func (p *Parser) run() { outer: for { select { case <-p.close: break outer default: r := p.readRune() p.mu.Lock() p.state = anywhere(r, p) if p.state == nil { p.mu.Unlock() break outer } p.mu.Unlock() } } if p.escTimeout != nil { p.escTimeout.Stop() } p.emit(EOF{}) close(p.sequences) p.closed <- true } func (p *Parser) Close() { p.close <- true } func (p *Parser) WaitClose() { <-p.closed } func (p *Parser) readRune() rune { r, _, err := p.r.ReadRune() if p.escTimeout != nil { p.escTimeout.Stop() } if r == unicode.ReplacementChar { // If invalid UTF-8, let's read the byte and deliver // it as is err = p.r.UnreadRune() if err != nil { return eof } b, err := p.r.ReadByte() if err != nil { return eof } r = rune(b) } if err != nil { return eof } return r } func (p *Parser) emit(seq Sequence) { p.sequences <- seq } // This action only occurs in ground state. The current code should be mapped to // a glyph according to the character set mappings and shift states in effect, // and that glyph should be displayed. 20 (SP) and 7F (DEL) have special // behaviour in later VT series, as described in ground. func (p *Parser) print(r rune) { bldr := strings.Builder{} bldr.WriteRune(r) // We read until we have consumed the entire grapheme var ( rest string grapheme = bldr.String() w int ) for p.r.Buffered() > 0 { nextRune, _, _ := p.r.ReadRune() bldr.WriteRune(nextRune) grapheme, rest, w, _ = uniseg.FirstGraphemeClusterInString(bldr.String(), -1) if rest != "" { p.r.UnreadRune() break } } if w == 0 { // If we weren't buffered, we won't have a width. Measure it // here w = uniseg.StringWidth(grapheme) } p.emit(Print{Grapheme: grapheme, Width: w}) } // The C0 or C1 control function should be executed, which may have any one of a // variety of effects, including changing the cursor position, suspending or // resuming communications or changing the shift states in effect. There are no // parameters to this action. func (p *Parser) execute(r rune) { if in(r, 0x00, 0x1F) { p.emit(C0(r)) return } } // This action causes the current private flag, intermediate characters, final // character and parameters to be forgotten. This occurs on entry to the escape, // csi entry and dcs entry states, so that erroneous sequences like CSI 3 ; 1 // CSI 2 J are handled correctly. func (p *Parser) clear() { p.intermediate = p.intermediate[:0] p.final = rune(0) p.params = p.params[:0] } // The private marker or intermediate character should be stored for later // use in selecting a control function to be executed when a final // character arrives. X3.64 doesn’t place any limit on the number of // intermediate characters allowed before a final character, although it // doesn’t define any control sequences with more than one. Digital defined // escape sequences with two intermediate characters, and control sequences // and device control strings with one. If more than two intermediate // characters arrive, the parser can just flag this so that the dispatch // can be turned into a null operation. func (p *Parser) collect(r rune) { p.intermediate = append(p.intermediate, r) } // The final character of an escape sequence has arrived, so determined the // control function to be executed from the intermediate character(s) and // final character, and execute it. The intermediate characters are // available because collect stored them as they arrived. func (p *Parser) escapeDispatch(r rune) { esc := ESC{ Final: r, } if len(p.intermediate) > 0 { esc.Intermediate = p.intermediate p.intermediate = p.intermediatePool.Get() } p.emit(esc) } // This action collects the characters of a parameter string for a control // sequence or device control sequence and builds a list of parameters. The // characters processed by this action are the digits 0-9 (codes 30-39) and // the semicolon (code 3B). The semicolon separates parameters. There is no // limit to the number of characters in a parameter string, although a // maximum of 16 parameters need be stored. If more than 16 parameters // arrive, all the extra parameters are silently ignored. // // Most control functions support default values for their parameters. The // default value for a parameter is given by either leaving the parameter // blank, or specifying a value of zero. Judging by previous threads on the // newsgroup comp.terminals, this causes some confusion, with the // occasional assertion that zero is the default parameter value for // control functions. This is not the case: many control functions have a // default value of 1, one (GSM) has a default value of 100, and some have // no default. However, in all cases the default value is represented by // either zero or a blank value. // // In the standard ECMA-48, which can be considered X3.64’s successor², // there is a distinction between a parameter with an empty value // (representing the default value), and one that has the value zero. There // used to be a mode, ZDM (Zero Default Mode), in which the two cases were // treated identically, but that is now deprecated in the fifth edition // (1991). Although a VT500 parser needs to treat both empty and zero // parameters as representing the default, it is worth considering future // extensions by distinguishing them internally func (p *Parser) param(r rune) { p.params = append(p.params, r) } // A final character has arrived, so determine the control function to be // executed from private marker, intermediate character(s) and final // character, and execute it, passing in the parameter list. func (p *Parser) csiDispatch(r rune) { csi := CSI{ Final: r, } if len(p.intermediate) > 0 { csi.Intermediate = p.intermediate p.intermediate = p.intermediatePool.Get() } if len(p.params) == 0 { p.emit(csi) return } // Usually we won't have more than 2 csi.Parameters = p.paramListPool.Get()[:0] // csi.Parameters = make([][]int, 0, 4) ps := 0 // an rgb sequence will have up to 6 subparams param := p.paramPool.Get()[:0] // param := make([]int, 0, 6) for i := 0; i < len(p.params); i += 1 { b := p.params[i] switch b { case ';': param = append(param, ps) csi.Parameters = append(csi.Parameters, param) // param = make([]int, 0, 6) param = p.paramPool.Get()[:0] ps = 0 case ':': param = append(param, ps) ps = 0 default: // All of our non ';' and ':' bytes are a digit. ps *= 10 ps += int(b) - 0x30 } } param = append(param, ps) csi.Parameters = append(csi.Parameters, param) p.emit(csi) } // When the control function OSC (Operating System Command) is recognised, // this action initializes an external parser (the “OSC Handler”) to handle // the characters from the control string. OSC control strings are not // structured in the same way as device control strings, so there is no // choice of parsers. // // oscStart registers oscEnd as the exit function. This will be called on when // the state moves from oscString to any other state func (p *Parser) oscStart() { // p.emit(OSCStart{}) p.exit = p.oscEnd } // This action passes characters from the control string to the OSC Handler // as they arrive. There is therefore no need to buffer characters until // the end of the control string is recognised. func (p *Parser) oscPut(r rune) { p.oscData = append(p.oscData, r) // p.emit(OSCData(r)) } // This action is called when the OSC string is terminated by ST, CAN, SUB // or ESC, to allow the OSC handler to finish neatly. func (p *Parser) oscEnd() { p.emit(OSC{ Payload: p.oscData, }) // OSC will usually be a hyperlink or pasted text, these can be pretty // large so we'll initialize with 128 p.oscData = make([]rune, 0, 128) } // This action is invoked when a final character arrives in the first part // of a device control string. It determines the control function from the // private marker, intermediate character(s) and final character, and // executes it, passing in the parameter list. It also selects a handler // function for the rest of the characters in the control string. This // handler function will be called by the put action for every character in // the control string as it arrives. // // This way of handling device control strings has been selected because it // allows the simple plugging-in of extra parsers as functionality is // added. Support for a fairly simple control string like DECDLD (Downline // Load) could be added into the main parser if soft characters were // required, but the main parser is no place for complicated protocols like // ReGIS. // // hook registers unhook as the exit function. This will be called on when // the state moves from dcsPassthrough to any other state func (p *Parser) hook(r rune) { p.exit = p.unhook p.dcs = DCS{ Final: r, Data: make([]rune, 0, 128), } if len(p.intermediate) > 0 { p.dcs.Intermediate = p.intermediate p.intermediate = p.intermediatePool.Get() } if len(p.params) == 0 { return } paramStr := strings.Split(string(p.params), ";") params := make([]int, 0, len(paramStr)) for _, param := range paramStr { if param == "" { params = append(params, 0) continue } val, err := strconv.Atoi(param) if err != nil { p.emit(fmt.Errorf("hook: %w", err)) return } params = append(params, val) } p.dcs.Parameters = params } // This action passes characters from the data string part of a device // control string to a handler that has previously been selected by the // hook action. C0 controls are also passed to the handler. func (p *Parser) put(r rune) { p.dcs.Data = append(p.dcs.Data, r) } // When a device control string is terminated by ST, CAN, SUB or ESC, this // action calls the previously selected handler function with an “end of // data” parameter. This allows the handler to finish neatly. func (p *Parser) unhook() { p.emit(p.dcs) p.dcs = DCS{} } func (p *Parser) apcUnhook() { p.emit(APC{ Data: string(p.apcData), }) p.apcData = []rune{} } // in returns true if the rune lies within the range, inclusive of the endpoints func in(r rune, min int32, max int32) bool { if r >= min && r <= max { return true } return false } // State functions type stateFn func(rune, *Parser) stateFn // This isn’t a real state. It is used on the state diagram to show // transitions that can occur from any state to some other state. func anywhere(r rune, p *Parser) stateFn { switch { case r == eof: if p.exit != nil { p.exit() p.exit = nil } return nil case r == 0x18, r == 0x1A: if p.exit != nil { p.exit() p.exit = nil } p.execute(r) return ground case r == 0x1B: if p.exit != nil { p.exit() p.exit = nil } p.clear() p.escTimeout = time.AfterFunc(10*time.Millisecond, func() { p.emit(C0(0x1B)) p.mu.Lock() p.state = ground p.mu.Unlock() }) return escape default: return p.state(r, p) } } // This state is entered when the control function CSI is recognised, in // 7-bit or 8-bit form. This state will only deal with the first character // of a control sequence, because the characters 3C-3F can only appear as // the first character of a control sequence, if they appear at all. // Strictly speaking, X3.64 says that the entire string is “subject to // private or experimental interpretation” if the first character is one of // 3C-3F, which allows sequences like CSI ?::) and 3F (?) were used by Digital. // // C0 controls are executed immediately during the recognition of a control // sequence. C1 controls will cancel the sequence and then be executed. I // imagine this treatment of C1 controls is prompted by the consideration // that the 7-bit (ESC Fe) and 8-bit representations of C1 controls should // act in the same way. When the first character of the 7-bit // representation, ESC, is received, it will cancel the control sequence, // so the 8-bit representation should do so as well. func csiEntry(r rune, p *Parser) stateFn { switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): p.execute(r) return csiEntry case r == 0x7F: // ignore return csiEntry case in(r, 0x30, 0x39), r == 0x3B, r == 0x3A: // 0x3A is not per the PFW, but using colons is valid SGR // syntax for separating params when including colorspace. The // colorspace should be ignored p.param(r) return csiParam case in(r, 0x3C, 0x3F): p.collect(r) return csiParam // case is(r, 0x3A): // return csiIgnore case in(r, 0x20, 0x2F): p.collect(r) return csiIntermediate case in(r, 0x40, 0x7E): p.csiDispatch(r) return ground default: // Return to ground on unexpected characters p.emit(fmt.Errorf("unexpected characted: %c", r)) return ground } } // This state is entered when a parameter character is recognised in a // control sequence. It then recognises other parameter characters until an // intermediate or final character appears. Further occurrences of the // private-marker characters 3C-3F or the character 3A, which has no // standardised meaning, will cause transition to the csi ignore state. func csiParam(r rune, p *Parser) stateFn { switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): p.execute(r) return csiParam case r == 0x7F: // ignore return csiParam case in(r, 0x30, 0x39), r == 0x3B, r == 0x3A: // 0x3A is not per the PFW, but using colons is valid SGR // syntax for separating params when including colorspace. The // colorspace should be ignored p.param(r) return csiParam case in(r, 0x40, 0x7E): p.csiDispatch(r) return ground case in(r, 0x20, 0x2F): p.collect(r) return csiIntermediate case in(r, 0x3C, 0x3F): return csiIgnore default: // Return to ground on unexpected characters p.emit(fmt.Errorf("unexpected characted: %c", r)) return ground } } // This state is used to consume remaining characters of a control sequence // that is still being recognised, but has already been disregarded as // malformed. This state will only exit when a final character is // recognised, at which point it transitions to ground state without // dispatching the control function. This state may be entered because: // // 1. a private-marker character 3C-3F is recognised in any place other // than the first character of the control sequence, // 2. the character 3A appears anywhere, or // 3. a parameter character 30-3F occurs after an intermediate // character has been recognised. // // C0 controls will still be executed while a control sequence is being // ignored func csiIgnore(r rune, p *Parser) stateFn { switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): p.execute(r) return csiIgnore case r == 0x7F: // ignore return csiIgnore case in(r, 0x40, 0x7E): return ground default: return csiIgnore } } // This state is entered when an intermediate character is recognised in a // control sequence. It then recognises other intermediate characters until // a final character appears. If any more parameter characters appear, this // is an error condition which will cause a transition to the csi ignore // state. func csiIntermediate(r rune, p *Parser) stateFn { switch { case r == eof: return nil case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): p.execute(r) return csiIntermediate case r == 0x7F: // ignore return csiIntermediate case in(r, 0x20, 0x2F): p.collect(r) return csiIntermediate case in(r, 0x30, 0x3F): return csiIgnore case in(r, 0x40, 0x7E): p.csiDispatch(r) return ground default: // Return to ground on unexpected characters p.emit(fmt.Errorf("unexpected characted: %c", r)) return ground } } // This state is entered when the control function DCS is recognised, in // 7-bit or 8-bit form. X3.64 doesn’t define any structure for device // control strings, but Digital made them appear like control sequences // followed by a data string, with a form and length dependent on the // control function. This state is only used to recognise the first // character of the control string, mirroring the csi entry state. // // C0 controls other than CAN, SUB and ESC are not executed while // recognising the first part of a device control string. func dcsEntry(r rune, p *Parser) stateFn { switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): // ignore return dcsEntry case r == 0x7F: // ignore return dcsEntry case in(r, 0x20, 0x2F): p.collect(r) return dcsIntermediate case r == 0x3A: return dcsIgnore case in(r, 0x30, 0x39), r == 0x3B: p.param(r) return dcsParam case in(r, 0x3C, 0x3F): p.collect(r) return dcsParam case in(r, 0x40, 0x7E): p.hook(r) return dcsPassthrough default: p.hook(r) return dcsPassthrough } } // This state is entered when an intermediate character is recognised in a // device control string. It then recognises other intermediate characters // until a final character appears. If any more parameter characters // appear, this is an error condition which will cause a transition to the // dcs ignore state. func dcsIntermediate(r rune, p *Parser) stateFn { switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): // ignore return dcsIntermediate case in(r, 0x20, 0x2F): p.collect(r) return dcsIntermediate case r == 0x7F: // ignore return dcsIntermediate case in(r, 0x30, 0x3F): return dcsIgnore case in(r, 0x40, 0x7E): p.hook(r) return dcsPassthrough default: // Return to ground on unexpected characters p.emit(fmt.Errorf("unexpected characted: %c", r)) return ground } } // This state is entered when a parameter character is recognised in a // device control string. It then recognises other parameter characters // until an intermediate or final character appears. Occurrences of the // private-marker characters 3C-3F or the undefined character 3A will cause // a transition to the dcs ignore state. func dcsParam(r rune, p *Parser) stateFn { switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): // ignore return dcsParam case in(r, 0x30, 0x39), r == 0x3B: p.param(r) return dcsParam case r == 0x7F: // ignore return dcsParam case in(r, 0x20, 0x2F): p.collect(r) return dcsIntermediate case r == 0x3A, in(r, 0x3C, 0x3F): return dcsIgnore case in(r, 0x40, 0x7E): p.hook(r) return dcsPassthrough default: // Return to ground on unexpected characters p.emit(fmt.Errorf("unexpected characted: %c", r)) return ground } } // This state is used to consume remaining characters of a device control // string that is still being recognised, but has already been disregarded // as malformed. This state will only exit when the control function ST is // recognised, at which point it transitions to ground state. This state // may be entered because: // // 1. a private-marker character 3C-3F is recognised in any place other // than the first character of the control string, // 2. the character 3A appears anywhere, or // 3. a parameter character 30-3F occurs after an intermediate // character has been recognised. // // These conditions are only errors in the first part of the control // string, until a final character has been recognised. The data string // that follows is not checked by this parser. func dcsIgnore(r rune, p *Parser) stateFn { p.ignoreST = true switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): // ignore return dcsIgnore case in(r, 0x20, 0x7F): // ignore return dcsIgnore default: // ignore return dcsIgnore } } // This state is a shortcut for writing state machines for all possible // device control strings into the main parser. When a final character has // been recognised in a device control string, this state will establish a // channel to a handler for the appropriate control function, and then pass // all subsequent characters through to this alternate handler, until the // data string is terminated (usually by recognising the ST control // function). // // This state has an exit action so that the control function handler can // be informed when the data string has come to an end. This is so that the // last soft character in a DECDLD string can be completed when there is no // other means of knowing that its definition has ended, for example. func dcsPassthrough(r rune, p *Parser) stateFn { p.ignoreST = true p.exit = p.unhook switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): p.put(r) return dcsPassthrough case in(r, 0x20, 0x7E): p.put(r) return dcsPassthrough case r == 0x7F: // ignore return dcsPassthrough default: p.put(r) return dcsPassthrough } } // This state is entered whenever the C0 control ESC is received. This will // immediately cancel any escape sequence, control sequence or control // string in progress. If an escape sequence or control sequence was in // progress, “cancel” means that the sequence will have no effect, because // the final character that determines the control function (in conjunction // with any intermediates) will not have been received. However, the ESC // that cancels a control string may occur after the control function has // been determined and the following string has had some effect on terminal // state. For example, some soft characters may already have been defined. // Cancelling a control string does not undo these effects. // // A control string that started with DCS, OSC, PM or APC is usually // terminated by the C1 control ST (String Terminator). In a 7-bit // environment, ST will be represented by ESC \ (1B 5C). However, receiving // the ESC character will “cancel” the control string, so the ST control // function that is invoked by the arrival of the following “\” is // essentially a “no-op” function. Does this point seem like pure trivia? // Maybe, but I worried for ages about whether the control string // recogniser needed a one character lookahead in order to know whether ESC // \ was going to terminate it. The actual solution became clear when I was // using ReGIS on a VT330: sending ESC immediately caused the graphics // output cursor to disappear from the screen, so I knew that the control // string had already finished before the “\” arrived. Many of the clues // that enabled me to derive this state diagram have been as subtle as // that. func escape(r rune, p *Parser) stateFn { defer func() { p.ignoreST = false }() switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): p.execute(r) return escape case in(r, 0x20, 0x2F): p.collect(r) return escapeIntermediate case in(r, 0x30, 0x4E), in(r, 0x51, 0x57), r == 0x59, r == 0x5A, in(r, 0x60, 0x7F): // 0x7F is included here to allow for Alt+BackSpace inputs p.escapeDispatch(r) return ground case r == 0x5C: if p.ignoreST { return ground } p.escapeDispatch(r) return ground case r == 0x4F: return ss3 case r == 0x50: p.clear() return dcsEntry case r == 0x58, r == 0x5E: return sosPm case r == 0x5F: p.exit = p.apcUnhook return apc case r == 0x5B: p.clear() return csiEntry case r == 0x5D: p.oscStart() return oscString default: // Return to ground on unexpected characters return ground } } func ss3(r rune, p *Parser) stateFn { switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): p.execute(r) return ss3 case r == 0x7F: // ignore return ss3 default: p.emit(SS3(r)) return ground } } // This state is entered when an intermediate character arrives in an // escape sequence. Escape sequences have no parameters, so the control // function to be invoked is determined by the intermediate and final // characters. In this parser there is just one escape intermediate, and // the parser uses the collect action to remember intermediate characters // as they arrive, for processing by the esc_dispatch action when the final // character arrives. An alternate approach (and the one adopted by xterm) // is to have multiple copies of this state and choose the next appropriate // one as each intermediate character arrives. I think that this alternate // approach is merely an optimisation; the approach presented here doesn’t // require any more states if the repertoire of supported control functions // increases. // // This state is only split from the escape state because certain escape // sequences are the 7-bit representations of C1 controls that change the // state of the parser. Without these “compatibility sequences”, there // could just be one escape state to collect intermediates and dispatch the // sequence when a final character was received. func escapeIntermediate(r rune, p *Parser) stateFn { switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): p.execute(r) return escapeIntermediate case r == 0x7F: // ignore return escapeIntermediate case in(r, 0x20, 0x2F): p.collect(r) return escapeIntermediate case in(r, 0x30, 0x7E): p.escapeDispatch(r) return ground default: // Return to ground on unexpected characters return ground } } // The VT500 doesn’t define any function for these control strings, so this // state ignores all received characters until the control function ST is // recognised. func sosPm(r rune, p *Parser) stateFn { p.ignoreST = true switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): // ignore return sosPm default: return sosPm } } func apc(r rune, p *Parser) stateFn { p.ignoreST = true switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): // ignore return apc default: p.apcData = append(p.apcData, r) return apc } } // This is the initial state of the parser, and the state used to consume // all characters other than components of escape and control sequences. // // GL characters (20 to 7F) are printed. I have included 20 (SP) and 7F // (DEL) in this area, although both codes have special behaviour. If a // 94-character set is mapped into GL, 20 will cause a space to be // displayed, and 7F will be ignored. When a 96-character set is mapped // into GL, both 20 and 7F may cause a character to be displayed. Later // models of the VT220 included the DEC Multinational Character Set (MCS), // which has 94 characters in its supplemental set (i.e. the characters // supplied in addition to ASCII), so terminals only claiming VT220 // compatibility can always ignore 7F. The VT320 introduced ISO Latin-1, // which has 96 characters in its supplemental set, so emulators with a // VT320 compatibility mode need to treat 7F as a printable character. func ground(r rune, p *Parser) stateFn { switch { case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): p.execute(r) return ground default: p.print(r) return ground } } // This state is entered when the control function OSC (Operating System // Command) is recognised. On entry it prepares an external parser for OSC // strings and passes all printable characters to a handler function. C0 // controls other than CAN, SUB and ESC are ignored during reception of the // control string. // // The only control functions invoked by OSC strings are DECSIN (Set Icon // Name) and DECSWT (Set Window Title), present on the multisession VT520 // and VT525 terminals. Earlier terminals treat OSC in the same way as PM // and APC, ignoring the entire control string. func oscString(r rune, p *Parser) stateFn { p.ignoreST = true switch { case r == 0x07: p.exit() p.exit = nil return ground case in(r, 0x00, 0x17), r == 0x19, in(r, 0x1C, 0x1F): // ignore return oscString case in(r, 0x20, 0x7F): p.oscPut(r) return oscString default: // catch all for UTF-8 p.oscPut(r) return oscString } } // Sequence is the generic data type of items emitted from the parser. These can // be control sequences, escape sequences, or printable characters. type Sequence interface{} // A character which should be printed to the screen type Print struct { Grapheme string Width int } func (seq Print) String() string { return fmt.Sprintf("Print: %q", seq.Grapheme) } // A C0 control code type C0 rune func (seq C0) String() string { return fmt.Sprintf("C0 0x%X", rune(seq)) } // An escape sequence with intermediate characters type ESC struct { Intermediate []rune Final rune } func (seq ESC) String() string { buf := bytes.NewBuffer(nil) buf.WriteString("ESC") for _, p := range seq.Intermediate { buf.WriteRune(' ') buf.WriteRune(p) } buf.WriteRune(' ') buf.WriteRune(seq.Final) return buf.String() } type SS3 rune func (seq SS3) String() string { return fmt.Sprintf("SS3 0x%X", rune(seq)) } // A CSI Sequence type CSI struct { Intermediate []rune Parameters [][]int Final rune } func newCSIParamList() [][]int { return make([][]int, 0, 4) } func newCSIParam() []int { return make([]int, 0, 6) } func newIntermediateSlice() []rune { return make([]rune, 0, 2) } func (seq CSI) String() string { segments := make([]string, 0, 9) segments = append(segments, "CSI") if len(seq.Intermediate) > 0 { segments = append(segments, string(seq.Intermediate[0])) } for i, p := range seq.Parameters { if i > 0 { segments = append(segments, ";") } param := []string{} for _, sub := range p { param = append(param, fmt.Sprintf("%d", sub)) } segments = append(segments, strings.Join(param, ":")) } if len(seq.Intermediate) > 1 { segments = append(segments, string(seq.Intermediate[1:])) } segments = append(segments, string(seq.Final)) return strings.Join(segments, " ") } // An OSC sequence. The Payload is the raw runes received, and must be parsed // externally type OSC struct { Payload []rune } func (seq OSC) String() string { return "OSC " + string(seq.Payload) } // Sent at the beginning of a DCS passthrough sequence. type DCS struct { Final rune Intermediate []rune Parameters []int Data []rune } func (seq DCS) String() string { segments := make([]string, 0, 9) segments = append(segments, "DCS") if len(seq.Intermediate) > 0 { segments = append(segments, string(seq.Intermediate[0])) } for i, p := range seq.Parameters { if i > 0 { segments = append(segments, ";") } segments = append(segments, fmt.Sprintf("%d", p)) } if len(seq.Intermediate) > 1 { segments = append(segments, string(seq.Intermediate[1:])) } segments = append(segments, string(seq.Final)) if len(seq.Data) > 0 { segments = append(segments, string(seq.Data)) } return strings.Join(segments, " ") } type APC struct { Data string } func (seq APC) String() string { return "APC " + seq.Data } // Sent when the underlying PTY is closed type EOF struct{} func (seq EOF) String() string { return "EOF" } golang-sourcehut-rockorager-vaxis-0.13.0/ansi/parser_test.go000066400000000000000000000335311476577054500242120ustar00rootroot00000000000000package ansi import ( "bytes" "reflect" "strings" "testing" "github.com/stretchr/testify/assert" ) func TestUTF8(t *testing.T) { tests := []struct { name string input string expected []Sequence }{ { name: "UTF-8", input: "🔥", expected: []Sequence{ Print{"🔥", 2}, }, }, { name: "UTF-8", input: "👩‍🚀", expected: []Sequence{ Print{"👩‍🚀", 2}, }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { r := strings.NewReader(test.input) parse := NewParser(r) i := 0 for { seq := <-parse.Next() if _, ok := seq.(EOF); ok { assert.Equal(t, len(test.expected), i, "wrong amount of sequences") break } if i < len(test.expected) { assert.Equal(t, test.expected[i], seq) } i += 1 } }) } } func TestIn(t *testing.T) { tests := []struct { name string inRange []rune input rune expected bool }{ { name: "endpoint min", inRange: []rune{0x00, 0x20}, input: 0x00, expected: true, }, { name: "endpoint max", inRange: []rune{0x00, 0x20}, input: 0x20, expected: true, }, { name: "within", inRange: []rune{0x00, 0x20}, input: 0x19, expected: true, }, { name: "outside", inRange: []rune{0x00, 0x20}, input: 0x21, expected: false, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { actual := in(test.input, test.inRange[0], test.inRange[1]) assert.Equal(t, test.expected, actual) }) } } func TestAnywhere(t *testing.T) { tests := []struct { expected stateFn name string input rune }{ { name: "0x18", input: 0x18, expected: ground, }, { name: "0x1A", input: 0x1A, expected: ground, }, { name: "0x1B", input: 0x1B, expected: escape, }, { name: "eof", input: eof, expected: nil, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { r := bytes.NewBuffer(nil) parse := NewParser(r) called := false parse.exit = func() { called = true } actual := anywhere(test.input, parse) act := reflect.ValueOf(actual).Pointer() exp := reflect.ValueOf(test.expected).Pointer() assert.Equal(t, exp, act, "wrong return function") if test.expected != nil { assert.True(t, called, "exit function not called") } }) } } func TestCSI(t *testing.T) { tests := []struct { name string input string expected []Sequence }{ { name: "CSI Entry + C0", input: "a\x1b[\x00", expected: []Sequence{ Print{"a", 1}, C0(0x00), }, }, { name: "CSI Entry + escape", input: "a\x1b[\x1b", expected: []Sequence{ Print{"a", 1}, }, }, { name: "CSI Entry + ignore", input: "a\x1b[\x7F", expected: []Sequence{ Print{"a", 1}, }, }, { name: "CSI Entry + dispatch", input: "a\x1b[c", expected: []Sequence{ Print{"a", 1}, CSI{ Final: 'c', }, }, }, { name: "CSI Param with collect first", input: "a\x1b[ 0 { os.Exit(1) } } golang-sourcehut-rockorager-vaxis-0.13.0/color.go000066400000000000000000000112751476577054500220440ustar00rootroot00000000000000package vaxis import "math" // Color is a terminal color. The zero value represents the default foreground // or background color type Color uint32 const ( indexed Color = 1 << 24 rgb Color = 1 << 25 ) // Params returns the TParm parameters for the color, or an empty slice if the // color is the default color func (c Color) Params() []uint8 { switch { case c&indexed != 0: return []uint8{uint8(c)} case c&rgb != 0: r := uint8(c >> 16) g := uint8(c >> 8) b := uint8(c) return []uint8{r, g, b} } return []uint8{} } // asIndex returns an 8bit color index for a given color. If the color is the // default color or already an index color, this returns itself. RGB colors will // be converted to their closest 256-index match, excluding indexes 0-15 as // these are typically user altered func (c Color) asIndex() Color { if c&rgb == 0 { return c } // Convert to 256 palette oR := uint8(c >> 16) oG := uint8(c >> 8) oB := uint8(c) dist := math.Inf(1) match := -1 for i, v := range colorIndex { dR := uint8(v >> 16) dG := uint8(v >> 8) dB := uint8(v) // weighted, thanks stackoverflow. We skip the sqrt // because we don't care about the absolute value of the // distance, only the comparisons trial := sq(float64(dR-oR)*.3) + sq(float64(dG-oG)*.59) + sq(float64(dB-oB)*.11) if trial < dist { match = i dist = trial } if dist == 0 { // return early when we have an exact match return IndexColor(uint8(i + 16)) } } if match < 0 { return Color(0) } return IndexColor(uint8(match + 16)) } func sq(v float64) float64 { return v * v } // RGBColor creates a new Color based on the supplied RGB values func RGBColor(r uint8, g uint8, b uint8) Color { color := Color(int(r)<<16 | int(g)<<8 | int(b)) return color | rgb } // HexColor creates a new Color based on the supplied 24-bit hex value func HexColor(v uint32) Color { return Color(v) | rgb } // IndexColor creates a new Color from the supplied 8 bit value. Values 0-255 // are valid func IndexColor(index uint8) Color { color := Color(index) return color | indexed } // index 0 is IndexColor(16) var colorIndex = []uint32{ 0x000000, 0x00005F, 0x000087, 0x0000AF, 0x0000D7, 0x0000FF, 0x005F00, 0x005F5F, 0x005F87, 0x005FAF, 0x005FD7, 0x005FFF, 0x008700, 0x00875F, 0x008787, 0x0087Af, 0x0087D7, 0x0087FF, 0x00AF00, 0x00AF5F, 0x00AF87, 0x00AFAF, 0x00AFD7, 0x00AFFF, 0x00D700, 0x00D75F, 0x00D787, 0x00D7AF, 0x00D7D7, 0x00D7FF, 0x00FF00, 0x00FF5F, 0x00FF87, 0x00FFAF, 0x00FFd7, 0x00FFFF, 0x5F0000, 0x5F005F, 0x5F0087, 0x5F00AF, 0x5F00D7, 0x5F00FF, 0x5F5F00, 0x5F5F5F, 0x5F5F87, 0x5F5FAF, 0x5F5FD7, 0x5F5FFF, 0x5F8700, 0x5F875F, 0x5F8787, 0x5F87AF, 0x5F87D7, 0x5F87FF, 0x5FAF00, 0x5FAF5F, 0x5FAF87, 0x5FAFAF, 0x5FAFD7, 0x5FAFFF, 0x5FD700, 0x5FD75F, 0x5FD787, 0x5FD7AF, 0x5FD7D7, 0x5FD7FF, 0x5FFF00, 0x5FFF5F, 0x5FFF87, 0x5FFFAF, 0x5FFFD7, 0x5FFFFF, 0x870000, 0x87005F, 0x870087, 0x8700AF, 0x8700D7, 0x8700FF, 0x875F00, 0x875F5F, 0x875F87, 0x875FAF, 0x875FD7, 0x875FFF, 0x878700, 0x87875F, 0x878787, 0x8787AF, 0x8787D7, 0x8787FF, 0x87AF00, 0x87AF5F, 0x87AF87, 0x87AFAF, 0x87AFD7, 0x87AFFF, 0x87D700, 0x87D75F, 0x87D787, 0x87D7AF, 0x87D7D7, 0x87D7FF, 0x87FF00, 0x87FF5F, 0x87FF87, 0x87FFAF, 0x87FFD7, 0x87FFFF, 0xAF0000, 0xAF005F, 0xAF0087, 0xAF00AF, 0xAF00D7, 0xAF00FF, 0xAF5F00, 0xAF5F5F, 0xAF5F87, 0xAF5FAF, 0xAF5FD7, 0xAF5FFF, 0xAF8700, 0xAF875F, 0xAF8787, 0xAF87AF, 0xAF87D7, 0xAF87FF, 0xAFAF00, 0xAFAF5F, 0xAFAF87, 0xAFAFAF, 0xAFAFD7, 0xAFAFFF, 0xAFD700, 0xAFD75F, 0xAFD787, 0xAFD7AF, 0xAFD7D7, 0xAFD7FF, 0xAFFF00, 0xAFFF5F, 0xAFFF87, 0xAFFFAF, 0xAFFFD7, 0xAFFFFF, 0xD70000, 0xD7005F, 0xD70087, 0xD700AF, 0xD700D7, 0xD700FF, 0xD75F00, 0xD75F5F, 0xD75F87, 0xD75FAF, 0xD75FD7, 0xD75FFF, 0xD78700, 0xD7875F, 0xD78787, 0xD787AF, 0xD787D7, 0xD787FF, 0xD7AF00, 0xD7AF5F, 0xD7AF87, 0xD7AFAF, 0xD7AFD7, 0xD7AFFF, 0xD7D700, 0xD7D75F, 0xD7D787, 0xD7D7AF, 0xD7D7D7, 0xD7D7FF, 0xD7FF00, 0xD7FF5F, 0xD7FF87, 0xD7FFAF, 0xD7FFD7, 0xD7FFFF, 0xFF0000, 0xFF005F, 0xFF0087, 0xFF00AF, 0xFF00D7, 0xFF00FF, 0xFF5F00, 0xFF5F5F, 0xFF5F87, 0xFF5FAF, 0xFF5FD7, 0xFF5FFF, 0xFF8700, 0xFF875F, 0xFF8787, 0xFF87AF, 0xFF87D7, 0xFF87FF, 0xFFAF00, 0xFFAF5F, 0xFFAF87, 0xFFAFAF, 0xFFAFD7, 0xFFAFFF, 0xFFD700, 0xFFD75F, 0xFFD787, 0xFFD7AF, 0xFFD7D7, 0xFFD7FF, 0xFFFF00, 0xFFFF5F, 0xFFFF87, 0xFFFFAF, 0xFFFFD7, 0xFFFFFF, 0x080808, 0x121212, 0x1C1C1C, 0x262626, 0x303030, 0x3A3A3A, 0x444444, 0x4E4E4E, 0x585858, 0x626262, 0x6C6C6C, 0x767676, 0x808080, 0x8A8A8A, 0x949494, 0x9E9E9E, 0xA8A8A8, 0xB2B2B2, 0xBCBCBC, 0xC6C6C6, 0xD0D0D0, 0xDADADA, 0xE4E4E4, 0xEEEEEE, } golang-sourcehut-rockorager-vaxis-0.13.0/color_test.go000066400000000000000000000015401476577054500230750ustar00rootroot00000000000000package vaxis_test import "git.sr.ht/~rockorager/vaxis" func ExampleRGBColor() { vx, _ := vaxis.New(vaxis.Options{}) color := vaxis.RGBColor(1, 2, 3) vx.Window().Fill(vaxis.Cell{ Character: vaxis.Character{ Grapheme: "a", }, Style: vaxis.Style{ Background: color, }, }) } func ExampleIndexColor() { vx, _ := vaxis.New(vaxis.Options{}) // Index 1 is usually a red color := vaxis.IndexColor(1) vx.Window().Fill(vaxis.Cell{ Character: vaxis.Character{ Grapheme: " ", Width: 1, }, Style: vaxis.Style{ Background: color, }, }) } func ExampleHexColor() { vx, _ := vaxis.New(vaxis.Options{}) // Creates an RGB color from a hex value color := vaxis.HexColor(0x00AABB) vx.Window().Fill(vaxis.Cell{ Character: vaxis.Character{ Grapheme: " ", Width: 1, }, Style: vaxis.Style{ Background: color, }, }) } golang-sourcehut-rockorager-vaxis-0.13.0/event.go000066400000000000000000000051601476577054500220430ustar00rootroot00000000000000package vaxis // Event is an empty interface used to pass data within a Vaxis application. // Vaxis will emit user input events as well as other input-related events. // Users can use PostEvent to post their own events into the loop type Event interface{} type ( primaryDeviceAttribute struct{} capabilitySixel struct{} capabilityOsc4 struct{} capabilityOsc10 struct{} capabilityOsc11 struct{} synchronizedUpdates struct{} unicodeCoreCap struct{} kittyKeyboard struct{} kittyGraphics struct{} styledUnderlines struct{} truecolor struct{} notifyColorChange struct{} textAreaPix struct{} textAreaChar struct{} inBandResizeEvents struct{} appID string terminalID string ) // Resize is delivered whenever a window size change is detected (likely via // SIGWINCH) type Resize struct { Cols int Rows int XPixel int YPixel int } // PasteStartEvent is sent at the beginning of a bracketed paste. Each [Key] // within the paste will also have the EventPaste set as the EventType type PasteStartEvent struct{} // PasteEndEvent is sent at the end of a bracketed paste. Each [Key] // within the paste will also have the EventPaste set as the EventType type PasteEndEvent struct{} // FocusIn is sent when the terminal has gained focus type FocusIn struct{} // FocusOut is sent when the terminal has lost focus type FocusOut struct{} // Redraw is a generic event which can be sent to the host application to tell // it some update has occurred it may not know about otherwise and it must // redraw. These are always issued after a SyncFunc has been called type Redraw struct{} // SyncFunc is a function which must be called in the main thread. Applications // should check for SyncFunc events, and when one is found it is safe to call. // Applications should assume that a redraw must occur after a SyncFunc type SyncFunc func() // QuitEvent is sent when the application is closing. It is emitted when the // application calls vaxis.Close, and often times won't be seen by the // application. type QuitEvent struct{} // ColorThemeMode is the current color theme of the terminal. The raw value is // equivalent to the DSR response value for each mode. type ColorThemeMode int const ( // The terminal has a dark color theme DarkMode ColorThemeMode = 1 // The terminal has a light color theme LightMode ColorThemeMode = 2 ) // ColorThemeUpdate is sent when the terminal color scheme has changed. This // event is only delivered if supported by the terminal type ColorThemeUpdate struct { Mode ColorThemeMode } golang-sourcehut-rockorager-vaxis-0.13.0/go.mod000066400000000000000000000010661476577054500215020ustar00rootroot00000000000000module git.sr.ht/~rockorager/vaxis go 1.18 require ( github.com/containerd/console v1.0.3 github.com/creack/pty v1.1.18 github.com/mattn/go-runewidth v0.0.14 github.com/mattn/go-sixel v0.0.5 github.com/rivo/uniseg v0.4.4 github.com/stretchr/testify v1.8.3 golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 golang.org/x/image v0.9.0 golang.org/x/sys v0.10.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/soniakeys/quant v1.0.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) golang-sourcehut-rockorager-vaxis-0.13.0/go.sum000066400000000000000000000127511476577054500215320ustar00rootroot00000000000000github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sixel v0.0.5 h1:55w2FR5ncuhKhXrM5ly1eiqMQfZsnAHIpYNGZX03Cv8= github.com/mattn/go-sixel v0.0.5/go.mod h1:h2Sss+DiUEHy0pUqcIB6PFXo5Cy8sTQEFr3a9/5ZLNw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y= github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds= github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 h1:k/i9J1pBpvlfR+9QsetwPyERsqu1GIbi967PQMq3Ivc= golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/image v0.9.0 h1:QrzfX26snvCM20hIhBwuHI/ThTg18b/+kcKdXHvnR+g= golang.org/x/image v0.9.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0 h1:SqMFp9UcQJZa+pmYuAKjd9xq1f0j5rLcDIk0mj4qAsA= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= golang-sourcehut-rockorager-vaxis-0.13.0/gwidth.go000066400000000000000000000013601476577054500222060ustar00rootroot00000000000000package vaxis import ( "strings" "github.com/mattn/go-runewidth" "github.com/rivo/uniseg" ) type graphemeWidthMethod int const ( wcwidth graphemeWidthMethod = iota noZWJ // we do unicodeStd but not if there is a ZWJ unicodeStd ) func gwidth(s string, method graphemeWidthMethod) int { switch method { case noZWJ: s = strings.ReplaceAll(s, "\u200D", "") return uniseg.StringWidth(s) case unicodeStd: return uniseg.StringWidth(s) default: total := 0 for _, r := range s { if r >= 0xFE00 && r <= 0xFE0F { // Variation Selectors 1 - 16 continue } if r >= 0xE0100 && r <= 0xE01EF { // Variation Selectors 17-256 continue } total += runewidth.RuneWidth(r) } return total } } golang-sourcehut-rockorager-vaxis-0.13.0/gwidth_test.go000066400000000000000000000021501476577054500232430ustar00rootroot00000000000000package vaxis import ( "testing" "github.com/stretchr/testify/assert" ) func TestRenderedWidth(t *testing.T) { tests := []struct { name string input string unicodeWidth int wcwidthWidth int noZWJWidth int }{ { name: "a", input: "a", unicodeWidth: 1, wcwidthWidth: 1, noZWJWidth: 1, }, { name: "emoji with ZWJ", input: "👩‍🚀", unicodeWidth: 2, wcwidthWidth: 4, noZWJWidth: 4, }, { name: "emoji with VS16 selector", input: "\xE2\x9D\xA4\xEF\xB8\x8F", unicodeWidth: 2, // This is *technically* wrong but most ter wcwidthWidth: 1, noZWJWidth: 2, }, { name: "emoji with skintone selector", input: "👋🏿", unicodeWidth: 2, wcwidthWidth: 4, noZWJWidth: 2, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { assert.Equal(t, test.unicodeWidth, gwidth(test.input, unicodeStd)) assert.Equal(t, test.wcwidthWidth, gwidth(test.input, wcwidth)) assert.Equal(t, test.noZWJWidth, gwidth(test.input, noZWJ)) }) } } golang-sourcehut-rockorager-vaxis-0.13.0/image.go000066400000000000000000000333161476577054500220100ustar00rootroot00000000000000package vaxis import ( "bytes" "encoding/base64" "fmt" "image" "image/color" "image/png" "io" "git.sr.ht/~rockorager/vaxis/log" "git.sr.ht/~rockorager/vaxis/octreequant" "github.com/mattn/go-sixel" "golang.org/x/image/draw" ) // Alpha value that we consider to be transparent enough to use default // background color const transparentEnough = 50 const ( noGraphics = iota fullBlock halfBlock sixelGraphics kitty ) // Image is a static image on the screen type Image interface { // Draw draws the [Image] to the [Window]. The image will not be drawn // if it is larger than the window Draw(Window) // Destroy removes an image from memory. Call when done with this image Destroy() // Resizes the image to fit within the provided area. The image will not // be upscaled, nor will it's aspect ratio be changed Resize(w int, h int) // CellSize is the current cell size of the encoded image CellSize() (w int, h int) } // NewImage creates a new image using the highest quality renderer the terminal // is capable of func (vx *Vaxis) NewImage(img image.Image) (Image, error) { switch vx.graphicsProtocol { case fullBlock: return vx.NewFullBlockImage(img), nil case halfBlock: return vx.NewHalfBlockImage(img), nil case sixelGraphics: return vx.NewSixel(img), nil case kitty: return vx.NewKittyGraphic(img), nil default: return nil, fmt.Errorf("no supported image protocol") } } type KittyImage struct { vx *Vaxis img image.Image id uint64 w int h int uploaded int32 encoding int32 buf *bytes.Buffer } func (vx *Vaxis) NewKittyGraphic(img image.Image) *KittyImage { log.Trace("new kitty image") k := &KittyImage{ vx: vx, img: img, id: vx.nextGraphicID(), buf: bytes.NewBuffer(nil), } return k } // Draw draws the [Image] to the [Window]. func (k *KittyImage) Draw(win Window) { if atomicLoad(&k.encoding) { return } col, row := win.Origin() log.Trace("placing kitty image at cell %d,%d", col, row) // the pid is a 32 bit number where the high 16bits are the width and // the low 16 are the height pid := uint(col)<<16 | uint(row) writeFunc := func(w io.Writer) { if !atomicLoad(&k.uploaded) { w.Write(k.buf.Bytes()) atomicStore(&k.uploaded, true) k.buf.Reset() } fmt.Fprintf(w, "\x1B_Ga=p,i=%d,p=%d,C=1\x1B\\", k.id, pid) } deleteFunc := func(w io.Writer) { fmt.Fprintf(w, "\x1B_Ga=d,d=i,i=%d,p=%d\x1B\\", k.id, pid) } placement := &placement{ col: col, row: row, id: k.id, w: k.w, h: k.h, writeTo: writeFunc, deleteFn: deleteFunc, } k.vx.graphicsNext = append(k.vx.graphicsNext, placement) } // Destroy deletes this image from memory func (k *KittyImage) Destroy() { fmt.Fprintf(k.vx.console, "\x1B_Ga=d,d=I,i=%d\x1B\\", k.id) } func (k *KittyImage) CellSize() (w int, h int) { return k.w, k.h } // Resizes the image to fit within the wxh area. The image will not be // upscaled, nor will it's aspect ratio be changed. Resizing will be done in a // separate goroutine. A [Redraw] event will be posted when complete func (k *KittyImage) Resize(w int, h int) { // Resize the image cellPixW := k.vx.winSize.XPixel / k.vx.winSize.Cols cellPixH := k.vx.winSize.YPixel / k.vx.winSize.Rows img := resizeImage(k.img, w, h, cellPixW, cellPixH) // Reupload the image max := img.Bounds().Max k.w = max.X / cellPixW if max.X%cellPixW != 0 { k.w += 1 } k.h = max.Y / cellPixH if max.Y%cellPixH != 0 { k.h += 1 } atomicStore(&k.encoding, true) go func() { defer atomicStore(&k.encoding, false) // Encode it to base64 buf := bytes.NewBuffer(nil) wc := base64.NewEncoder(base64.StdEncoding, buf) err := png.Encode(wc, img) if err != nil { log.Error("couldn't encode kitty image: %v", err) return } wc.Close() b := make([]byte, 4096) atomicStore(&k.uploaded, false) for buf.Len() > 0 { n, err := buf.Read(b) if err == io.EOF { break } m := 1 if buf.Len() == 0 { m = 0 } fmt.Fprintf(k.buf, "\x1B_Gf=100,i=%d,m=%d;%s\x1B\\", k.id, m, string(b[:n])) } k.vx.PostEventBlocking(Redraw{}) }() } type Sixel struct { vx *Vaxis img image.Image buf *bytes.Buffer id uint64 w int h int encoding int32 } // Draw draws the [Image] to the [Window]. The image will not be drawn // if it is larger than the window func (s *Sixel) Draw(win Window) { if s.buf.Len() == 0 { return } if atomicLoad(&s.encoding) { return } w, h := win.Size() if s.w > w || s.h > h { return } for y := 0; y < s.h; y += 1 { for x := 0; x < s.w; x += 1 { win.SetCell(x, y, Cell{ sixel: true, }) } } writeFunc := func(w io.Writer) { // Also need to set sixel value in here for Refresh cycles for y := 0; y < s.h; y += 1 { for x := 0; x < s.w; x += 1 { win.SetCell(x, y, Cell{ sixel: true, }) } } w.Write(s.buf.Bytes()) } deleteFunc := func(_ io.Writer) { // no-op. we expect users to Clear the screen or just print // cells, which will have the effect of clearing the sixel } col, row := win.Origin() log.Trace("placing sixel image at cell %d,%d", col, row) placement := &placement{ col: col, row: row, writeTo: writeFunc, deleteFn: deleteFunc, id: s.id, w: s.w, h: s.h, } s.vx.graphicsNext = append(s.vx.graphicsNext, placement) } // Destroy removes an image from memory. Call when done with this image func (s *Sixel) Destroy() { s.buf.Reset() } // Resizes the image to fit within the wxh area. The image will not be // upscaled, nor will it's aspect ratio be changed. Resize will be done in a // separate gorotuine. A Redraw event will be posted when complete func (s *Sixel) Resize(w int, h int) { atomicStore(&s.encoding, true) go func() { defer atomicStore(&s.encoding, false) // Resize the image cellPixW := s.vx.winSize.XPixel / s.vx.winSize.Cols cellPixH := s.vx.winSize.YPixel / s.vx.winSize.Rows img := resizeImage(s.img, w, h, cellPixW, cellPixH) max := img.Bounds().Max s.w = max.X / cellPixW if max.X%cellPixW != 0 { s.w += 1 } s.h = max.Y / cellPixH if max.Y%cellPixH != 0 { s.h += 1 } // Re-encode the image s.buf.Reset() var paletted image.Image if p, ok := img.(*image.Paletted); ok && len(p.Palette) < 255 { // fast-path for paletted images: pass through to sixel paletted = p } else { paletted = octreequant.Paletted(img, 254) } err := sixel.NewEncoder(s.buf).Encode(paletted) if err != nil { log.Error("couldn't encode sixel: %v", err) return } s.vx.PostEventBlocking(Redraw{}) }() } // CellSize is the current cell size of the encoded image func (s *Sixel) CellSize() (w int, h int) { if atomicLoad(&s.encoding) { return } return s.w, s.h } func (vx *Vaxis) NewSixel(img image.Image) *Sixel { log.Trace("new sixel image") s := &Sixel{ vx: vx, img: img, id: vx.nextGraphicID(), buf: bytes.NewBuffer(nil), } return s } // placement is an image placement. If two placements are identical, the // image will not be redrawn type placement struct { writeTo func(w io.Writer) deleteFn func(w io.Writer) col int row int id uint64 w int h int } // samePlacement compares two placements for equality. Two placements are // considered equal if it is the same image, with the same size, at the same // location func samePlacement(p1, p2 *placement) bool { if p1.id != p2.id { return false } if p1.col != p2.col { return false } if p1.row != p2.row { return false } if p1.w != p2.w { return false } if p1.h != p2.h { return false } return true } // Resizes an image to fit within the provided rectangle (as cells). If the // image already fits, it won't be resized func resizeImage(img image.Image, w int, h int, cellPixW int, cellPixH int) image.Image { wPix := img.Bounds().Max.X hPix := img.Bounds().Max.Y // Looks complicated but we're just calculating the size of the // image in cells, and rounding up since we will always take // over any cell we bleed into. columns := wPix / cellPixW if wPix%cellPixW != 0 { columns += 1 } lines := hPix / cellPixH if hPix%cellPixH != 0 { lines += 1 } log.Debug("resizing image from (%d x %d) to (%d x %d)", columns, lines, w, h) if columns <= w && lines <= h { return img } // calculate scale factors sfX := float64(w) / float64(columns) sfY := float64(h) / float64(lines) newPixelWidth := wPix newPixelHeight := hPix switch { case sfX == sfY: // no-op case sfX < sfY: // Width is farther off, so set our new width to w and scale h // appropriately newPixelWidth = int(sfX * float64(wPix)) newPixelHeight = int(sfX * float64(hPix)) case sfX > sfY: newPixelWidth = int(sfY * float64(wPix)) newPixelHeight = int(sfY * float64(hPix)) } dst := image.NewRGBA(image.Rect(0, 0, newPixelWidth, newPixelHeight)) draw.NearestNeighbor.Scale(dst, dst.Rect, img, img.Bounds(), draw.Over, nil) return dst } // FullBlockImage is an image composed of 0x20 characters. This is the most // primitive graphics protocol type FullBlockImage struct { vx *Vaxis img image.Image cells []Color width int height int } func (vx *Vaxis) NewFullBlockImage(img image.Image) *FullBlockImage { log.Trace("new full block image") fb := &FullBlockImage{ vx: vx, img: img, } return fb } func (fb *FullBlockImage) Draw(win Window) { col, row := win.Origin() log.Trace("placing full block image at cell %d,%d", col, row) for i, cell := range fb.cells { y := i / fb.width x := i - (y * fb.width) win.SetCell(x, y, Cell{ Character: Character{ Grapheme: " ", Width: 1, }, Style: Style{ Background: cell, }, }) } } // Resize resizes and re-encodes an image func (fb *FullBlockImage) Resize(w int, h int) { // FullBlockImage gets resized with a cell geometry of 1x2 pixels. We // will then average the vertical two pixels to make a single color ' ' // character img := resizeImage(fb.img, w, h, 1, 2) // Store the actual width and height of the resized image fb.width = img.Bounds().Max.X h = img.Bounds().Max.Y if h%2 != 0 { h += 1 } fb.height = h / 2 // The image will be made into an array of cells, each cell will capture // 1x2 pixels fb.cells = make([]Color, (fb.height * fb.width)) for i := range fb.cells { y := i / fb.width x := i - (y * fb.width) y *= 2 top := img.At(x, y) bot := img.At(x, y+1) r, g, b, a := averageColor(top, bot) switch { // TODO: What is the right value for alpha that we should set // the background color = 0?? case a < 50: fb.cells[i] = 0 default: fb.cells[i] = RGBColor(r, g, b) } } } func (fb *FullBlockImage) Destroy() { fb.cells = []Color{} } func (fb *FullBlockImage) CellSize() (int, int) { return fb.width, fb.height } func toRGB(c color.Color) (uint8, uint8, uint8, uint8) { pr, pg, pb, pa := c.RGBA() var r, g, b, a uint8 switch pa { case 0: r = uint8(pr) g = uint8(pg) b = uint8(pb) default: r = uint8((pr * 255) / pa) g = uint8((pg * 255) / pa) b = uint8((pb * 255) / pa) a = uint8(pa >> 8) } return r, g, b, a } // averageColor computes the average color from all inputs and returns it's rgb // value func averageColor(c color.Color, colors ...color.Color) (uint8, uint8, uint8, uint8) { var r, g, b, a int colors = append(colors, c) for _, col := range colors { rA, gA, bA, aA := toRGB(col) r += int(rA) g += int(gA) b += int(bA) a += int(aA) } n := len(colors) return uint8(r / n), uint8(g / n), uint8(b / n), uint8(a / n) } // HalfBlockImage is an image composed of half block characters. type HalfBlockImage struct { vx *Vaxis img image.Image cells []Cell width int height int } func (vx *Vaxis) NewHalfBlockImage(img image.Image) *HalfBlockImage { log.Trace("new half block image") hb := &HalfBlockImage{ vx: vx, img: img, } return hb } func (hb *HalfBlockImage) Draw(win Window) { col, row := win.Origin() log.Trace("placing half block image at cell %d,%d", col, row) for i, cell := range hb.cells { y := i / hb.width x := i - (y * hb.width) win.SetCell(x, y, cell) } } // Resize resizes and re-encodes an image func (hb *HalfBlockImage) Resize(w int, h int) { // HalfBlockImage gets resized with a cell geometry of 1x2 pixels. img := resizeImage(hb.img, w, h, 1, 2) // Store the actual width and height of the resized image hb.width = img.Bounds().Max.X h = img.Bounds().Max.Y if h%2 != 0 { h += 1 } hb.height = h / 2 // The image will be made into an array of cells, each cell will capture // 1x2 pixels hb.cells = make([]Cell, (hb.height * hb.width)) for i := range hb.cells { y := i / hb.width x := i - (y * hb.width) y *= 2 tr, tg, tb, ta := toRGB(img.At(x, y)) br, bg, bb, ba := toRGB(img.At(x, y+1)) // Figure out if one of the alpha channels is transparent // "enough" switch { case ta < transparentEnough && ba < transparentEnough: // Use a transparent space hb.cells[i] = Cell{ Character: Character{ Grapheme: " ", Width: 1, }, } case ta < transparentEnough: // Top is transparent. Use a lower block hb.cells[i] = Cell{ Character: Character{ Grapheme: "▄", Width: 1, }, Style: Style{ Foreground: RGBColor(br, bg, bb), }, } case ba < transparentEnough: // Bottom is transparent. Use an upper block hb.cells[i] = Cell{ Character: Character{ Grapheme: "▀", Width: 1, }, Style: Style{ Foreground: RGBColor(tr, tg, tb), }, } default: // Neither is transparent. Use an upper block hb.cells[i] = Cell{ Character: Character{ Grapheme: "▀", Width: 1, }, Style: Style{ Foreground: RGBColor(tr, tg, tb), Background: RGBColor(br, bg, bb), }, } } } } func (hb *HalfBlockImage) Destroy() { hb.cells = []Cell{} } func (hb *HalfBlockImage) CellSize() (int, int) { return hb.width, hb.height } golang-sourcehut-rockorager-vaxis-0.13.0/image_fullblock.go000066400000000000000000000044021476577054500240370ustar00rootroot00000000000000package vaxis // // import ( // "image" // ) // // var blitterCells = map[int]rune{ // 0b1111: '█', // FULL BLOCK // 0b0000: ' ', // SPACE // 0b1100: '▀', // UPPER HALF BLOCK // 0b0011: '▄', // LOWER HALF BLOCK // 0b0101: '▐', // RIGHT HALF BLOCK // 0b1010: '▌', // LEFT HALF BLOCK // 0b0010: '▖', // QUADRANT LOWER LEFT // 0b0001: '▗', // QUADRANT LOWER RIGHT // 0b1000: '▘', // QUADRANT UPPER LEFT // 0b1011: '▙', // QUADRANT UPPER LEFT AND LOWER LEFT AND LOWER RIGHT // 0b1001: '▚', // QUADRANT UPPER LEFT AND LOWER RIGHT // 0b1110: '▛', // QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER LEFT // 0b1101: '▜', // QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER RIGHT // 0b0100: '▝', // QUADRANT UPPER RIGHT // 0b0110: '▞', // QUADRANT UPPER RIGHT AND LOWER LEFT // 0b0111: '▟', // QUADRANT UPPER RIGHT AND LOWER LEFT AND LOWER RIGHT // } // // func blit(img image.Image) []Cell { // w := img.Bounds().Max.X // h := img.Bounds().Max.Y // if h%2 != 0 { // h += 1 // } // // The image will be made into an array of cells, each cell will capture // // 1x2 pixels // cells := make([]Cell, (w * h / 2)) // for i, cell := range cells { // y := i / w // x := i - (y * w) // y *= 2 // // top := img.At(x, y) // bottom := img.At(x, y+1) // var ( // r uint8 // g uint8 // b uint8 // ) // switch top { // case bottom: // cell.Character = Character{ // Grapheme: " ", // Width: 1, // } // pr, pg, pb, a := top.RGBA() // if a > 0 { // r = uint8((pr * 255) / a) // g = uint8((pg * 255) / a) // b = uint8((pb * 255) / a) // cell.Background = RGBColor(r, g, b) // } // default: // cell.Character = Character{ // Grapheme: "▀", // } // pr, pg, pb, a := top.RGBA() // switch a { // case 0: // r = uint8(pr) // g = uint8(pg) // b = uint8(pb) // default: // r = uint8((pr * 255) / a) // g = uint8((pg * 255) / a) // b = uint8((pb * 255) / a) // } // cell.Foreground = RGBColor(r, g, b) // pr, pg, pb, a = bottom.RGBA() // if a > 0 { // r = uint8((pr * 255) / a) // g = uint8((pg * 255) / a) // b = uint8((pb * 255) / a) // cell.Background = RGBColor(r, g, b) // } // } // cells[i] = cell // } // return cells // } golang-sourcehut-rockorager-vaxis-0.13.0/image_test.go000066400000000000000000000015221476577054500230410ustar00rootroot00000000000000package vaxis_test import ( "image/png" "os" "git.sr.ht/~rockorager/vaxis" ) func ExampleImage() { // Open our image f, err := os.Open("/home/rockorager/pic.png") if err != nil { panic(err) } // Decode into an image.Image img, err := png.Decode(f) if err != nil { panic(err) } vx, err := vaxis.New(vaxis.Options{}) if err != nil { panic(err) } // Create a graphic with Vaxis. Depending on the terminal, this will // either send the graphic to the terminal or create a sixel encoded // version of the image vimg, err := vx.NewImage(img) if err != nil { panic(err) } // Resize to whatever size we want, in cell values w := 20 h := 10 vimg.Resize(w, h) // Create a window. The window should fully contain the image win := vx.Window().New(0, 0, w, h) // Draw the graphic in the window vimg.Draw(win) vx.Render() } golang-sourcehut-rockorager-vaxis-0.13.0/key.go000066400000000000000000000444271476577054500215230ustar00rootroot00000000000000package vaxis import ( "bytes" "fmt" "strings" "unicode" "unicode/utf8" "git.sr.ht/~rockorager/vaxis/ansi" ) // Key is a key event. Codepoint can be either the literal codepoint of the // keypress, or a value set by Vaxis to indicate special keys. Special keys have // their codepoints outside of the valid unicode range type Key struct { // Text is text that the keypress generated Text string // Keycode is our primary key press. In alternate layouts, this will be // the lowercase value of the unicode point Keycode rune // The shifted keycode of this key event. This will only be non-zero if // the shift-modifier was used to generate the event ShiftedCode rune // BaseLayoutCode is the keycode that would have been generated on a // standard PC-101 layout BaseLayoutCode rune // Modifiers are any key modifier used to generate the event Modifiers ModifierMask // EventType is the type of key event this was (press, release, repeat, // or paste) EventType EventType } // Matches returns true if there is any way for the passed key and mods to match // the [Key]. Before matching, ModCapsLock and ModNumLock are removed from the // modifier mask. Returns true if any of the following are true // // 1. Keycode and Modifiers are exact matches // 2. Text and Modifiers are exact matches // 3. ShiftedCode and Modifiers (with ModShift removed) are exact matches // 4. BaseLayoutCode and Modifiers are exact matches // // If key is not a letter, but still a graphic: (so we can match ':', which is // shift+; but we don't want to match "shift+tab" as the same as "tab") // // 5. Keycode and Modifers (without ModShift) are exact matches // 6. Shifted Keycode and Modifers (without ModShift) are exact matches // // If key is lowercase or not a letter and mods includes ModShift, uppercase // Key, remove ModShift and continue // // 6. Text and Modifiers are exact matches func (k Key) Matches(key rune, modifiers ...ModifierMask) bool { var mods ModifierMask for _, mod := range modifiers { mods |= mod } mods = mods &^ ModCapsLock mods = mods &^ ModNumLock kMods := k.Modifiers &^ ModCapsLock kMods = kMods &^ ModNumLock unshiftedkMods := kMods &^ ModShift unshiftedMods := mods &^ ModShift // Rule 1 if k.Keycode == key && mods == kMods { return true } // Rule 2 if k.Text == string(key) && mods == kMods { return true } // Rule 3 if k.ShiftedCode == key && mods == unshiftedkMods { return true } // Rule 4 if k.BaseLayoutCode == key && mods == kMods { return true } // Rule 5 if !unicode.IsLetter(key) && unicode.IsGraphic(key) { if k.Keycode == key && unshiftedkMods == unshiftedMods { return true } if k.ShiftedCode == key && unshiftedkMods == unshiftedMods { return true } } // Rule 6 if mods&ModShift != 0 && unicode.IsLower(key) { key = unicode.ToUpper(key) if k.Text == string(key) && unshiftedMods == unshiftedkMods { return true } } return false } // MatchString parses a string and matches to the Key event. The syntax for // strings is: [+]+. For example: // // Ctrl+p // Shift+Alt+Up // // All modifiers will be matched lowercase func (k Key) MatchString(tgt string) bool { if tgt == "" { return false } if r, n := utf8.DecodeRuneInString(tgt); n == len(tgt) { // fast path if the 'tgt' is a single utf8 codepoint return k.Matches(r) } vals := strings.Split(tgt, "+") mods := vals[0 : len(vals)-1] key := vals[len(vals)-1] var mask ModifierMask for _, m := range mods { switch strings.ToLower(m) { case "shift": mask |= ModShift case "alt": mask |= ModAlt case "ctrl": mask |= ModCtrl case "super": mask |= ModSuper case "meta": mask |= ModMeta case "caps": mask |= ModCapsLock case "num": mask |= ModNumLock } } if r, n := utf8.DecodeRuneInString(key); n == len(key) { // fast path if the 'key' is unicode return k.Matches(r, mask) } for _, kn := range keyNames { if !strings.EqualFold(kn.name, key) { continue } return k.Matches(kn.key, mask) } // maybe it's a multi-byte, non special character. Grab the first rune // and try matching for _, r := range key { return k.Matches(r, mask) } // not a match return false } // ModifierMask is a bitmask for which modifier keys were held when a key was // pressed type ModifierMask int const ( // Values equivalent to kitty keyboard protocol ModShift ModifierMask = 1 << iota ModAlt ModCtrl ModSuper ModHyper ModMeta ModCapsLock ModNumLock ) // EventType is an input event type (press, repeat, release, etc) type EventType int const ( // The key / button was pressed EventPress EventType = iota // The key / button was repeated EventRepeat // The key / button was released EventRelease // A mouse motion event (with or without a button press) EventMotion // The key resulted from a paste EventPaste ) // String returns a human-readable description of the keypress, suitable for use // in matching ("Ctrl+c") func (k Key) String() string { buf := &bytes.Buffer{} if k.EventType != EventRelease { // if k.Modifiers&ModNumLock != 0 { // buf.WriteString("num-") // } // if k.Modifiers&ModCapsLock != 0 { // buf.WriteString("caps-") // } if k.Modifiers&ModMeta != 0 { buf.WriteString("Meta+") } if k.Modifiers&ModHyper != 0 { buf.WriteString("Hyper+") } if k.Modifiers&ModSuper != 0 { buf.WriteString("Super+") } if k.Modifiers&ModCtrl != 0 { buf.WriteString("Ctrl+") } if k.Modifiers&ModAlt != 0 { buf.WriteString("Alt+") } if k.Modifiers&ModShift != 0 { buf.WriteString("Shift+") } } switch { case k.Keycode == KeyTab: case k.Keycode == KeySpace: case k.Keycode == KeyEsc: case k.Keycode == KeyBackspace: case k.Keycode == KeyEnter: case k.Keycode == 0x08: k.Keycode = KeyBackspace case k.Keycode < 0x00: return "invalid" case k.Keycode < 0x20: var val rune switch { case k.Keycode == 0x00: val = '@' case k.Keycode <= 0x1A: // normalize these to lowercase runes val = k.Keycode + 0x60 case k.Keycode < 0x20: val = k.Keycode + 0x40 } return fmt.Sprintf("Ctrl+%c", val) case k.Keycode <= unicode.MaxRune: if k.Modifiers&ModCapsLock != 0 { buf.WriteRune(unicode.ToUpper(k.Keycode)) } else { buf.WriteRune(k.Keycode) } } for _, kn := range keyNames { if kn.key != k.Keycode { continue } buf.WriteString(kn.name) break } return buf.String() } func decodeKey(seq ansi.Sequence) Key { key := Key{} switch seq := seq.(type) { case ansi.Print: // For decoding keys, we take the first rune var raw rune for _, r := range seq.Grapheme { raw = r break } key.Keycode = raw if unicode.IsUpper(raw) { key.Keycode = unicode.ToLower(raw) key.ShiftedCode = raw // It's a shifted character key.Modifiers = ModShift } if key.Keycode != KeyBackspace { key.Text = seq.Grapheme } // NOTE: we don't set baselayout code on printed keys. In legacy // encodings, this is meaningless. In kitty, this is best used to map // keybinds and we should only get ansi.Print types when a paste occurs case ansi.C0: switch rune(seq) { case 0x08: key.Keycode = KeyBackspace case 0x09: key.Keycode = KeyTab case 0x0D: key.Keycode = KeyEnter case 0x1B: key.Keycode = KeyEsc default: key.Modifiers = ModCtrl switch { case rune(seq) == 0x00: key.Keycode = '@' case rune(seq) <= 0x1A: // normalize these to lowercase runes key.Keycode = rune(seq) + 0x60 case rune(seq) < 0x20: key.Keycode = rune(seq) + 0x40 } } case ansi.ESC: key.Keycode = seq.Final key.Modifiers = ModAlt case ansi.SS3: switch rune(seq) { case 'A': key.Keycode = KeyUp case 'B': key.Keycode = KeyDown case 'C': key.Keycode = KeyRight case 'D': key.Keycode = KeyLeft case 'F': key.Keycode = KeyEnd case 'H': key.Keycode = KeyHome case 'P': key.Keycode = KeyF01 case 'Q': key.Keycode = KeyF02 case 'R': key.Keycode = KeyF03 case 'S': key.Keycode = KeyF04 } case ansi.CSI: if len(seq.Parameters) == 0 { seq.Parameters = [][]int{ {1}, } } for i, pm := range seq.Parameters { switch i { case 0: for j, ps := range pm { switch j { case 0: // our keycode // unicode-key-code // This will always be length of at least 1 sk := specialKey{rune(ps), seq.Final} if sk.keycode == 1 && sk.final == 'Z' { key.Keycode = KeyTab key.Modifiers = ModShift continue } var ok bool key.Keycode, ok = specialsKeys[sk] if !ok { key.Keycode = rune(ps) } case 1: // Shifted keycode key.ShiftedCode = rune(ps) case 2: // Base layout code key.BaseLayoutCode = rune(ps) } } case 1: // Kitty keyboard protocol reports these as their // bitmask + 1, so that an unmodified key has a value of // 1. We subtract one to normalize to our internal // representation for j, ps := range pm { switch j { case 0: // Modifiers key.Modifiers = ModifierMask(pm[0] - 1) if key.Modifiers < 0 { key.Modifiers = 0 } case 1: // event type // key.EventType = EventType(ps) - 1 } } case 2: // text-as-codepoint if key.Keycode == 27 && seq.Final == '~' && len(pm) > 0 { // Special case: Ctrl+Enter, Ctrl+Tab, ... are sent as 27;mods;key~ key.Keycode = rune(pm[0]) } else { for _, p := range pm { key.Text += string(rune(p)) } } } } } // Remove caps and num, if all we have left is shift and no text was // generated, check if our codepoint is printable and we'll force some // text. This check is to get around a bug in ghostty and foot where // shift+space generates \x1b[32;2u with the disambiguate flag on. This // can be removed in the future when these are fixed (I've submitted // patches to both terminals) nmods := key.Modifiers &^ (ModCapsLock | ModNumLock) if key.Text == "" && nmods == ModShift && unicode.IsPrint(key.Keycode) { key.Text = string(unicode.ToUpper(key.Keycode)) } return key } type specialKey struct { keycode rune final rune } const ( extended = unicode.MaxRune + 1 ) const ( KeyUp rune = extended + 1 + iota KeyRight KeyDown KeyLeft KeyInsert KeyDelete KeyPgDown KeyPgUp KeyHome KeyEnd KeyF00 KeyF01 KeyF02 KeyF03 KeyF04 KeyF05 KeyF06 KeyF07 KeyF08 KeyF09 KeyF10 KeyF11 KeyF12 KeyF13 KeyF14 KeyF15 KeyF16 KeyF17 KeyF18 KeyF19 KeyF20 KeyF21 KeyF22 KeyF23 KeyF24 KeyF25 KeyF26 KeyF27 KeyF28 KeyF29 KeyF30 KeyF31 KeyF32 KeyF33 KeyF34 KeyF35 KeyF36 KeyF37 KeyF38 KeyF39 KeyF40 KeyF41 KeyF42 KeyF43 KeyF44 KeyF45 KeyF46 KeyF47 KeyF48 KeyF49 KeyF50 KeyF51 KeyF52 KeyF53 KeyF54 KeyF55 KeyF56 KeyF57 KeyF58 KeyF59 KeyF60 KeyF61 KeyF62 KeyF63 // F63 is max defined in terminfo KeyClear KeyDownLeft KeyDownRight KeyUpLeft KeyUpRight KeyCenter KeyBegin KeyCancel KeyClose KeyCommand KeyCopy KeyExit KeyPrint KeyRefresh // notcurses says these are only avaialbe in kitty kbp KeyCapsLock KeyScrollLock KeyNumlock KeyPrintScreen KeyPause KeyMenu // Media keys, also generally only kitty kbp KeyMediaPlay KeyMediaPause KeyMediaPlayPause KeyMediaRev KeyMediaStop KeyMediaFF KeyMediaRewind KeyMediaNext KeyMediaPrev KeyMediaRecord KeyMediaVolDown KeyMediaVolUp KeyMediaMute // Modifiers, when pressed by themselves KeyLeftShift KeyLeftControl KeyLeftAlt KeyLeftSuper KeyLeftHyper KeyLeftMeta KeyRightShift KeyRightControl KeyRightAlt KeyRightSuper KeyRightHyper KeyRightMeta KeyL3Shift KeyL5Shift // Numerical pad keys, also generally only kitty kbp KeyKeyPad0 KeyKeyPad1 KeyKeyPad2 KeyKeyPad3 KeyKeyPad4 KeyKeyPad5 KeyKeyPad6 KeyKeyPad7 KeyKeyPad8 KeyKeyPad9 KeyKeyPadDecimal KeyKeyPadDivide KeyKeyPadMultiply KeyKeyPadSubtract KeyKeyPadAdd KeyKeyPadEnter KeyKeyPadEqual KeyKeyPadSeparator KeyKeyPadLeft KeyKeyPadRight KeyKeyPadUp KeyKeyPadDown KeyKeyPadPageUp KeyKeyPadPageDown KeyKeyPadHome KeyKeyPadEnd KeyKeyPadInsert KeyKeyPadDelete KeyKeyPadBegin // Aliases KeyEnter = 0x0D KeyReturn = KeyEnter KeyTab = 0x09 KeyEsc = 0x1B KeySpace = 0x20 KeyBackspace = 0x7F ) var specialsKeys = map[specialKey]rune{ {27, 'u'}: KeyEsc, {13, 'u'}: KeyEnter, {9, 'u'}: KeyTab, {127, 'u'}: KeyBackspace, {2, '~'}: KeyInsert, {3, '~'}: KeyDelete, {1, 'D'}: KeyLeft, {1, 'C'}: KeyRight, {1, 'B'}: KeyDown, {1, 'A'}: KeyUp, {5, '~'}: KeyPgUp, {6, '~'}: KeyPgDown, {1, 'F'}: KeyEnd, {4, '~'}: KeyEnd, {8, '~'}: KeyEnd, {1, 'H'}: KeyHome, {1, '~'}: KeyHome, {7, '~'}: KeyHome, {57358, 'u'}: KeyCapsLock, {57359, 'u'}: KeyScrollLock, {57360, 'u'}: KeyNumlock, {57361, 'u'}: KeyPrintScreen, {57362, 'u'}: KeyPause, {57363, 'u'}: KeyMenu, {1, 'P'}: KeyF01, {11, '~'}: KeyF01, {1, 'Q'}: KeyF02, {12, '~'}: KeyF02, {1, 'R'}: KeyF03, {13, '~'}: KeyF03, {1, 'S'}: KeyF04, {14, '~'}: KeyF04, {15, '~'}: KeyF05, {17, '~'}: KeyF06, {18, '~'}: KeyF07, {19, '~'}: KeyF08, {20, '~'}: KeyF09, {21, '~'}: KeyF10, {23, '~'}: KeyF11, {24, '~'}: KeyF12, // DEC vt220 F13-F20 definitions {25, '~'}: KeyF13, {26, '~'}: KeyF14, {28, '~'}: KeyF15, {29, '~'}: KeyF16, {31, '~'}: KeyF17, {32, '~'}: KeyF18, {33, '~'}: KeyF19, {34, '~'}: KeyF20, // Kitty encodings F13+ {57376, 'u'}: KeyF13, {57377, 'u'}: KeyF14, {57378, 'u'}: KeyF15, {57379, 'u'}: KeyF16, {57380, 'u'}: KeyF17, {57381, 'u'}: KeyF18, {57382, 'u'}: KeyF19, {57383, 'u'}: KeyF20, {57384, 'u'}: KeyF21, {57385, 'u'}: KeyF22, {57386, 'u'}: KeyF23, {57387, 'u'}: KeyF24, {57388, 'u'}: KeyF25, {57389, 'u'}: KeyF26, {57390, 'u'}: KeyF27, {57391, 'u'}: KeyF28, {57392, 'u'}: KeyF29, {57393, 'u'}: KeyF30, {57394, 'u'}: KeyF31, {57395, 'u'}: KeyF32, {57396, 'u'}: KeyF33, {57397, 'u'}: KeyF34, {57398, 'u'}: KeyF35, {57399, 'u'}: KeyKeyPad0, {57400, 'u'}: KeyKeyPad1, {57401, 'u'}: KeyKeyPad2, {57402, 'u'}: KeyKeyPad3, {57403, 'u'}: KeyKeyPad4, {57404, 'u'}: KeyKeyPad5, {57405, 'u'}: KeyKeyPad6, {57406, 'u'}: KeyKeyPad7, {57407, 'u'}: KeyKeyPad8, {57408, 'u'}: KeyKeyPad9, {57409, 'u'}: KeyKeyPadDecimal, {57410, 'u'}: KeyKeyPadDivide, {57411, 'u'}: KeyKeyPadMultiply, {57412, 'u'}: KeyKeyPadSubtract, {57413, 'u'}: KeyKeyPadAdd, {57414, 'u'}: KeyKeyPadEnter, {57415, 'u'}: KeyKeyPadEqual, {57416, 'u'}: KeyKeyPadSeparator, {57417, 'u'}: KeyKeyPadLeft, {57418, 'u'}: KeyKeyPadRight, {57419, 'u'}: KeyKeyPadUp, {57420, 'u'}: KeyKeyPadDown, {57421, 'u'}: KeyKeyPadPageUp, {57422, 'u'}: KeyKeyPadPageDown, {57423, 'u'}: KeyKeyPadHome, {57424, 'u'}: KeyKeyPadEnd, {57425, 'u'}: KeyKeyPadInsert, {57426, 'u'}: KeyKeyPadDelete, {1, 'E'}: KeyKeyPadBegin, {57427, '~'}: KeyKeyPadBegin, {57428, 'u'}: KeyMediaPlay, {57429, 'u'}: KeyMediaPause, {57430, 'u'}: KeyMediaPlayPause, {57431, 'u'}: KeyMediaRev, {57432, 'u'}: KeyMediaStop, {57433, 'u'}: KeyMediaFF, {57434, 'u'}: KeyMediaRewind, {57435, 'u'}: KeyMediaNext, {57436, 'u'}: KeyMediaPrev, {57437, 'u'}: KeyMediaRecord, {57438, 'u'}: KeyMediaVolDown, {57439, 'u'}: KeyMediaVolUp, {57440, 'u'}: KeyMediaMute, {57441, 'u'}: KeyLeftShift, {57442, 'u'}: KeyLeftControl, {57443, 'u'}: KeyLeftAlt, {57444, 'u'}: KeyLeftSuper, {57445, 'u'}: KeyLeftHyper, {57446, 'u'}: KeyLeftMeta, {57447, 'u'}: KeyRightShift, {57448, 'u'}: KeyRightControl, {57449, 'u'}: KeyRightAlt, {57450, 'u'}: KeyRightSuper, {57451, 'u'}: KeyRightHyper, {57452, 'u'}: KeyRightMeta, {57453, 'u'}: KeyL3Shift, {57454, 'u'}: KeyL5Shift, } type keyName struct { key rune name string } var keyNames = []keyName{ {KeyUp, "Up"}, {KeyRight, "Right"}, {KeyDown, "Down"}, {KeyLeft, "Left"}, {KeyInsert, "Insert"}, {KeyDelete, "Delete"}, {KeyBackspace, "BackSpace"}, {KeyPgDown, "Page_Down"}, {KeyPgUp, "Page_Up"}, {KeyHome, "Home"}, {KeyEnd, "End"}, {KeyF00, "F0"}, {KeyF01, "F1"}, {KeyF02, "F2"}, {KeyF03, "F3"}, {KeyF04, "F4"}, {KeyF05, "F5"}, {KeyF06, "F6"}, {KeyF07, "F7"}, {KeyF08, "F8"}, {KeyF09, "F9"}, {KeyF10, "F10"}, {KeyF11, "F11"}, {KeyF12, "F12"}, {KeyF13, "F13"}, {KeyF14, "F14"}, {KeyF15, "F15"}, {KeyF16, "F16"}, {KeyF17, "F17"}, {KeyF18, "F18"}, {KeyF19, "F19"}, {KeyF20, "F20"}, {KeyF21, "F21"}, {KeyF22, "F22"}, {KeyF23, "F23"}, {KeyF24, "F24"}, {KeyF25, "F25"}, {KeyF26, "F26"}, {KeyF27, "F27"}, {KeyF28, "F28"}, {KeyF29, "F29"}, {KeyF30, "F30"}, {KeyF31, "F31"}, {KeyF32, "F32"}, {KeyF33, "F33"}, {KeyF34, "F34"}, {KeyF35, "F35"}, {KeyF36, "F36"}, {KeyF37, "F37"}, {KeyF38, "F38"}, {KeyF39, "F39"}, {KeyF40, "F40"}, {KeyF41, "F41"}, {KeyF42, "F42"}, {KeyF43, "F43"}, {KeyF44, "F44"}, {KeyF45, "F45"}, {KeyF46, "F46"}, {KeyF47, "F47"}, {KeyF48, "F48"}, {KeyF49, "F49"}, {KeyF50, "F50"}, {KeyF51, "F51"}, {KeyF52, "F52"}, {KeyF53, "F53"}, {KeyF54, "F54"}, {KeyF55, "F55"}, {KeyF56, "F56"}, {KeyF57, "F57"}, {KeyF58, "F58"}, {KeyF59, "F59"}, {KeyF60, "F60"}, {KeyF61, "F61"}, {KeyF62, "F62"}, {KeyF63, "F63"}, {KeyEnter, "Enter"}, {KeyClear, "Clear"}, {KeyDownLeft, "DownLeft"}, {KeyDownRight, "DownRight"}, {KeyUpLeft, "UpLeft"}, {KeyUpRight, "UpRight"}, {KeyCenter, "Center"}, {KeyBegin, "Begin"}, {KeyCancel, "Cancel"}, {KeyClose, "Close"}, {KeyCommand, "Cmd"}, {KeyCopy, "Copy"}, {KeyExit, "Exit"}, {KeyPrint, "Print"}, {KeyRefresh, "Refresh"}, {KeyCapsLock, "Caps_Lock"}, {KeyScrollLock, "Scroll_Lock"}, {KeyNumlock, "Num_Lock"}, {KeyPrintScreen, "Print"}, {KeyPause, "Pause"}, {KeyMenu, "Menu"}, {KeyMediaPlay, "Media_Play"}, {KeyMediaPause, "Media_Pause"}, {KeyMediaPlayPause, "Media_Play_Pause"}, {KeyMediaRev, "Media_Reverse"}, {KeyMediaStop, "Media_Stop"}, {KeyMediaFF, "Media_Fast_Forward"}, {KeyMediaRewind, "Media_Rewind"}, {KeyMediaNext, "Media_Track_Next"}, {KeyMediaPrev, "Media_Track_Previous"}, {KeyMediaRecord, "Media_Record"}, {KeyMediaVolDown, "Lower_Volume"}, {KeyMediaVolUp, "Raise_Volume"}, {KeyMediaMute, "Mute_Volume"}, {KeyLeftShift, "Shift_L"}, {KeyLeftControl, "Control_L"}, {KeyLeftAlt, "Alt_L"}, {KeyLeftSuper, "Super_L"}, {KeyLeftHyper, "Hyper_L"}, {KeyLeftMeta, "Meta_L"}, {KeyRightShift, "Shift_R"}, {KeyRightControl, "Control_R"}, {KeyRightAlt, "Alt_R"}, {KeyRightSuper, "Super_R"}, {KeyRightHyper, "Hyper_R"}, {KeyRightMeta, "Meta_R"}, {KeyL3Shift, "ISO_Level3_Shift"}, {KeyL5Shift, "ISO_Level5_Shift"}, {KeyTab, "Tab"}, {KeyEsc, "Escape"}, {KeySpace, "space"}, } golang-sourcehut-rockorager-vaxis-0.13.0/key_example_test.go000066400000000000000000000005251476577054500242640ustar00rootroot00000000000000package vaxis_test import ( "git.sr.ht/~rockorager/vaxis" ) func ExampleKey() { vx, _ := vaxis.New(vaxis.Options{}) msg := vx.PollEvent() switch msg := msg.(type) { case vaxis.Key: switch msg.String() { case "Ctrl+c": vx.Close() case "Ctrl+l": vx.Refresh() case "j": // Down? default: // handle the key } } } golang-sourcehut-rockorager-vaxis-0.13.0/key_test.go000066400000000000000000000177161476577054500225630ustar00rootroot00000000000000package vaxis import ( "strings" "testing" "git.sr.ht/~rockorager/vaxis/ansi" "github.com/stretchr/testify/assert" ) func TestKey(t *testing.T) { tests := []struct { name string key Key matchRune rune matchMods ModifierMask }{ { name: "j", key: Key{Keycode: 'j'}, matchRune: 'j', }, { name: "Ctrl+@", key: Key{Keycode: '@', Modifiers: ModCtrl}, matchRune: '@', matchMods: ModCtrl, }, { name: "Ctrl+a", key: Key{Keycode: 'a', Modifiers: ModCtrl}, matchRune: 'a', matchMods: ModCtrl, }, { name: "Alt+a", key: Key{Keycode: 'a', Modifiers: ModAlt}, matchRune: 'a', matchMods: ModAlt, }, { name: "F1", key: Key{Keycode: KeyF01}, matchRune: KeyF01, }, { name: "Shift+F1", key: Key{Keycode: KeyF01, Modifiers: ModShift}, matchRune: KeyF01, matchMods: ModShift, }, { name: "Shift+Tab", key: Key{Keycode: KeyTab, Modifiers: ModShift}, matchRune: KeyTab, matchMods: ModShift, }, { name: "Escape", key: Key{Keycode: KeyEsc}, matchRune: KeyEsc, }, { name: "space", key: Key{Keycode: KeySpace}, matchRune: ' ', }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { actual := test.key.String() assert.Equal(t, test.name, actual) assert.True(t, test.key.Matches(test.matchRune, test.matchMods)) }) } } // Parses a raw sequence obtained from actual terminals into a key then tests // the match function func TestKeyMatches(t *testing.T) { shouldMatch := []struct { name string sequence string matchRune rune matchMods ModifierMask matchString string }{ { name: "Application: j", sequence: "j", matchRune: 'j', matchString: "j", }, { name: "Kitty: j", sequence: "\x1b[106;1:3u", matchRune: 'j', matchString: "j", }, { name: "Legacy: Ctrl+j", sequence: "\n", matchRune: 'j', matchMods: ModCtrl, matchString: "ctrl+j", }, { name: "Legacy: Ctrl+z", sequence: "\x1a", matchRune: 'z', matchMods: ModCtrl, matchString: "ctrl+z", }, { name: "Kitty: Ctrl+j", sequence: "\x1b[106;5:3u", matchRune: 'j', matchMods: ModCtrl, }, { name: "Legacy: caps+j", sequence: "J", matchRune: 'J', matchString: "caps+J", }, { name: "Kitty: caps+j", sequence: "\x1b[106;65;74u", matchRune: 'J', matchString: "caps+j", }, { name: "Kitty: shift+j", sequence: "\x1b[106;65;74u", matchRune: 'j', matchMods: ModShift, matchString: "shift+j", }, { name: "Legacy: F1", sequence: "\x1bOP", matchRune: KeyF01, matchString: "f1", }, { name: "Kitty: F1", sequence: "\x1b[P", matchRune: KeyF01, matchString: "f1", }, { name: "Legacy: Shift+F1", sequence: "\x1b[1;2P", matchRune: KeyF01, matchMods: ModShift, matchString: "shift+f1", }, { name: "Kitty: Shift+F1", sequence: "\x1b[1;2P", matchRune: KeyF01, matchMods: ModShift, matchString: "shift+f1", }, { name: "Kitty: F35", sequence: "\x1b[57398u", matchRune: KeyF35, matchString: "F35", }, { name: "Kitty: Shift+F35", sequence: "\x1b[57398;2u", matchRune: KeyF35, matchMods: ModShift, matchString: "sHiFt+f35", }, { name: "Legacy: ф", sequence: "ф", matchRune: 'ф', matchString: "ф", }, { name: "Kitty: ф matched to 'ф'", sequence: "\x1b[1092::97;;1092u", matchRune: 'ф', matchString: "ф", }, { name: "Kitty: ф matched to 'a'", sequence: "\x1b[1092::97;;1092u", matchRune: 'a', matchString: "ф", }, { name: "Kitty: Ctrl+Shift+ф matched to Ctrl+Shift+'a'", sequence: "\x1b[1092:1060:97;6:3u", matchRune: 'a', matchMods: ModCtrl | ModShift, matchString: "ctrl+shift+ф", }, { name: "Kitty: ':' (shift + ';')", sequence: "\x1b[59:58;2;58u", matchRune: ':', matchMods: ModShift, matchString: ":", }, { name: "legacy: 'tab'", sequence: "\t", matchRune: KeyTab, matchString: "tab", }, { name: "legacy: 'shift+tab'", sequence: "\x1b[Z", matchRune: KeyTab, matchMods: ModShift, matchString: "shift+tab", }, { name: "Kitty: 'tab'", sequence: "\x1b[9;1:1u", matchRune: KeyTab, matchString: "tab", }, { name: "Kitty: 'shift+tab'", sequence: "\x1b[9;2:1u", matchRune: KeyTab, matchMods: ModShift, matchString: "shift+tab", }, { name: "legacy: 'ctrl+shift+tab'", sequence: "\x1b[27;6;9~", matchRune: KeyTab, matchMods: ModShift | ModCtrl, matchString: "ctrl+shift+tab", }, { name: "kitty: 'caps+p'", sequence: "\x1b[112;65;80u", // actually the sequence for CAPS+p matchRune: 'p', matchMods: ModCapsLock, matchString: "P", }, } for _, test := range shouldMatch { t.Run(test.name, func(t *testing.T) { parser := ansi.NewParser(strings.NewReader(test.sequence)) seq := <-parser.Next() key := decodeKey(seq) assert.True(t, key.Matches(test.matchRune, test.matchMods), "got %s %#v", key.String(), key) if test.matchString != "" { assert.True(t, key.MatchString(test.matchString), "got %s %#v", key.String(), key) } }) } shouldNotMatch := []struct { name string sequence string matchRune rune matchMods ModifierMask matchString string }{ { name: "kitty: 'caps+p' is not 'ctrl+shift+p'", sequence: "\x1b[112;65;80u", // actually the sequence for CAPS+p matchRune: 'p', matchMods: ModCtrl | ModShift, matchString: "ctrl+shift+p", }, } for _, test := range shouldNotMatch { t.Run(test.name, func(t *testing.T) { parser := ansi.NewParser(strings.NewReader(test.sequence)) seq := <-parser.Next() key := decodeKey(seq) assert.False(t, key.Matches(test.matchRune, test.matchMods), "got %s %#v", key.String(), key) if test.matchString != "" { assert.False(t, key.MatchString(test.matchString), "got %s %#v", key.String(), key) } }) } } func TestKeyDecode(t *testing.T) { tests := []struct { name string sequence ansi.Sequence expected Key }{ { name: "legacy: j", sequence: ansi.Print{"j", 1}, expected: Key{ Keycode: 'j', Text: "j", }, }, { name: "legacy: Up", sequence: ansi.SS3('A'), expected: Key{Keycode: KeyUp}, }, { name: "legacy: Up, normal keys", sequence: ansi.CSI{Final: 'A'}, expected: Key{Keycode: KeyUp}, }, { name: "legacy: shift+j", sequence: ansi.Print{"J", 1}, expected: Key{ Keycode: 'j', ShiftedCode: 'J', Modifiers: ModShift, Text: "J", }, }, { name: "kitty: j with event", sequence: ansi.CSI{ Final: 'u', Parameters: [][]int{ {106}, {1, 1}, {106}, }, }, expected: Key{ Keycode: 'j', Text: "j", }, }, { name: "kitty: j with minimal", sequence: ansi.CSI{ Final: 'u', Parameters: [][]int{ {106}, {}, {106}, }, }, expected: Key{ Keycode: 'j', Text: "j", }, }, { name: "kitty: ф", sequence: ansi.CSI{ Final: 'u', Parameters: [][]int{ {1092, 0, 102}, {}, {1092}, }, }, expected: Key{ Keycode: 'ф', BaseLayoutCode: 'f', Text: "ф", }, }, { name: "kitty: multiple codepoints", sequence: ansi.CSI{ Final: 'u', Parameters: [][]int{ {106}, {}, {127482, 127480}, }, }, expected: Key{ Keycode: 'j', Text: "🇺🇸", }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { act := decodeKey(test.sequence) assert.Equal(t, test.expected, act) }) } } golang-sourcehut-rockorager-vaxis-0.13.0/log/000077500000000000000000000000001476577054500211525ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/log/log.go000066400000000000000000000033761476577054500222730ustar00rootroot00000000000000package log import ( "fmt" "io" "log" "time" ) const ( LevelError int = iota LevelWarn LevelInfo LevelDebug LevelTrace calldepth = 3 flags = log.Lshortfile ) var ( level = LevelError traceLogger = log.New(io.Discard, "TRACE ", flags) debugLogger = log.New(io.Discard, "DEBUG ", flags) infoLogger = log.New(io.Discard, "INFO ", flags) warnLogger = log.New(io.Discard, "WARN ", flags) errorLogger = log.New(io.Discard, "ERROR ", flags) ) // LevelError = 0 // LevelWarn = 1 // LevelInfo = 2 // LevelDebug = 3 // LevelTrace = 4 func SetLevel(l int) { level = l } func SetOutput(w io.Writer) { traceLogger.SetOutput(w) debugLogger.SetOutput(w) infoLogger.SetOutput(w) warnLogger.SetOutput(w) errorLogger.SetOutput(w) } func now() string { return time.Now().Format("15:04:05.000") } func fmtMessage(message string, args ...any) string { if len(args) > 0 { message = fmt.Sprintf(message, args...) } return fmt.Sprintf("%s %s", now(), message) } func Trace(format string, args ...any) { if level < LevelTrace { return } message := fmtMessage(format, args...) traceLogger.Output(calldepth, message) } func Debug(format string, args ...any) { if level < LevelDebug { return } message := fmtMessage(format, args...) debugLogger.Output(calldepth, message) } func Info(format string, args ...any) { if level < LevelInfo { return } message := fmtMessage(format, args...) infoLogger.Output(calldepth, message) } func Warn(format string, args ...any) { if level < LevelWarn { return } message := fmtMessage(format, args...) warnLogger.Output(calldepth, message) } func Error(format string, args ...any) { if level < LevelError { return } message := fmtMessage(format, args...) errorLogger.Output(calldepth, message) } golang-sourcehut-rockorager-vaxis-0.13.0/mouse.go000066400000000000000000000045041476577054500220530ustar00rootroot00000000000000package vaxis import ( "git.sr.ht/~rockorager/vaxis/ansi" "git.sr.ht/~rockorager/vaxis/log" ) // Mouse is a mouse event type Mouse struct { Button MouseButton Row int Col int EventType EventType Modifiers ModifierMask } // MouseButton represents a mouse button type MouseButton int const ( MouseLeftButton MouseButton = iota MouseMiddleButton MouseRightButton MouseNoButton MouseWheelUp MouseButton = 64 MouseWheelDown MouseButton = 65 MouseButton8 MouseButton = 128 MouseButton9 MouseButton = 129 MouseButton10 MouseButton = 130 MouseButton11 MouseButton = 131 ) // MouseShape is used with OSC 22 to change the shape of the mouse cursor type MouseShape string const ( MouseShapeDefault MouseShape = "default" MouseShapeTextInput MouseShape = "text" MouseShapeClickable MouseShape = "pointer" MouseShapeHelp MouseShape = "help" MouseShapeBusyBackground MouseShape = "progress" MouseShapeBusy MouseShape = "wait" MouseShapeResizeHorizontal MouseShape = "ew-resize" MouseShapeResizeVertical MouseShape = "ns-resize" // The thick plus sign cursor that's typically used in spread-sheet applications to select cells. MouseShapeCell MouseShape = "cell" ) const ( motion = 0b00100000 buttonBits = 0b11000011 mouseModShift = 0b00000100 mouseModAlt = 0b00001000 mouseModCtrl = 0b00010000 ) func parseMouseEvent(seq ansi.CSI) (Mouse, bool) { mouse := Mouse{} if len(seq.Intermediate) != 1 && seq.Intermediate[0] != '<' { log.Error("[CSI] unknown sequence: %s", seq) return mouse, false } if len(seq.Parameters) != 3 { log.Error("[CSI] unknown sequence: %s", seq) return mouse, false } switch seq.Final { case 'M': mouse.EventType = EventPress case 'm': mouse.EventType = EventRelease } // buttons are encoded with the high two and low two bits button := seq.Parameters[0][0] & buttonBits mouse.Button = MouseButton(button) if seq.Parameters[0][0]&motion != 0 { mouse.EventType = EventMotion } if seq.Parameters[0][0]&mouseModShift != 0 { mouse.Modifiers |= ModShift } if seq.Parameters[0][0]&mouseModAlt != 0 { mouse.Modifiers |= ModAlt } if seq.Parameters[0][0]&mouseModCtrl != 0 { mouse.Modifiers |= ModCtrl } mouse.Col = seq.Parameters[1][0] - 1 mouse.Row = seq.Parameters[2][0] - 1 return mouse, true } golang-sourcehut-rockorager-vaxis-0.13.0/octreequant/000077500000000000000000000000001476577054500227235ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/octreequant/ooctreequant.go000066400000000000000000000105671476577054500257740ustar00rootroot00000000000000// Package octreequant implements an image quantizer, for transforming bitmap // images to palette images, before encoding them to SIXEL. // // This code was originally developed by delthas and taken from: https://github.com/delthas/octreequant package octreequant import ( "image" imagecolor "image/color" ) const maxDepth = 8 type color struct { r int g int b int a0 bool // true if transparent } func (c color) color() imagecolor.RGBA { return imagecolor.RGBA{ R: uint8(c.r), G: uint8(c.g), B: uint8(c.b), A: 255, } } func newColor(c imagecolor.Color) color { cr, cg, cb, ca := c.RGBA() var r, g, b int if ca == 0 { return color{ a0: true, } } if ca == 0xFFFF { r = int(cr) >> 8 g = int(cg) >> 8 b = int(cb) >> 8 } else { r = int(cr) * 255 / int(ca) g = int(cg) * 255 / int(ca) b = int(cb) * 255 / int(ca) } return color{ r: r, g: g, b: b, } } type node struct { c color n int i int children []*node } func (n *node) leaf() bool { return n.n > 0 } func (n *node) leafs() []*node { nodes := make([]*node, 0, 8) for _, n := range n.children { if n == nil { continue } if n.leaf() { nodes = append(nodes, n) } else { nodes = append(nodes, n.leafs()...) } } return nodes } func (n *node) addColor(color color, level int, parent *tree) { if level >= maxDepth { n.c.r += color.r n.c.g += color.g n.c.b += color.b n.n++ return } i := n.colorIndex(color, level) c := n.children[i] if c == nil { c = newNode(level, parent) n.children[i] = c } c.addColor(color, level+1, parent) } func (n *node) paletteIndex(color color, level int) int { if n.leaf() { return n.i } i := n.colorIndex(color, level) if c := n.children[i]; c != nil { return c.paletteIndex(color, level+1) } for _, n := range n.children { if n == nil { continue } return n.paletteIndex(color, level+1) } panic("unreachable") } func (n *node) removeLeaves() int { r := 0 for _, c := range n.children { if c == nil { continue } n.c.r += c.c.r n.c.g += c.c.g n.c.b += c.c.b n.n += c.n r += 1 } return r - 1 } func (n *node) colorIndex(color color, level int) int { i := 0 mask := 0x80 >> level if color.r&mask != 0 { i |= 4 } if color.g&mask != 0 { i |= 2 } if color.b&mask != 0 { i |= 1 } return i } func (n *node) color() color { return color{ r: n.c.r / n.n, g: n.c.g / n.n, b: n.c.b / n.n, } } func newNode(level int, parent *tree) *node { n := node{ children: make([]*node, 8), } if level < maxDepth-1 { parent.addNode(level, &n) } return &n } type tree struct { levels [][]*node root *node count int // size of palette a0 bool // true if any color is transparent } func (t *tree) leaves() []*node { return t.root.leafs() } func (t *tree) addNode(level int, n *node) { t.levels[level] = append(t.levels[level], n) } func (t *tree) addColor(color color) { t.a0 = t.a0 || color.a0 t.root.addColor(color, 0, t) } func (t *tree) makePalette(count int) imagecolor.Palette { palette := make(imagecolor.Palette, 0, count) i := 0 c := len(t.leaves()) if t.a0 { count-- } for level := maxDepth - 1; level >= 0; level-- { if len(t.levels[level]) == 0 { continue } for _, n := range t.levels[level] { c -= n.removeLeaves() if c <= count { break } } if c <= count { break } t.levels[level] = t.levels[level][:0] } for _, n := range t.leaves() { if i >= count { break } if n.leaf() { palette = append(palette, n.color().color()) } n.i = i i++ } if t.a0 { palette = append(palette, imagecolor.RGBA{}) } t.count = len(palette) return palette } func (t *tree) paletteIndex(color color) int { if color.a0 { return t.count-1 } return t.root.paletteIndex(color, 0) } func newTree() *tree { t := tree{ levels: make([][]*node, maxDepth), } t.root = newNode(0, &t) return &t } // Paletted quantizes an image and returns a paletted image, with // a palette up to the specified color count. func Paletted(img image.Image, colors int) *image.Paletted { w := img.Bounds().Dx() h := img.Bounds().Dy() t := newTree() for y := 0; y < h; y++ { for x := 0; x < w; x++ { t.addColor(newColor(img.At(x, y))) } } out := image.NewPaletted(img.Bounds(), t.makePalette(colors)) for y := 0; y < h; y++ { for x := 0; x < w; x++ { out.SetColorIndex(x, y, uint8(t.paletteIndex(newColor(img.At(x, y))))) } } return out } golang-sourcehut-rockorager-vaxis-0.13.0/quirks.go000066400000000000000000000023011476577054500222320ustar00rootroot00000000000000package vaxis import ( "os" "strings" "git.sr.ht/~rockorager/vaxis/log" ) func (vx *Vaxis) applyQuirks() { id := string(vx.termID) switch { case strings.HasPrefix(id, "kitty"): log.Debug("kitty identified. applying quirks") vx.caps.noZWJ = true case id == "tmux 3.4": // tmux 3.4 has unicode support, but doesn't advertise via 2027 vx.caps.unicodeCore = true } if os.Getenv("ASCIINEMA_REC") != "" { // Asciinema doesn't support any advanced image protocols vx.graphicsProtocol = halfBlock } if os.Getenv("VAXIS_FORCE_LEGACY_SGR") != "" { fgIndexSet = strings.ReplaceAll(fgIndexSet, ":", ";") fgRGBSet = strings.ReplaceAll(fgRGBSet, ":", ";") bgIndexSet = strings.ReplaceAll(bgIndexSet, ":", ";") bgRGBSet = strings.ReplaceAll(bgRGBSet, ":", ";") } if os.Getenv("VAXIS_FORCE_WCWIDTH") != "" { vx.caps.unicodeCore = false vx.caps.explicitWidth = false } if os.Getenv("VAXIS_FORCE_UNICODE") != "" { vx.caps.unicodeCore = true } if os.Getenv("VAXIS_FORCE_NOZWJ") != "" { vx.caps.noZWJ = true vx.caps.explicitWidth = false } if os.Getenv("VAXIS_DISABLE_NOZWJ") != "" { vx.caps.noZWJ = false } if os.Getenv("VAXIS_FORCE_XTWINOPS") != "" { vx.xtwinops = true } } golang-sourcehut-rockorager-vaxis-0.13.0/screen.go000066400000000000000000000015031476577054500221760ustar00rootroot00000000000000package vaxis type screen struct { buf [][]Cell rows int cols int } func newScreen() *screen { std := &screen{} return std } func (s *screen) size() (cols int, rows int) { return s.cols, s.rows } // resize resizes the stdsurface based on a SIGWINCH func (s *screen) resize(cols int, rows int) { s.buf = make([][]Cell, rows) for row := range s.buf { s.buf[row] = make([]Cell, cols) } s.rows = rows s.cols = cols } // Set a cell at col, row func (s *screen) setCell(col int, row int, text Cell) { if col < 0 || row < 0 { return } if col >= s.cols { return } if row >= s.rows { return } s.buf[row][col] = text } func (s *screen) setStyle(col int, row int, style Style) { if col < 0 || row < 0 { return } if col >= s.cols { return } if row >= s.rows { return } s.buf[row][col].Style = style } golang-sourcehut-rockorager-vaxis-0.13.0/sequences.go000066400000000000000000000065061476577054500227220ustar00rootroot00000000000000package vaxis import ( "fmt" ) const ( // Queries // Device Status Report - Cursor Position Report dsrcpr = "\x1b[6n" // Generic DSR dsr = "\x1b[?%dn" // Device primary attributes primaryAttributes = "\x1b[c" tertiaryAttributes = "\x1b[=c" // Device Status Report - XTVERSION xtversion = "\x1b[>0q" // kitty keyboard protocol kittyKBQuery = "\x1b[?u" kittyKBEnable = "\x1b[>%du" kittyKBPop = "\x1b[" to signal // non-contiguous links which are the same (IE when a link may be // wrapped on lines) HyperlinkParams string // Foreground is the color to apply to the foreground of this cell Foreground Color // Background is the color to apply to the background of this cell Background Color // UnderlineColor is the color to apply to the underline of this cell, // if supported UnderlineColor Color // UnderlineStyle is the type of underline to apply (single, double, // curly, etc). If a particular style is not supported, Vaxis will // fallback to single underlines UnderlineStyle UnderlineStyle // Attribute represents all other style information for this cell (bold, // dim, italic, etc) Attribute AttributeMask } // AttributeMask represents a bitmask of boolean attributes to style a cell type AttributeMask uint8 const ( AttrNone = 0 AttrBold AttributeMask = 1 << iota AttrDim AttrItalic AttrBlink AttrReverse AttrInvisible AttrStrikethrough ) // UnderlineStyle represents the style of underline to apply type UnderlineStyle uint8 const ( UnderlineOff UnderlineStyle = iota UnderlineSingle UnderlineDouble UnderlineCurly UnderlineDotted UnderlineDashed ) golang-sourcehut-rockorager-vaxis-0.13.0/styled_string.go000066400000000000000000000222151476577054500236140ustar00rootroot00000000000000package vaxis import ( "fmt" "strconv" "strings" "github.com/rivo/uniseg" ) const ( ssFgIndexSet = "\x1b[38:5:%dm" ssFgRGBSet = "\x1b[38:2:%d:%d:%dm" ssBgIndexSet = "\x1b[48:5:%dm" ssBgRGBSet = "\x1b[48:2:%d:%d:%dm" ) type StyledString struct { Cells []Cell } func (vx *Vaxis) NewStyledString(s string, defaultStyle Style) *StyledString { ss := &StyledString{ Cells: make([]Cell, 0, len(s)), } style := defaultStyle width := 0 grapheme := "" seq := "" for len(s) > 0 { switch { case strings.HasPrefix(s, "\x1b["): s = strings.TrimPrefix(s, "\x1b[") seq, s, _ = strings.Cut(s, "m") if s == "" { // we don't need to process this sequence since // we don't have anything after it return ss } if seq == "" { style = defaultStyle continue } params := strings.Split(seq, ";") for _, param := range params { subs := strings.Split(param, ":") switch subs[0] { case "0": style = defaultStyle case "1": style.Attribute |= AttrBold case "2": style.Attribute |= AttrDim case "3": style.Attribute |= AttrItalic case "4": if len(subs) > 1 { switch subs[1] { case "0": style.UnderlineStyle = UnderlineOff case "1": style.UnderlineStyle = UnderlineSingle case "2": style.UnderlineStyle = UnderlineDouble case "3": style.UnderlineStyle = UnderlineCurly case "4": style.UnderlineStyle = UnderlineDotted case "5": style.UnderlineStyle = UnderlineDashed } } else { style.UnderlineStyle = UnderlineSingle } case "5": style.Attribute |= AttrBlink case "7": style.Attribute |= AttrReverse case "8": style.Attribute |= AttrInvisible case "9": style.Attribute |= AttrStrikethrough case "22": style.Attribute &^= AttrBold style.Attribute &^= AttrDim case "23": style.Attribute &^= AttrItalic case "24": style.UnderlineStyle = UnderlineOff case "25": style.Attribute &^= AttrBlink case "27": style.Attribute &^= AttrReverse case "28": style.Attribute &^= AttrInvisible case "29": style.Attribute &^= AttrStrikethrough case "30": style.Foreground = IndexColor(0) case "31": style.Foreground = IndexColor(1) case "32": style.Foreground = IndexColor(2) case "33": style.Foreground = IndexColor(3) case "34": style.Foreground = IndexColor(4) case "35": style.Foreground = IndexColor(5) case "36": style.Foreground = IndexColor(6) case "37": style.Foreground = IndexColor(7) case "38": switch len(subs) { case 3: idx, _ := strconv.Atoi(subs[2]) style.Foreground = IndexColor(uint8(idx)) case 5: r, _ := strconv.Atoi(subs[2]) g, _ := strconv.Atoi(subs[3]) b, _ := strconv.Atoi(subs[4]) style.Foreground = RGBColor(uint8(r), uint8(g), uint8(b)) } case "39": style.Foreground = 0 case "40": style.Background = IndexColor(0) case "41": style.Background = IndexColor(1) case "42": style.Background = IndexColor(2) case "43": style.Background = IndexColor(3) case "44": style.Background = IndexColor(4) case "45": style.Background = IndexColor(5) case "46": style.Background = IndexColor(6) case "47": style.Background = IndexColor(7) case "48": switch len(subs) { case 3: idx, _ := strconv.Atoi(subs[2]) style.Background = IndexColor(uint8(idx)) case 5: r, _ := strconv.Atoi(subs[2]) g, _ := strconv.Atoi(subs[3]) b, _ := strconv.Atoi(subs[4]) style.Background = RGBColor(uint8(r), uint8(g), uint8(b)) } case "49": style.Background = 0 case "58": switch len(subs) { case 3: idx, _ := strconv.Atoi(subs[2]) style.UnderlineColor = IndexColor(uint8(idx)) case 5: r, _ := strconv.Atoi(subs[2]) g, _ := strconv.Atoi(subs[3]) b, _ := strconv.Atoi(subs[4]) style.UnderlineColor = RGBColor(uint8(r), uint8(g), uint8(b)) } case "90": style.Foreground = IndexColor(8) case "91": style.Foreground = IndexColor(9) case "92": style.Foreground = IndexColor(10) case "93": style.Foreground = IndexColor(11) case "94": style.Foreground = IndexColor(12) case "95": style.Foreground = IndexColor(13) case "96": style.Foreground = IndexColor(14) case "97": style.Foreground = IndexColor(15) case "100": style.Background = IndexColor(8) case "101": style.Background = IndexColor(9) case "102": style.Background = IndexColor(10) case "103": style.Background = IndexColor(11) case "104": style.Background = IndexColor(12) case "105": style.Background = IndexColor(13) case "106": style.Background = IndexColor(14) case "107": style.Background = IndexColor(15) } } default: grapheme, s, width, _ = uniseg.FirstGraphemeClusterInString(s, -1) switch { case vx.caps.unicodeCore || vx.caps.explicitWidth: // we're done case vx.caps.noZWJ: width = gwidth(grapheme, noZWJ) default: width = gwidth(grapheme, wcwidth) } ss.Cells = append(ss.Cells, Cell{ Character: Character{ Grapheme: grapheme, Width: width, }, Style: style, }) } } return ss } // Returns the rendered width of the styled string func (ss *StyledString) Len() int { total := 0 for _, ch := range ss.Cells { total += ch.Width } return total } func (ss *StyledString) Encode() string { bldr := &strings.Builder{} cursor := Style{} for _, next := range ss.Cells { if cursor.Foreground != next.Foreground { fg := next.Foreground ps := fg.Params() switch len(ps) { case 0: _, _ = bldr.WriteString(fgReset) case 1: switch { case ps[0] < 8: fmt.Fprintf(bldr, fgSet, ps[0]) case ps[0] < 16: fmt.Fprintf(bldr, fgBrightSet, ps[0]-8) default: fmt.Fprintf(bldr, ssFgIndexSet, ps[0]) } case 3: fmt.Fprintf(bldr, ssFgRGBSet, ps[0], ps[1], ps[2]) } } if cursor.Background != next.Background { bg := next.Background ps := bg.Params() switch len(ps) { case 0: _, _ = bldr.WriteString(bgReset) case 1: switch { case ps[0] < 8: fmt.Fprintf(bldr, bgSet, ps[0]) case ps[0] < 16: fmt.Fprintf(bldr, bgBrightSet, ps[0]-8) default: fmt.Fprintf(bldr, ssBgIndexSet, ps[0]) } case 3: fmt.Fprintf(bldr, ssBgRGBSet, ps[0], ps[1], ps[2]) } } if cursor.UnderlineColor != next.UnderlineColor { ul := next.UnderlineColor ps := ul.Params() switch len(ps) { case 0: _, _ = bldr.WriteString(ulColorReset) case 1: _, _ = fmt.Fprintf(bldr, ulIndexSet, ps[0]) case 3: _, _ = fmt.Fprintf(bldr, ulRGBSet, ps[0], ps[1], ps[2]) } } if cursor.Attribute != next.Attribute { attr := cursor.Attribute // find the ones that have changed dAttr := attr ^ next.Attribute // If the bit is changed and in next, it was // turned on on := dAttr & next.Attribute if on&AttrBold != 0 { _, _ = bldr.WriteString(boldSet) } if on&AttrDim != 0 { _, _ = bldr.WriteString(dimSet) } if on&AttrItalic != 0 { _, _ = bldr.WriteString(italicSet) } if on&AttrBlink != 0 { _, _ = bldr.WriteString(blinkSet) } if on&AttrReverse != 0 { _, _ = bldr.WriteString(reverseSet) } if on&AttrInvisible != 0 { _, _ = bldr.WriteString(hiddenSet) } if on&AttrStrikethrough != 0 { _, _ = bldr.WriteString(strikethroughSet) } // If the bit is changed and is in previous, it // was turned off off := dAttr & attr if off&AttrBold != 0 { // Normal intensity isn't in terminfo _, _ = bldr.WriteString(boldDimReset) // Normal intensity turns off dim. If it // should be on, let's turn it back on if next.Attribute&AttrDim != 0 { _, _ = bldr.WriteString(dimSet) } } if off&AttrDim != 0 { // Normal intensity isn't in terminfo _, _ = bldr.WriteString(boldDimReset) // Normal intensity turns off bold. If it // should be on, let's turn it back on if next.Attribute&AttrBold != 0 { _, _ = bldr.WriteString(boldSet) } } if off&AttrItalic != 0 { _, _ = bldr.WriteString(italicReset) } if off&AttrBlink != 0 { // turn off blink isn't in terminfo _, _ = bldr.WriteString(blinkReset) } if off&AttrReverse != 0 { _, _ = bldr.WriteString(reverseReset) } if off&AttrInvisible != 0 { // turn off invisible isn't in terminfo _, _ = bldr.WriteString(hiddenReset) } if off&AttrStrikethrough != 0 { _, _ = bldr.WriteString(strikethroughReset) } } if cursor.UnderlineStyle != next.UnderlineStyle { ulStyle := next.UnderlineStyle _, _ = bldr.WriteString(tparm(ulStyleSet, ulStyle)) } if cursor.Hyperlink != next.Hyperlink { link := next.Hyperlink linkPs := next.HyperlinkParams if link == "" { linkPs = "" } _, _ = bldr.WriteString(tparm(osc8, linkPs, link)) } cursor = next.Style bldr.WriteString(next.Grapheme) } empty := Style{} if cursor != empty { bldr.WriteString(sgrReset) } return bldr.String() } golang-sourcehut-rockorager-vaxis-0.13.0/text.go000066400000000000000000000002171476577054500217040ustar00rootroot00000000000000package vaxis // Segment represents text segment type Segment struct { // Text is the value of the text segment Text string Style Style } golang-sourcehut-rockorager-vaxis-0.13.0/text_test.go000066400000000000000000000006231476577054500227440ustar00rootroot00000000000000package vaxis_test import "git.sr.ht/~rockorager/vaxis" func ExampleSegment() { vx, _ := vaxis.New(vaxis.Options{}) c := vaxis.Cell{ Character: vaxis.Character{ Grapheme: "a", Width: 1, }, Style: vaxis.Style{ Foreground: vaxis.IndexColor(1), Attribute: vaxis.AttrBold | vaxis.AttrBlink, }, } // Fills the entire window with blinking, bold, red "a"s vx.Window().Fill(c) } golang-sourcehut-rockorager-vaxis-0.13.0/uniseg_bench_test.go000066400000000000000000000030011476577054500244020ustar00rootroot00000000000000package vaxis_test import ( "strings" "testing" "github.com/rivo/uniseg" ) func BenchmarkUniseg(b *testing.B) { const testString = "😀🔮🌏📝test string" b.Run("rune reader", func(b *testing.B) { // Just so we can see the penalty for i := 0; i < b.N; i += 1 { result := []string{} r := strings.NewReader(testString) for { ch, _, err := r.ReadRune() if err != nil { break } result = append(result, string(ch)) } } }) b.Run("graphemes", func(b *testing.B) { for i := 0; i < b.N; i += 1 { result := []string{} g := uniseg.NewGraphemes(testString) for g.Next() { result = append(result, g.Str()) } } }) b.Run("step", func(b *testing.B) { for i := 0; i < b.N; i += 1 { result := [][]byte{} in := []byte(testString) state := -1 cluster := []byte{} for len(in) > 0 { cluster, in, _, state = uniseg.Step(in, state) result = append(result, cluster) } } }) b.Run("stepstring", func(b *testing.B) { for i := 0; i < b.N; i += 1 { result := []string{} in := testString state := -1 cluster := "" for len(in) > 0 { cluster, in, _, state = uniseg.StepString(in, state) result = append(result, cluster) } } }) b.Run("firstfunction", func(b *testing.B) { for i := 0; i < b.N; i += 1 { result := []string{} in := testString state := -1 cluster := "" for len(in) > 0 { cluster, in, _, state = uniseg.FirstGraphemeClusterInString(in, state) result = append(result, cluster) } } }) } golang-sourcehut-rockorager-vaxis-0.13.0/vaxis.go000066400000000000000000001265611476577054500220650ustar00rootroot00000000000000// Package vaxis is a terminal user interface for modern terminals package vaxis import ( "bytes" "context" "encoding/base64" "fmt" "io" "os" "os/signal" "strings" "sync" "sync/atomic" "time" "github.com/containerd/console" "git.sr.ht/~rockorager/vaxis/ansi" "git.sr.ht/~rockorager/vaxis/log" ) type capabilities struct { synchronizedUpdate bool unicodeCore bool noZWJ bool // a terminal may support shaped emoji but not ZWJ rgb bool kittyGraphics bool kittyKeyboard bool styledUnderlines bool sixels bool colorThemeUpdates bool reportSizeChars bool reportSizePixels bool osc4 bool osc10 bool osc11 bool osc176 bool inBandResize bool explicitWidth bool } type cursorState struct { row int col int style CursorStyle visible bool } // Options are the runtime options which must be supplied to a new [Vaxis] // object at instantiation type Options struct { // DisableKittyKeyboard disables the use of the Kitty Keyboard protocol. // By default, if support is detected the protocol will be used. DisableKittyKeyboard bool // Deprecated Use CSIuBitMask instead // // ReportKeyboardEvents will report key release and key repeat events if // KittyKeyboardProtocol is enabled and supported by the terminal ReportKeyboardEvents bool // The size of the event queue channel. This will default to 1024 to // prevent any blocking on writes. EventQueueSize int // Disable mouse events DisableMouse bool // WithTTY passes an absolute path to use for the TTY Vaxis will draw // on. If the file is not a TTY, an error will be returned when calling // New WithTTY string // NoSignals causes Vaxis to not install any signal handlers NoSignals bool // CSIuBitMask is the bit mask to use for CSIu key encoding, when // available. This has no effect if DisableKittyKeyboard is true CSIuBitMask CSIuBitMask // WithConsole provides the ability to use a custom console. WithConsole console.Console } type CSIuBitMask int const ( CSIuDisambiguate CSIuBitMask = 1 << iota CSIuReportEvents CSIuAlternateKeys CSIuAllKeys CSIuAssociatedText ) type Vaxis struct { queue chan Event console console.Console parser *ansi.Parser tw *writer screenNext *screen screenLast *screen graphicsNext []*placement graphicsLast []*placement mouseShapeNext MouseShape mouseShapeLast MouseShape appIDLast appID pastePending bool chClipboard chan string chSigWinSz chan os.Signal chSigKill chan os.Signal chCursorPos chan [2]int chQuit chan bool winSize Resize nextSize Resize chSizeDone chan bool caps capabilities graphicsProtocol int graphicsIDNext uint64 reqCursorPos int32 charCache map[string]int cursorNext cursorState cursorLast cursorState closed bool refresh bool kittyFlags int disableMouse bool chFg chan string chBg chan string chColor chan string userCursorStyle CursorStyle xtwinops bool withTty string withConsole console.Console termID terminalID renders int elapsed time.Duration mu sync.Mutex resize int32 } // New creates a new [Vaxis] instance. Calling New will query the underlying // terminal for supported features and enter the alternate screen func New(opts Options) (*Vaxis, error) { switch os.Getenv("VAXIS_LOG_LEVEL") { case "trace": log.SetLevel(log.LevelTrace) log.SetOutput(os.Stderr) case "debug": log.SetLevel(log.LevelDebug) log.SetOutput(os.Stderr) case "info": log.SetLevel(log.LevelInfo) log.SetOutput(os.Stderr) case "warn": log.SetLevel(log.LevelWarn) log.SetOutput(os.Stderr) case "error": log.SetLevel(log.LevelError) log.SetOutput(os.Stderr) } // Let's give some deadline for our queries responding. If they don't, // it means the terminal doesn't respond to Primary Device Attributes // and that is a problem ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) defer cancel() var err error vx := &Vaxis{ kittyFlags: int(CSIuDisambiguate), } if opts.CSIuBitMask > CSIuDisambiguate { vx.kittyFlags = int(opts.CSIuBitMask) } if opts.ReportKeyboardEvents { vx.kittyFlags |= int(CSIuReportEvents) } if opts.EventQueueSize < 1 { opts.EventQueueSize = 1024 } if opts.DisableMouse { vx.disableMouse = true } var tgts []*os.File switch { case opts.WithConsole != nil: vx.withConsole = opts.WithConsole case opts.WithTTY != "": vx.withTty = opts.WithTTY f, err := os.OpenFile(opts.WithTTY, os.O_RDWR, 0) if err != nil { return nil, err } tgts = []*os.File{f} default: f, err := os.OpenFile("/dev/tty", os.O_RDWR, 0) if err != nil { tgts = []*os.File{os.Stderr, os.Stdout, os.Stdin} break } tgts = []*os.File{f, os.Stderr, os.Stdout, os.Stdin} } vx.queue = make(chan Event, opts.EventQueueSize) vx.screenNext = newScreen() vx.screenLast = newScreen() vx.chClipboard = make(chan string) vx.chSigWinSz = make(chan os.Signal, 1) vx.chSigKill = make(chan os.Signal, 1) vx.chCursorPos = make(chan [2]int) vx.chQuit = make(chan bool) vx.chSizeDone = make(chan bool, 1) vx.charCache = make(map[string]int, 256) vx.chFg = make(chan string, 1) vx.chBg = make(chan string, 1) vx.chColor = make(chan string, 1) err = vx.openTty(tgts) if err != nil { return nil, err } vx.sendQueries() outer: for { select { case <-ctx.Done(): log.Warn("terminal did not respond to DA1 query") break outer case ev := <-vx.queue: switch ev := ev.(type) { case primaryDeviceAttribute: break outer case capabilitySixel: log.Info("[capability] Sixel graphics") vx.mu.Lock() vx.caps.sixels = true if vx.graphicsProtocol < sixelGraphics { vx.graphicsProtocol = sixelGraphics } vx.mu.Unlock() case capabilityOsc4: log.Info("[capability] OSC 4 supported") vx.mu.Lock() vx.caps.osc4 = true vx.mu.Unlock() case capabilityOsc10: log.Info("[capability] OSC 10 supported") vx.mu.Lock() vx.caps.osc10 = true vx.mu.Unlock() case capabilityOsc11: log.Info("[capability] OSC 11 supported") vx.mu.Lock() vx.caps.osc11 = true vx.mu.Unlock() case synchronizedUpdates: log.Info("[capability] Synchronized updates") vx.mu.Lock() vx.caps.synchronizedUpdate = true vx.mu.Unlock() case unicodeCoreCap: log.Info("[capability] Unicode core") vx.mu.Lock() vx.caps.unicodeCore = true vx.mu.Unlock() case notifyColorChange: log.Info("[capability] Color theme notifications") vx.mu.Lock() vx.caps.colorThemeUpdates = true vx.mu.Unlock() case kittyKeyboard: log.Info("[capability] Kitty keyboard") if opts.DisableKittyKeyboard { continue } vx.mu.Lock() vx.caps.kittyKeyboard = true vx.mu.Unlock() case styledUnderlines: log.Info("[capability] Styled underlines") vx.mu.Lock() vx.caps.styledUnderlines = true vx.mu.Unlock() case truecolor: log.Info("[capability] RGB") vx.mu.Lock() vx.caps.rgb = true vx.mu.Unlock() case kittyGraphics: log.Info("[capability] Kitty graphics supported") vx.mu.Lock() vx.caps.kittyGraphics = true if vx.graphicsProtocol < kitty { vx.graphicsProtocol = kitty } vx.mu.Unlock() case textAreaPix: log.Info("[capability] Report screen size: pixels") vx.mu.Lock() vx.caps.reportSizePixels = true vx.mu.Unlock() case textAreaChar: log.Info("[capability] Report screen size: characters") vx.mu.Lock() vx.caps.reportSizeChars = true vx.mu.Unlock() case appID: log.Info("[capability] OSC 176 supported") vx.mu.Lock() vx.caps.osc176 = true vx.appIDLast = ev vx.mu.Unlock() case terminalID: vx.mu.Lock() vx.termID = ev vx.mu.Unlock() case inBandResizeEvents: vx.mu.Lock() vx.caps.inBandResize = true vx.mu.Unlock() } } } vx.enterAltScreen() vx.enableModes() if !opts.NoSignals { vx.setupSignals() } vx.applyQuirks() switch os.Getenv("VAXIS_GRAPHICS") { case "none": vx.graphicsProtocol = noGraphics case "full": vx.graphicsProtocol = fullBlock case "half": vx.graphicsProtocol = halfBlock case "sixel": vx.graphicsProtocol = sixelGraphics case "kitty": vx.graphicsProtocol = kitty default: // Use highest quality block renderer by default. Users will // need to fallback on their own if not supported if vx.graphicsProtocol < halfBlock { vx.graphicsProtocol = halfBlock } } ws, err := vx.reportWinsize() if err != nil { return nil, err } if ws.XPixel == 0 || ws.YPixel == 0 { log.Debug("pixel size not reported, setting graphics protocol to half block") vx.graphicsProtocol = halfBlock } vx.screenNext.resize(ws.Cols, ws.Rows) vx.screenLast.resize(ws.Cols, ws.Rows) vx.winSize = ws // Set the next style to be a CursorBlock by default. vx.cursorNext.style = CursorBlock vx.PostEvent(vx.winSize) return vx, nil } // PostEvent inserts an event into the [Vaxis] event loop func (vx *Vaxis) PostEvent(ev Event) { log.Debug("[event] %#v", ev) select { case vx.queue <- ev: return default: log.Warn("Event dropped: %T", ev) } } // PostEventBlocking inserts an event into the [Vaxis] event loop. The call will // block if the queue is full. This method should only be used from a different // goroutine than the main thread. func (vx *Vaxis) PostEventBlocking(ev Event) { vx.queue <- ev } // SyncFunc queues a function to be called from the main thread. vaxis will call // the function when the event is received in the main thread either through // PollEvent or Events. A Redraw event will be sent to the host application // after the function is completed func (vx *Vaxis) SyncFunc(fn func()) { vx.PostEvent(SyncFunc(fn)) } // PollEvent blocks until there is an Event, and returns that Event func (vx *Vaxis) PollEvent() Event { ev, ok := <-vx.queue if !ok { return QuitEvent{} } return ev } // Events returns the channel of events. func (vx *Vaxis) Events() chan Event { return vx.queue } // Close shuts down the event loops and returns the terminal to it's original // state func (vx *Vaxis) Close() { if vx.closed { return } vx.PostEvent(QuitEvent{}) vx.closed = true defer close(vx.chQuit) vx.Suspend() vx.console.Close() log.Info("Renders: %d", vx.renders) if vx.renders != 0 { log.Info("Time/render: %s", vx.elapsed/time.Duration(vx.renders)) } log.Info("Cached characters: %d", len(vx.charCache)) } // Resize manually triggers a resize event. Normally, vaxis listens to SIGWINCH // for resize events, however in some use cases a manual resize trigger may be // needed func (vx *Vaxis) Resize() { atomicStore(&vx.resize, true) vx.PostEvent(Redraw{}) } // Render renders the model's content to the terminal func (vx *Vaxis) Render() { if atomicLoad(&vx.resize) { defer atomicStore(&vx.resize, false) ws, err := vx.reportWinsize() if err != nil { log.Error("couldn't report winsize: %v", err) return } if ws.Cols != vx.winSize.Cols || ws.Rows != vx.winSize.Rows { vx.screenNext.resize(ws.Cols, ws.Rows) vx.screenLast.resize(ws.Cols, ws.Rows) vx.winSize = ws vx.refresh = true vx.PostEvent(vx.winSize) return } } start := time.Now() // defer renderBuf.Reset() vx.render() _, _ = vx.tw.Flush() // updating cursor state has to be after Flush, we check state change in // flush. vx.cursorLast = vx.cursorNext vx.elapsed += time.Since(start) vx.renders += 1 vx.refresh = false } // Refresh forces a full render of the entire screen. Traditionally, this should // be bound to Ctrl+l func (vx *Vaxis) Refresh() { vx.refresh = true vx.Render() } func (vx *Vaxis) render() { vx.mu.Lock() defer vx.mu.Unlock() var ( reposition = true cursor Style ) outerLast: // Delete any placements we don't have this round for _, p1 := range vx.graphicsLast { // Delete all previous placements on a refresh if vx.refresh { p1.deleteFn(vx.tw) continue } for _, p2 := range vx.graphicsNext { if samePlacement(p1, p2) { continue outerLast } } p1.deleteFn(vx.tw) } if vx.refresh { vx.graphicsLast = []*placement{} } outerNew: // draw new placements for _, p1 := range vx.graphicsNext { for _, p2 := range vx.graphicsLast { if samePlacement(p1, p2) { // don't write existing placements continue outerNew } } _, _ = vx.tw.WriteString(tparm(cup, p1.row+1, p1.col+1)) p1.writeTo(vx.tw) } // Save this frame as the last frame vx.graphicsLast = vx.graphicsNext if vx.mouseShapeLast != vx.mouseShapeNext { _, _ = vx.tw.WriteString(tparm(mouseShape, vx.mouseShapeNext)) vx.mouseShapeLast = vx.mouseShapeNext } for row := range vx.screenNext.buf { reposition = true for col := 0; col < len(vx.screenNext.buf[row]); col += 1 { next := vx.screenNext.buf[row][col] if next.sixel { vx.screenLast.buf[row][col].sixel = true reposition = true continue } if next == vx.screenLast.buf[row][col] && !vx.refresh { reposition = true // Advance the column by the width of this // character skip := vx.advance(next) // skip := advance(next.Content) for i := 1; i < skip+1; i += 1 { if col+i >= len(vx.screenNext.buf[row]) { break } // null out any cells we end up skipping vx.screenLast.buf[row][col+i] = Cell{} } col += skip continue } vx.screenLast.buf[row][col] = next if reposition { if cursor.Hyperlink != "" { _, _ = vx.tw.WriteString(tparm(osc8, "", "")) } _, _ = vx.tw.WriteString(tparm(cup, row+1, col+1)) reposition = false } // TODO Optimizations // 1. We could save two bytes when both FG and BG change // by combining into a single sequence. It saves one // "\x1b" and one "m". It adds a lot of complexity // though // // 2. We could save some more bytes when FG, BG, and Attr // all change. Lots of complexity to add this if cursor.Foreground != next.Foreground { fg := next.Foreground ps := fg.Params() if !vx.caps.rgb { ps = fg.asIndex().Params() } switch len(ps) { case 0: _, _ = vx.tw.WriteString(fgReset) case 1: switch { case ps[0] < 8: vx.tw.Printf(fgSet, ps[0]) case ps[0] < 16: vx.tw.Printf(fgBrightSet, ps[0]-8) default: vx.tw.Printf(fgIndexSet, ps[0]) } case 3: vx.tw.Printf(fgRGBSet, ps[0], ps[1], ps[2]) } } if cursor.Background != next.Background { bg := next.Background ps := bg.Params() if !vx.caps.rgb { ps = bg.asIndex().Params() } switch len(ps) { case 0: _, _ = vx.tw.WriteString(bgReset) case 1: switch { case ps[0] < 8: vx.tw.Printf(bgSet, ps[0]) case ps[0] < 16: vx.tw.Printf(bgBrightSet, ps[0]-8) default: vx.tw.Printf(bgIndexSet, ps[0]) } case 3: vx.tw.Printf(bgRGBSet, ps[0], ps[1], ps[2]) } } if vx.caps.styledUnderlines { if cursor.UnderlineColor != next.UnderlineColor { ul := next.UnderlineColor ps := ul.Params() if !vx.caps.rgb { ps = ul.asIndex().Params() } switch len(ps) { case 0: _, _ = vx.tw.WriteString(ulColorReset) case 1: _, _ = vx.tw.Printf(ulIndexSet, ps[0]) case 3: _, _ = vx.tw.Printf(ulRGBSet, ps[0], ps[1], ps[2]) } } } if cursor.Attribute != next.Attribute { attr := cursor.Attribute // find the ones that have changed dAttr := attr ^ next.Attribute // If the bit is changed and in next, it was // turned on on := dAttr & next.Attribute if on&AttrBold != 0 { _, _ = vx.tw.WriteString(boldSet) } if on&AttrDim != 0 { _, _ = vx.tw.WriteString(dimSet) } if on&AttrItalic != 0 { _, _ = vx.tw.WriteString(italicSet) } if on&AttrBlink != 0 { _, _ = vx.tw.WriteString(blinkSet) } if on&AttrReverse != 0 { _, _ = vx.tw.WriteString(reverseSet) } if on&AttrInvisible != 0 { _, _ = vx.tw.WriteString(hiddenSet) } if on&AttrStrikethrough != 0 { _, _ = vx.tw.WriteString(strikethroughSet) } // If the bit is changed and is in previous, it // was turned off off := dAttr & attr if off&AttrBold != 0 { // Normal intensity isn't in terminfo _, _ = vx.tw.WriteString(boldDimReset) // Normal intensity turns off dim. If it // should be on, let's turn it back on if next.Attribute&AttrDim != 0 { _, _ = vx.tw.WriteString(dimSet) } } if off&AttrDim != 0 { // Normal intensity isn't in terminfo _, _ = vx.tw.WriteString(boldDimReset) // Normal intensity turns off bold. If it // should be on, let's turn it back on if next.Attribute&AttrBold != 0 { _, _ = vx.tw.WriteString(boldSet) } } if off&AttrItalic != 0 { _, _ = vx.tw.WriteString(italicReset) } if off&AttrBlink != 0 { // turn off blink isn't in terminfo _, _ = vx.tw.WriteString(blinkReset) } if off&AttrReverse != 0 { _, _ = vx.tw.WriteString(reverseReset) } if off&AttrInvisible != 0 { // turn off invisible isn't in terminfo _, _ = vx.tw.WriteString(hiddenReset) } if off&AttrStrikethrough != 0 { _, _ = vx.tw.WriteString(strikethroughReset) } } if cursor.UnderlineStyle != next.UnderlineStyle { ulStyle := next.UnderlineStyle switch vx.caps.styledUnderlines { case true: _, _ = vx.tw.WriteString(tparm(ulStyleSet, ulStyle)) case false: switch ulStyle { case UnderlineOff: _, _ = vx.tw.WriteString(underlineReset) default: // Fallback to single underlines _, _ = vx.tw.WriteString(underlineSet) } } } if cursor.Hyperlink != next.Hyperlink { link := next.Hyperlink linkPs := next.HyperlinkParams if link == "" { linkPs = "" } _, _ = vx.tw.WriteString(tparm(osc8, linkPs, link)) } cursor = next.Style if next.Width == 0 { next.Width = vx.characterWidth(next.Grapheme) } switch { case next.Width == 0: _, _ = vx.tw.WriteString(" ") case next.Width > 1 && vx.caps.explicitWidth: _, _ = fmt.Fprintf(vx.tw, explicitWidth, next.Width, next.Grapheme) default: _, _ = vx.tw.WriteString(next.Grapheme) } skip := vx.advance(next) for i := 1; i < skip+1; i += 1 { if col+i >= len(vx.screenNext.buf[row]) { break } // null out any cells we end up skipping vx.screenLast.buf[row][col+i] = Cell{} } col += skip } } if cursor.Hyperlink != "" { _, _ = vx.tw.WriteString(tparm(osc8, "", "")) } if vx.cursorNext.visible && !vx.cursorLast.visible { _, _ = vx.tw.WriteString(vx.showCursor()) } } func (vx *Vaxis) handleSequence(seq ansi.Sequence) { log.Trace("[stdin] sequence: %s", seq) switch seq := seq.(type) { case ansi.Print: key := decodeKey(seq) if vx.pastePending { key.EventType = EventPaste } vx.PostEventBlocking(key) case ansi.C0: key := decodeKey(seq) if vx.pastePending { key.EventType = EventPaste } vx.PostEventBlocking(key) case ansi.ESC: key := decodeKey(seq) if vx.pastePending { key.EventType = EventPaste } vx.PostEventBlocking(key) case ansi.SS3: key := decodeKey(seq) if vx.pastePending { key.EventType = EventPaste } vx.PostEventBlocking(key) case ansi.CSI: switch seq.Final { case 'c': if len(seq.Intermediate) == 1 && seq.Intermediate[0] == '?' { for _, ps := range seq.Parameters { switch ps[0] { case 4: vx.PostEventBlocking(capabilitySixel{}) } } vx.PostEventBlocking(primaryDeviceAttribute{}) return } case 'I': vx.PostEventBlocking(FocusIn{}) return case 'O': vx.PostEventBlocking(FocusOut{}) return case 'R': // KeyF1 or DSRCPR // This could be an F1 key, we need to buffer if we have // requested a DSRCPR (cursor position report) // // Kitty keyboard protocol disambiguates this scenario, // hopefully people are using that if atomicLoad(&vx.reqCursorPos) { atomicStore(&vx.reqCursorPos, false) if len(seq.Parameters) != 2 { log.Error("not enough DSRCPR params") return } vx.chCursorPos <- [2]int{ seq.Parameters[0][0], seq.Parameters[1][0], } return } case 'S': if len(seq.Intermediate) == 1 && seq.Intermediate[0] == '?' { if len(seq.Parameters) < 3 { break } switch seq.Parameters[0][0] { case 2: if seq.Parameters[1][0] == 0 { vx.PostEventBlocking(capabilitySixel{}) } } return } case 'n': if len(seq.Intermediate) == 1 && seq.Intermediate[0] == '?' { if len(seq.Parameters) != 2 { break } switch seq.Parameters[0][0] { case colorThemeResp: // 997 m := ColorThemeMode(seq.Parameters[1][0]) vx.PostEventBlocking(ColorThemeUpdate{ Mode: m, }) } return } case 'y': // DECRPM - DEC Report Mode if len(seq.Parameters) < 1 { log.Error("not enough DECRPM params") return } switch seq.Parameters[0][0] { case 2026: if len(seq.Parameters) < 2 { log.Error("not enough DECRPM params") return } switch seq.Parameters[1][0] { case 1, 2: vx.PostEventBlocking(synchronizedUpdates{}) } case 2027: if len(seq.Parameters) < 2 { log.Error("not enough DECRPM params") return } switch seq.Parameters[1][0] { case 1, 2: vx.PostEventBlocking(unicodeCoreCap{}) } case 2031: if len(seq.Parameters) < 2 { log.Error("not enough DECRPM params") return } switch seq.Parameters[1][0] { case 1, 2: vx.PostEventBlocking(notifyColorChange{}) } } return case 'u': if len(seq.Intermediate) == 1 && seq.Intermediate[0] == '?' { vx.PostEventBlocking(kittyKeyboard{}) return } case '~': if len(seq.Intermediate) == 0 { if len(seq.Parameters) == 0 { log.Error("[CSI] unknown sequence with final '~'") return } switch seq.Parameters[0][0] { case 200: vx.pastePending = true vx.PostEventBlocking(PasteStartEvent{}) return case 201: vx.pastePending = false vx.PostEventBlocking(PasteEndEvent{}) return } } case 'M', 'm': mouse, ok := parseMouseEvent(seq) if ok { vx.PostEventBlocking(mouse) } return case 't': if len(seq.Parameters) < 3 { log.Error("[CSI] unknown sequence: %s", seq) return } // CSI ; ; t typ := seq.Parameters[0][0] h := seq.Parameters[1][0] w := seq.Parameters[2][0] switch typ { case 4: vx.mu.Lock() vx.nextSize.XPixel = w vx.nextSize.YPixel = h report := vx.caps.reportSizePixels vx.mu.Unlock() if !report { // Gate on this so we only report this // once at startup vx.PostEventBlocking(textAreaPix{}) return } case 8: vx.mu.Lock() vx.nextSize.Cols = w vx.nextSize.Rows = h report := vx.caps.reportSizeChars vx.mu.Unlock() if !report { // Gate on this so we only report this // once at startup. This also means we // can set the size directly and won't // have race conditions vx.PostEventBlocking(textAreaChar{}) return } vx.chSizeDone <- true case 48: // CSI ; ; ; ; t switch len(seq.Parameters) { case 5: atomicStore(&vx.resize, true) vx.mu.Lock() vx.nextSize.Cols = w vx.nextSize.Rows = h vx.nextSize.YPixel = seq.Parameters[3][0] vx.nextSize.XPixel = seq.Parameters[4][0] resize := vx.caps.inBandResize vx.mu.Unlock() if !resize { vx.PostEventBlocking(inBandResizeEvents{}) } vx.Resize() } } return } key := decodeKey(seq) if vx.pastePending { key.EventType = EventPaste } vx.PostEventBlocking(key) case ansi.DCS: switch seq.Final { case 'r': if len(seq.Intermediate) < 1 { return } switch seq.Intermediate[0] { case '+': // XTGETTCAP response if len(seq.Parameters) < 1 { return } if seq.Parameters[0] == 0 { return } vals := strings.Split(string(seq.Data), "=") if len(vals) != 2 { log.Error("error parsing XTGETTCAP: %s", string(seq.Data)) } switch vals[0] { case hexEncode("Smulx"): vx.PostEventBlocking(styledUnderlines{}) case hexEncode("RGB"): vx.PostEventBlocking(truecolor{}) } case '$': // DECRQSS response (DECRPSS) // DECSCUSR (user cursor style) if strings.HasSuffix(string(seq.Data), " q") { // Convert the rune into a digit cursorStyle := seq.Data[0] // Valid cursor styles are 0-6 if cursorStyle < '0' || cursorStyle > '6' { log.Warn("invalid DECSCUSR: %d", cursorStyle) return } log.Debug("User cursor style discovered: %v", CursorStyle(cursorStyle-0x30)) vx.mu.Lock() vx.userCursorStyle = CursorStyle(cursorStyle - 0x30) vx.mu.Unlock() } } case '|': if len(seq.Intermediate) < 1 { return } switch seq.Intermediate[0] { case '!': if string(seq.Data) == hexEncode("~VTE") { // VTE supports styled underlines but // doesn't respond to XTGETTCAP vx.PostEventBlocking(styledUnderlines{}) } case '>': vx.PostEventBlocking(terminalID(seq.Data)) } } case ansi.APC: if len(seq.Data) == 0 { return } if strings.HasPrefix(seq.Data, "G") { vx.PostEventBlocking(kittyGraphics{}) } case ansi.OSC: if strings.HasPrefix(string(seq.Payload), "4") { // If we are here and don't know that the host terminal // supports the sequence, it means we are handling the response // to the initial query and thus nobody is expecting its actual // content. In this case, we don't want to fill the channel buffer // as no one will clear it. if vx.CanReportColor() { vx.chColor <- string(seq.Payload) } vx.PostEventBlocking(capabilityOsc4{}) } if strings.HasPrefix(string(seq.Payload), "10") { // Similar to OSC 4 if vx.CanReportForegroundColor() { vx.chFg <- string(seq.Payload) } vx.PostEventBlocking(capabilityOsc10{}) } if strings.HasPrefix(string(seq.Payload), "11") { // Similar to OSC 4 if vx.CanReportBackgroundColor() { vx.chBg <- string(seq.Payload) } vx.PostEventBlocking(capabilityOsc11{}) } if strings.HasPrefix(string(seq.Payload), "52") { vals := strings.Split(string(seq.Payload), ";") if len(vals) != 3 { log.Error("invalid OSC 52 payload") return } b, err := base64.StdEncoding.DecodeString(vals[2]) if err != nil { log.Error("couldn't decode OSC 52: %v", err) return } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) defer cancel() select { case vx.chClipboard <- string(b): case <-ctx.Done(): } } if strings.HasPrefix(string(seq.Payload), "176") { vals := strings.Split(string(seq.Payload), ";") if len(vals) != 2 { log.Error("invalid OSC 176 payload") return } vx.PostEvent(appID(vals[1])) } } } // QueryColor queries the host terminal for an indexed color and returns // it as an instance of an RGB vaxis.Color. If the host terminal doesn't // support this, Color(0) is returned instead. Make sure not to run this // in the same goroutine as Vaxis runs in or deadlock will occur. func (vx *Vaxis) QueryColor(c Color) Color { if !vx.CanReportColor() { return Color(0) } p := c.Params() if len(p) == 3 { // If an RGB color was passed, return it as is. return c } if len(p) != 1 { return Color(0) } vx.tw.WriteStringLocked(tparm(osc4, p[0])) resp := <-vx.chColor var r, g, b int prefix := fmt.Sprintf("4;%v;", p[0]) _, err := fmt.Sscanf(resp, prefix+"rgb:%x/%x/%x", &r, &g, &b) if err != nil { log.Error("QueryColor: failed to parse the OSC 4 response: %s", err) return Color(0) } // The returned value can in principle be 16 bits per channel, however // we are not aware of any terminal that would do this, foot for // instance just repeats the same 8 bits twice. Hence we only take the // lower 8 bits. return RGBColor(uint8(r), uint8(g), uint8(b)) } // QueryForeground queries the host terminal for foreground color and returns // it as an instance of vaxis.Color. If the host terminal doesn't support this, // Color(0) is returned instead. Make sure not to run this in the same // goroutine as Vaxis runs in or deadlock will occur. func (vx *Vaxis) QueryForeground() Color { if !vx.CanReportForegroundColor() { return Color(0) } vx.tw.WriteStringLocked(osc10) resp := <-vx.chFg var r, g, b int _, err := fmt.Sscanf(resp, "10;rgb:%x/%x/%x", &r, &g, &b) if err != nil { log.Error("QueryForeground: failed to parse the OSC 10 response: %s", err) return Color(0) } // Similar to QueryColor above. return RGBColor(uint8(r), uint8(g), uint8(b)) } // QueryBackground queries the host terminal for background color and returns // it as an instance of vaxis.Color. If the host terminal doesn't support this, // Color(0) is returned instead. Make sure not to run this in the same // goroutine as Vaxis runs in or deadlock will occur. func (vx *Vaxis) QueryBackground() Color { if !vx.CanReportBackgroundColor() { return Color(0) } vx.tw.WriteStringLocked(osc11) resp := <-vx.chBg var r, g, b int _, err := fmt.Sscanf(resp, "11;rgb:%x/%x/%x", &r, &g, &b) if err != nil { log.Error("QueryBackground: failed to parse the OSC 11 response: %s", err) return Color(0) } // Similar to QueryColor above. return RGBColor(uint8(r), uint8(g), uint8(b)) } func (vx *Vaxis) sendQueries() { // always query in the alt screen so a terminal who doesn't understand // this doesn't get messed up. We are in full control of the alt screen vx.enterAltScreen() defer vx.exitAltScreen() switch os.Getenv("COLORTERM") { case "truecolor", "24bit": vx.PostEvent(truecolor{}) } _, _ = vx.tw.WriteString(userCursorStyle) _, _ = vx.tw.WriteString(decrqm(synchronizedUpdate)) _, _ = vx.tw.WriteString(decrqm(unicodeCore)) _, _ = vx.tw.WriteString(decrqm(colorThemeUpdates)) // We blindly enable in band resize. We get a response immediately if it // is supported _, _ = vx.tw.WriteString(decset(inBandResize)) _, _ = vx.tw.WriteString(xtversion) _, _ = vx.tw.WriteString(kittyKBQuery) _, _ = vx.tw.WriteString(kittyGquery) _, _ = vx.tw.WriteString(xtsmSixelGeom) // Can the terminal report its own size? _, _ = vx.tw.WriteString(textAreaSize) // Explicit width query _, _ = vx.tw.WriteString("\x1b[H") _, _ = fmt.Fprintf(vx.tw, explicitWidth, 1, " ") _, col := vx.CursorPosition() if col == 1 { log.Debug("[capability] explicit width supported") vx.mu.Lock() vx.caps.explicitWidth = true vx.mu.Unlock() } // Query some terminfo capabilities // Just another way to see if we have RGB support _, _ = vx.tw.WriteString(xtgettcap("RGB")) // Does the terminal respond to OSC 4/10/11 queries? _, _ = vx.tw.WriteString(tparm(osc4, 1)) _, _ = vx.tw.WriteString(osc10) _, _ = vx.tw.WriteString(osc11) // Back up the current app ID _, _ = vx.tw.WriteString(getAppID) // We request Smulx to check for styled underlines. Technically, Smulx // only means the terminal supports different underline types (curly, // dashed, etc), but we'll assume the terminal also suppports underline // colors (CSI 58 : ...) _, _ = vx.tw.WriteString(xtgettcap("Smulx")) // Need to send tertiary for VTE based terminals. These don't respond to // XTGETTCAP _, _ = vx.tw.WriteString(tertiaryAttributes) // Send Device Attributes is last. Everything responds, and when we get // a response we'll return from init _, _ = vx.tw.WriteString(primaryAttributes) _, _ = vx.tw.Flush() } // enableModes enables all the modes we want func (vx *Vaxis) enableModes() { // kitty keyboard if vx.caps.kittyKeyboard { _, _ = vx.tw.WriteString(tparm(kittyKBEnable, vx.kittyFlags)) } // sixel scrolling if vx.caps.sixels { _, _ = vx.tw.WriteString(decset(sixelScrolling)) } // Mode 2027, unicode segmentation (for correct emoji/wc widths). We // only enable if we don't also have explicitWidth if vx.caps.unicodeCore && !vx.caps.explicitWidth { _, _ = vx.tw.WriteString(decset(unicodeCore)) } // Mode 2031: color scheme updates if vx.caps.colorThemeUpdates { _, _ = vx.tw.WriteString(decset(colorThemeUpdates)) // Let's query the current mode also _, _ = vx.tw.WriteString(tparm(dsr, colorThemeReq)) } if vx.caps.inBandResize { _, _ = vx.tw.WriteString(decset(inBandResize)) } // TODO: query for bracketed paste support? _, _ = vx.tw.WriteString(decset(bracketedPaste)) // bracketed paste _, _ = vx.tw.WriteString(decset(cursorKeys)) // application cursor keys _, _ = vx.tw.WriteString(applicationMode) // application cursor keys mode // TODO: Query for mouse modes or just hope for the best? In the // meantime, we enable button events, then all events. Terminals which // support both will enable the latter. Terminals which support only the // first will enable button events, then ignore the all events mode. if !vx.disableMouse { _, _ = vx.tw.WriteString(decset(mouseButtonEvents)) _, _ = vx.tw.WriteString(decset(mouseAllEvents)) _, _ = vx.tw.WriteString(decset(mouseFocusEvents)) _, _ = vx.tw.WriteString(decset(mouseSGR)) } _, _ = vx.tw.Flush() } func (vx *Vaxis) disableModes() { _, _ = vx.tw.WriteString(sgrReset) // reset fg, bg, attrs _, _ = vx.tw.WriteString(decrst(bracketedPaste)) // bracketed paste if vx.caps.kittyKeyboard { _, _ = vx.tw.WriteString(kittyKBPop) // kitty keyboard } _, _ = vx.tw.WriteString(decrst(cursorKeys)) _, _ = vx.tw.WriteString(numericMode) if !vx.disableMouse { _, _ = vx.tw.WriteString(decrst(mouseButtonEvents)) _, _ = vx.tw.WriteString(decrst(mouseAllEvents)) _, _ = vx.tw.WriteString(decrst(mouseFocusEvents)) _, _ = vx.tw.WriteString(decrst(mouseSGR)) } if vx.caps.sixels { _, _ = vx.tw.WriteString(decrst(sixelScrolling)) } if vx.caps.unicodeCore && !vx.caps.explicitWidth { _, _ = vx.tw.WriteString(decrst(unicodeCore)) } if vx.caps.colorThemeUpdates { _, _ = vx.tw.WriteString(decrst(colorThemeUpdates)) } if vx.caps.osc176 { _, _ = vx.tw.WriteString(tparm(setAppID, vx.appIDLast)) } if vx.caps.inBandResize { _, _ = vx.tw.WriteString(decrst(inBandResize)) } // Most terminals default to "text" mouse shape _, _ = vx.tw.WriteString(tparm(mouseShape, MouseShapeTextInput)) _, _ = vx.tw.Flush() } func (vx *Vaxis) enterAltScreen() { vx.tw.vx.refresh = true _, _ = vx.tw.WriteString(decset(alternateScreen)) _, _ = vx.tw.WriteString(decrst(cursorVisibility)) _, _ = vx.tw.Flush() } func (vx *Vaxis) exitAltScreen() { vx.HideCursor() _, _ = vx.tw.WriteString(decset(cursorVisibility)) _, _ = vx.tw.WriteString(clear) _, _ = vx.tw.WriteString(decrst(alternateScreen)) _, _ = vx.tw.Flush() } // Suspend takes Vaxis out of fullscreen state, disables all terminal modes, // stops listening for signals, and returns the terminal to it's original state. // Suspend can be useful to, for example, drop out of the full screen TUI and // run another TUI. The state of vaxis will be retained, so you can reenter the // original state by calling Resume func (vx *Vaxis) Suspend() error { // HACK: The parser could be hanging for input. Because we have a handle // on a real terminal, we can't "actually" close the FD, so the poll // doesn't necessarily wake on the close call. However, we are the only // one reading from it...so we have to do a little dance: // 1. Send a signal that we want to close // 2. Send a DA1 query so there is data on the reader, breaking the read // loop // 3. Confirm we have closed vx.parser.Close() io.WriteString(vx.console, primaryAttributes) vx.parser.WaitClose() vx.disableModes() vx.exitAltScreen() // Reset to user value, or CursorDefault. _, _ = vx.tw.WriteString(tparm(cursorStyleSet, int(vx.userCursorStyle))) // Always show the cursor on exit _, _ = vx.tw.WriteString(decset(cursorVisibility)) // Reset internal state to match reality vx.cursorLast.style = vx.userCursorStyle vx.tw.vx.tw.Flush() signal.Stop(vx.chSigKill) signal.Stop(vx.chSigWinSz) vx.console.Reset() return nil } // openTty opens the /dev/tty device, makes it raw, and starts an input parser func (vx *Vaxis) openTty(tgts []*os.File) error { if vx.withConsole != nil { vx.console = vx.withConsole } else { for _, s := range tgts { if c, err := console.ConsoleFromFile(s); err == nil { vx.console = c break } } } if vx.console == nil { return console.ErrNotAConsole } err := vx.console.SetRaw() if err != nil { return err } vx.tw = newWriter(vx) vx.parser = ansi.NewParser(vx.console) go func() { defer func() { if err := recover(); err != nil { vx.Close() panic(err) } }() for { select { case seq := <-vx.parser.Next(): switch seq := seq.(type) { case ansi.EOF: return default: vx.handleSequence(seq) vx.parser.Finish(seq) } case <-vx.chSigWinSz: atomicStore(&vx.resize, true) vx.PostEventBlocking(Redraw{}) case <-vx.chSigKill: vx.Close() return } } }() return nil } // Resume returns the application to it's fullscreen state, re-enters raw mode, // and reenables input parsing. Upon resuming, a Resize event will be delivered. // It is entirely possible the terminal was resized while suspended. func (vx *Vaxis) Resume() error { var tgts []*os.File if vx.withConsole == nil { tgts = []*os.File{os.Stderr, os.Stdout, os.Stdin} if vx.withTty != "" { f, err := os.OpenFile(vx.withTty, os.O_RDWR, 0) if err != nil { return err } tgts = []*os.File{f} } } err := vx.openTty(tgts) if err != nil { return err } vx.enterAltScreen() vx.enableModes() vx.setupSignals() atomicStore(&vx.resize, true) return nil } // HideCursor hides the hardware cursor func (vx *Vaxis) HideCursor() { vx.cursorNext.visible = false } // ShowCursor shows the cursor at the given colxrow, with the given style. The // passed column and row are 0-indexed and global. To show the cursor relative // to a window, use [Window.ShowCursor] func (vx *Vaxis) ShowCursor(col int, row int, style CursorStyle) { vx.cursorNext.style = style vx.cursorNext.col = col vx.cursorNext.row = row vx.cursorNext.visible = true } func (vx *Vaxis) showCursor() string { buf := bytes.NewBuffer(nil) buf.WriteString(vx.cursorStyle()) buf.WriteString(tparm(cup, vx.cursorNext.row+1, vx.cursorNext.col+1)) buf.WriteString(decset(cursorVisibility)) return buf.String() } // Reports the current cursor position. 0,0 is the upper left corner. Reports // -1,-1 if the query times out or fails func (vx *Vaxis) CursorPosition() (row int, col int) { // DSRCPR - reports cursor position atomicStore(&vx.reqCursorPos, true) _, _ = io.WriteString(vx.console, dsrcpr) timeout := time.NewTimer(50 * time.Millisecond) select { case <-timeout.C: log.Warn("CursorPosition timed out") atomicStore(&vx.reqCursorPos, false) return -1, -1 case pos := <-vx.chCursorPos: return pos[0] - 1, pos[1] - 1 } } // CursorStyle is the style to display the hardware cursor type CursorStyle int const ( CursorDefault = iota CursorBlockBlinking CursorBlock CursorUnderlineBlinking CursorUnderline CursorBeamBlinking CursorBeam ) func (vx *Vaxis) cursorStyle() string { return tparm(cursorStyleSet, int(vx.cursorNext.style)) } // ClipboardPush copies the provided string to the system clipboard func (vx *Vaxis) ClipboardPush(s string) { b64 := base64.StdEncoding.EncodeToString([]byte(s)) _, _ = io.WriteString(vx.console, tparm(osc52put, b64)) } // ClipboardPop requests the content from the system clipboard. ClipboardPop works by // requesting the data from the underlying terminal, which responds back with // the data. Depending on usage, this could take some time. Callers can provide // a context to set a deadline for this function to return. An error will be // returned if the context is cancelled. func (vx *Vaxis) ClipboardPop(ctx context.Context) (string, error) { _, _ = io.WriteString(vx.console, osc52pop) select { case str := <-vx.chClipboard: return str, nil case <-ctx.Done(): return "", ctx.Err() } } // Notify (attempts) to send a system notification. If title is the empty // string, OSC9 will be used - otherwise osc777 is used func (vx *Vaxis) Notify(title string, body string) { if title == "" { _, _ = io.WriteString(vx.console, tparm(osc9notify, body)) return } _, _ = io.WriteString(vx.console, tparm(osc777notify, title, body)) } // SetTitle sets the terminal's title via OSC 2 func (vx *Vaxis) SetTitle(s string) { _, _ = io.WriteString(vx.console, tparm(setTitle, s)) } // SetAppID sets the terminal's application ID via OSC 176 func (vx *Vaxis) SetAppID(s string) { _, _ = io.WriteString(vx.console, tparm(setAppID, s)) } // Bell sends a BEL control signal to the terminal func (vx *Vaxis) Bell() { _, _ = vx.console.Write([]byte{0x07}) } // advance returns the extra amount to advance the column by when rendering func (vx *Vaxis) advance(cell Cell) int { if cell.Width == 0 { cell.Width = vx.characterWidth(cell.Grapheme) } // TODO: use max(cell.Width-1, 0) when >go1.19 w := cell.Width - 1 if w < 0 { return 0 } return w } // RenderedWidth returns the rendered width of the provided string. The result // is dependent on if your terminal can support unicode properly. // // This is best effort. It will usually be correct, and in the few cases it's // wrong will end up wrong in the nicer-rendering way (complex emojis will have // extra space after them. This is preferable to messing up the internal model) // // This call can be expensive, callers should consider caching the result for // strings or characters which will need to be measured frequently func (vx *Vaxis) RenderedWidth(s string) int { if vx.caps.unicodeCore || vx.caps.explicitWidth { return gwidth(s, unicodeStd) } if vx.caps.noZWJ { log.Debug("nozwj") return gwidth(s, noZWJ) } return gwidth(s, wcwidth) } // characterWidth measures the width of a grapheme cluster, caching the result . // We only ever call this with characters, making it highly cacheable since // there is likely to only ever be a finite set of characters in the lifetime of // an application func (vx *Vaxis) characterWidth(s string) int { w, ok := vx.charCache[s] if ok { return w } w = vx.RenderedWidth(s) vx.charCache[s] = w return w } // SetMouseShape sets the shape of the mouse func (vx *Vaxis) SetMouseShape(shape MouseShape) { vx.mouseShapeNext = shape } // TerminalID returns the terminal name and version advertised by the terminal, // if supported. The actual format is implementation-defined, but it is safe to // assume that the ID will start with the terminal name. // Some examples: "foot(1.17.2)"; "WezTerm 20210502-154244-3f7122cb" func (vx *Vaxis) TerminalID() string { return string(vx.termID) } func (vx *Vaxis) CanRGB() bool { return vx.caps.rgb } func (vx *Vaxis) CanKittyGraphics() bool { return vx.caps.kittyGraphics } func (vx *Vaxis) CanSixel() bool { return vx.caps.sixels } func (vx *Vaxis) CanReportColor() bool { return vx.caps.osc4 } func (vx *Vaxis) CanReportForegroundColor() bool { return vx.caps.osc10 } func (vx *Vaxis) CanReportBackgroundColor() bool { return vx.caps.osc11 } func (vx *Vaxis) CanDisplayGraphics() bool { return vx.caps.sixels || vx.caps.kittyGraphics } func (vx *Vaxis) CanSetAppID() bool { return vx.caps.osc176 } func (vx *Vaxis) CanUnicodeCore() bool { return vx.caps.unicodeCore } func (vx *Vaxis) CanExplicitWidth() bool { return vx.caps.explicitWidth } func (vx *Vaxis) nextGraphicID() uint64 { vx.graphicsIDNext += 1 return vx.graphicsIDNext } func atomicLoad(val *int32) bool { return atomic.LoadInt32(val) == 1 } func atomicStore(addr *int32, val bool) { if val { atomic.StoreInt32(addr, 1) } else { atomic.StoreInt32(addr, 0) } } golang-sourcehut-rockorager-vaxis-0.13.0/vaxis_unix.go000066400000000000000000000030661476577054500231220ustar00rootroot00000000000000//go:build darwin || freebsd || linux || netbsd || openbsd || zos package vaxis import ( "fmt" "io" "os/signal" "syscall" "time" "git.sr.ht/~rockorager/vaxis/log" "golang.org/x/sys/unix" ) func (vx *Vaxis) setupSignals() { if !vx.caps.inBandResize { signal.Notify(vx.chSigWinSz, syscall.SIGWINCH, ) } signal.Notify(vx.chSigKill, // kill signals syscall.SIGABRT, syscall.SIGBUS, syscall.SIGFPE, syscall.SIGILL, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGSEGV, syscall.SIGTERM, ) } // reportWinsize func (vx *Vaxis) reportWinsize() (Resize, error) { if vx.caps.inBandResize { // We already received the size if we have in band reports vx.mu.Lock() defer vx.mu.Unlock() return vx.nextSize, nil } if vx.xtwinops && vx.caps.reportSizeChars && vx.caps.reportSizePixels { log.Trace("requesting screen size from terminal") io.WriteString(vx.console, textAreaSize) deadline := time.NewTimer(100 * time.Millisecond) select { case <-deadline.C: return Resize{}, fmt.Errorf("screen size request deadline exceeded") case <-vx.chSizeDone: vx.mu.Lock() defer vx.mu.Unlock() return vx.nextSize, nil } } log.Trace("requesting screen size from ioctl") ws, err := unix.IoctlGetWinsize(int(vx.console.Fd()), unix.TIOCGWINSZ) if err != nil { cws, err := vx.console.Size() if err == nil { return Resize{ Cols: int(cws.Width), Rows: int(cws.Height), }, nil } return Resize{}, err } return Resize{ Cols: int(ws.Col), Rows: int(ws.Row), XPixel: int(ws.Xpixel), YPixel: int(ws.Ypixel), }, nil } golang-sourcehut-rockorager-vaxis-0.13.0/vaxis_windows.go000066400000000000000000000026261476577054500236320ustar00rootroot00000000000000//go:build windows package vaxis import ( "fmt" "io" "os/signal" "syscall" "time" "git.sr.ht/~rockorager/vaxis/log" ) func (vx *Vaxis) setupSignals() { signal.Notify(vx.chSigKill, // kill signals syscall.SIGABRT, syscall.SIGBUS, syscall.SIGFPE, syscall.SIGILL, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGSEGV, syscall.SIGTERM, ) // TODO: Use ReadConsoleInput for events?? go vx.winch() } func (vx *Vaxis) winch() { ticker := time.NewTicker(100 * time.Millisecond) for { <-ticker.C if atomicLoad(&vx.resize) { continue } ws, err := vx.reportWinsize() if err != nil { log.Error("couldn't report winsize", "error", err) return } if ws.Cols != vx.winSize.Cols || ws.Rows != vx.winSize.Rows { atomicStore(&vx.resize, true) vx.PostEvent(Redraw{}) } } } func (vx *Vaxis) reportWinsize() (Resize, error) { if vx.caps.reportSizeChars && vx.caps.reportSizePixels { log.Trace("requesting screen size from terminal") io.WriteString(vx.console, textAreaSize) deadline := time.NewTimer(100 * time.Millisecond) select { case <-deadline.C: return Resize{}, fmt.Errorf("screen size request deadline exceeded") case <-vx.chSizeDone: return vx.nextSize, nil } } log.Trace("requesting screen size from console") ws, err := vx.console.Size() if err != nil { return Resize{}, err } return Resize{ Cols: int(ws.Width), Rows: int(ws.Height), }, nil } golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/000077500000000000000000000000001476577054500213635ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/button/000077500000000000000000000000001476577054500226765ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/button/button.go000066400000000000000000000055021476577054500245420ustar00rootroot00000000000000package button import ( "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/vxfw" "git.sr.ht/~rockorager/vaxis/vxfw/center" "git.sr.ht/~rockorager/vaxis/vxfw/text" ) type Button struct { Label string Style StyleSet OnClick func() (vxfw.Command, error) mouseDown bool hover bool focused bool } type StyleSet struct { Default vaxis.Style MouseDown vaxis.Style Hover vaxis.Style Focus vaxis.Style } func New(label string, onClick func() (vxfw.Command, error)) *Button { ss := StyleSet{ Default: vaxis.Style{ Attribute: vaxis.AttrReverse, }, MouseDown: vaxis.Style{ Foreground: vaxis.IndexColor(4), Attribute: vaxis.AttrReverse, }, Hover: vaxis.Style{ Foreground: vaxis.IndexColor(3), Attribute: vaxis.AttrReverse, }, Focus: vaxis.Style{ Foreground: vaxis.IndexColor(5), Attribute: vaxis.AttrReverse, }, } return &Button{ Label: label, Style: ss, OnClick: onClick, } } func (b *Button) HandleEvent(ev vaxis.Event, ph vxfw.EventPhase) (vxfw.Command, error) { switch ev := ev.(type) { case vaxis.Key: if ev.EventType == vaxis.EventRelease { return nil, nil } if ev.Matches(vaxis.KeyEnter) { return b.OnClick() } case vaxis.Mouse: b.hover = true if b.mouseDown && ev.EventType == vaxis.EventRelease { b.mouseDown = false return b.OnClick() } if ev.EventType == vaxis.EventPress && ev.Button == vaxis.MouseLeftButton { b.mouseDown = true return vxfw.ConsumeAndRedraw(), nil } case vxfw.MouseEnter: b.hover = true cmd := []vxfw.Command{ vxfw.SetMouseShapeCmd(vaxis.MouseShapeClickable), vxfw.RedrawCmd{}, vxfw.ConsumeEventCmd{}, } return cmd, nil case vxfw.MouseLeave: b.hover = false b.mouseDown = false cmd := []vxfw.Command{ vxfw.SetMouseShapeCmd(vaxis.MouseShapeDefault), vxfw.RedrawCmd{}, vxfw.ConsumeEventCmd{}, } return cmd, nil case vaxis.FocusIn: b.focused = true return vxfw.ConsumeAndRedraw(), nil case vaxis.FocusOut: b.focused = false b.mouseDown = false return vxfw.ConsumeAndRedraw(), nil } return nil, nil } func (b *Button) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { if ctx.Max.HasUnboundedHeight() || ctx.Max.HasUnboundedWidth() { panic("Button must have bounded constraints") } var style vaxis.Style switch { case b.mouseDown: style = b.Style.MouseDown case b.hover: style = b.Style.Hover case b.focused: style = b.Style.Focus default: style = b.Style.Default } l := text.New(b.Label) l.Style = style center := center.Center{Child: l} s, err := center.Draw(ctx) if err != nil { return vxfw.Surface{}, err } // Rewrite the widget of this surface. We don't really care about the // Center widget anyways, it's just for layout s.Widget = b s.Fill(style) return s, nil } // Verify we meet the Widget interface var _ vxfw.Widget = &Button{} golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/center/000077500000000000000000000000001476577054500226435ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/center/center.go000066400000000000000000000017201476577054500244520ustar00rootroot00000000000000package center import ( "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/vxfw" ) // Center draws the child centered within the space given to Center type Center struct { Child vxfw.Widget } func (c *Center) HandleEvent(ev vaxis.Event, ph vxfw.EventPhase) (vxfw.Command, error) { return nil, nil } func (c *Center) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { if ctx.Max.HasUnboundedHeight() || ctx.Max.HasUnboundedWidth() { panic("Center must have bounded constraints") } chCtx := vxfw.DrawContext{ Max: ctx.Max, Characters: ctx.Characters, } chS, err := c.Child.Draw(chCtx) if err != nil { return vxfw.Surface{}, nil } // Create the surface for center s := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, c) offX := (ctx.Max.Width - chS.Size.Width) / 2 offY := (ctx.Max.Height - chS.Size.Height) / 2 s.AddChild(int(offX), int(offY), chS) return s, err } // Verify we meet the Widget interface var _ vxfw.Widget = &Center{} golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/list/000077500000000000000000000000001476577054500223365ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/list/list.go000066400000000000000000000221161476577054500236420ustar00rootroot00000000000000package list import ( "math" "golang.org/x/exp/slices" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/vxfw" ) // BuilderFunc is a function which takes type BuilderFunc func(i uint, cursor uint) vxfw.Widget // Dynamic is a dynamic list. It does not contain a slice of Widgets, but // instead obtains them from a BuilderFunc type Dynamic struct { // Builder is the function Dynamic calls to get a widget at a location Builder BuilderFunc // DrawCursor indicates if the view should draw it's own cursor DrawCursor bool // DisableEventHandlers prevents the widget from handling key or mouse // events. Set this to true to use custom event handlers DisableEventHandlers bool // Distance between each list item Gap int cursor uint scroll scroll } // scroll state type scroll struct { // index of the top widget in the viewport top uint // line offset within the top widget. This is the number of lines // scrolled into the widget. A widget of height=4 with 1 row showing // would have this set to -3 offset int // pending is the pending scroll amount pending int // wantsCursor is true if we need to ensure the cursor is in view wantsCursor bool } func (d *Dynamic) SetCursor(c uint) { d.cursor = c d.ensureScroll() } // SetPendingScroll sets a pending scroll amount, by lines. Positive numbers // indicate a scroll down func (d *Dynamic) SetPendingScroll(lines int) { d.scroll.pending = lines } func (d *Dynamic) CaptureEvent(ev vaxis.Event) (vxfw.Command, error) { if d.DisableEventHandlers { return nil, nil } // We capture key events switch ev := ev.(type) { case vaxis.Key: if ev.Matches('j') || ev.Matches(vaxis.KeyDown) { cmd := d.NextItem() if cmd == nil { return nil, nil } return vxfw.ConsumeAndRedraw(), nil } if ev.Matches('k') || ev.Matches(vaxis.KeyUp) { cmd := d.PrevItem() if cmd == nil { return nil, nil } return vxfw.ConsumeAndRedraw(), nil } } return nil, nil } // Cursor returns the index of the cursor func (d *Dynamic) Cursor() uint { return d.cursor } func (d *Dynamic) HandleEvent(ev vaxis.Event, ph vxfw.EventPhase) (vxfw.Command, error) { if d.DisableEventHandlers { return nil, nil } switch ev := ev.(type) { case vaxis.Mouse: switch ev.Button { case vaxis.MouseWheelDown: d.scroll.pending += 3 return vxfw.ConsumeAndRedraw(), nil case vaxis.MouseWheelUp: if d.scroll.offset > 0 && d.scroll.top > 0 { // We can only scroll up if we are at the top d.scroll.pending -= 3 return vxfw.ConsumeAndRedraw(), nil } } } return nil, nil } func (d *Dynamic) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { if ctx.Max.HasUnboundedHeight() || ctx.Max.HasUnboundedWidth() { panic("Dynamic cannot have unbounded height or width") } s := vxfw.NewSurface(ctx.Max.Width, ctx.Max.Height, d) // Accumulated height is the accumulated height we have drawn. We // initialize it to the scroll offset + any pending scroll we have. We // negate it so that it is lines *above* the viewport. // ah := -(d.scroll.offset + d.scroll.pending) // Now we can reset pending d.scroll.pending = 0 // When ah > 0 and we are on the top widget, it means we have an upward // scroll which consumed all of the scroll offset. We can't go up any // more so we set some state here if ah > 0 && d.scroll.top == 0 { ah = 0 d.scroll.offset = 0 } // Set initial index for drawing downard. We set this before inserting // children because we might alter the top state based on accumulated // height i := d.scroll.top // When ah > 0, we will start drawing our previous "top" widget at a // positive offset. We need to insert widgets before it so we will do so // here if ah > 0 { err := d.insertChildren(ctx, &s, ah) if err != nil { return s, err } // Get the last child so we can set our accumulated height last := s.Children[len(s.Children)-1] ah = last.Origin.Row + int(last.Surface.Size.Height) } var colOffset int if d.DrawCursor { colOffset = 2 } // Loop through widgets to draw for { // Get the widget at this index ch := d.Builder(i, d.cursor) // If we don't have one, we are done if ch == nil { break } // Increment the index i += 1 // Set up constraints chCtx := vxfw.DrawContext{ Max: vxfw.Size{ Width: ctx.Max.Width - uint16(colOffset), Height: math.MaxUint16, }, Characters: ctx.Characters, } // Draw the child chS, err := ch.Draw(chCtx) if err != nil { return s, err } // Add it to the parent. The accumulated height is the current // row we are drawing on s.AddChild(colOffset, ah, chS) // Add our height to accumulated height ah += int(chS.Size.Height) + d.Gap // If we need to draw the cursor, keep going if d.scroll.wantsCursor && i <= d.cursor { continue } // If we have accumulated enough height, we are done if ah >= int(ctx.Max.Height) { break } } var totalHeight uint16 for _, ch := range s.Children { totalHeight += ch.Surface.Size.Height } if d.Gap > 0 && len(s.Children) > 1 { // Add gap for between each child totalHeight += uint16((len(s.Children) - 1) * d.Gap) } if d.DrawCursor { var row uint16 // Set the entire gutter to a blank cell for ; row < s.Size.Height; row += 1 { s.WriteCell(0, row, vaxis.Cell{ Character: vaxis.Character{ Grapheme: " ", Width: 1, }, }) s.WriteCell(1, row, vaxis.Cell{ Character: vaxis.Character{ Grapheme: " ", Width: 1, }, }) } // Get the index of the cursored widget in our child list idx := d.cursor - d.scroll.top // If our cursor is within the list, we draw a cursor next to it if int(idx) < len(s.Children) { ch := s.Children[idx] // Create a surface for the cursor cur := vxfw.NewSurface(ctx.Max.Width, ch.Surface.Size.Height, ch.Surface.Widget) // Draw the cursor glyph var curRow uint16 for ; curRow < ch.Surface.Size.Height; curRow += 1 { cur.WriteCell(0, curRow, vaxis.Cell{ Character: vaxis.Character{ Grapheme: "▐", Width: 1, }, }) } // Add the cursored widget as a child of the cursor // surface cur.AddChild(colOffset, 0, ch.Surface) ss := vxfw.NewSubSurface(0, ch.Origin.Row, cur) // Reassign the SubSurface as this one s.Children[idx] = ss } } // If we want the cursor, we check that the cursored widget is in view. // We position it so that it is fully in view, and if it is too large // then the top portion of it is in view if d.scroll.wantsCursor { idx := d.cursor - d.scroll.top // Guaranteed we have drawn enough children from above if int(idx) < len(s.Children) { ch := s.Children[idx] // Define the bottom row bRow := ch.Origin.Row + int(ch.Surface.Size.Height) // The bottom row is beyond the height, adjust all the children // so that the bottom of the cursored widget is at the bottom of // the screen if bRow > int(ctx.Max.Height) { adj := int(ctx.Max.Height) - bRow for i, ch := range s.Children { ch.Origin.Row += adj s.Children[i] = ch } } d.scroll.wantsCursor = false } } // Reset origins and state based on actual draw for i, ch := range s.Children { if ch.Origin.Row <= 0 && ch.Origin.Row+int(ch.Surface.Size.Height) > 0 { d.scroll.top += uint(i) d.scroll.offset = -ch.Origin.Row } } return s, nil } // Inserts children until h < 0 func (d *Dynamic) insertChildren(ctx vxfw.DrawContext, p *vxfw.Surface, ah int) error { // We'll start at the widget before the top widget d.scroll.top -= 1 var colOffset int if d.DrawCursor { colOffset = 2 } for ah > 0 { chCtx := vxfw.DrawContext{ Max: vxfw.Size{ Width: ctx.Max.Width - uint16(colOffset), Height: math.MaxUint16, }, Characters: ctx.Characters, } ch := d.Builder(d.scroll.top, d.cursor) // Break if we don't have a widget, really this should never // happen if ch == nil { break } s, err := ch.Draw(chCtx) if err != nil { return err } // Subtract the height of this surface and add it to the parent ah -= int(s.Size.Height) ss := vxfw.NewSubSurface(colOffset, ah, s) p.Children = slices.Insert(p.Children, 0, ss) if d.scroll.top == 0 { break } // Decrease the top widget index d.scroll.top -= 1 } // Our ah is now the offset into the top widget d.scroll.offset = ah // We reached the top widget but are below row 0. Reset the if d.scroll.top == 0 && ah > 0 { d.scroll.offset = 0 var row uint16 for i, ch := range p.Children { ch.Origin.Row = int(row) p.Children[i] = ch row += ch.Surface.Size.Height } return nil } return nil } func (d *Dynamic) NextItem() vxfw.Command { // Check if we have another item w := d.Builder(d.cursor+1, d.cursor) if w == nil { return nil } d.cursor += 1 d.ensureScroll() return vxfw.RedrawCmd{} } func (d *Dynamic) PrevItem() vxfw.Command { if d.cursor == 0 { return nil } w := d.Builder(d.cursor-1, d.cursor) if w == nil { return nil } d.cursor -= 1 d.ensureScroll() return vxfw.RedrawCmd{} } func (d *Dynamic) ensureScroll() { if d.cursor > d.scroll.top { d.scroll.wantsCursor = true return } d.scroll.top = d.cursor d.scroll.offset = 0 } var _ vxfw.Widget = &Dynamic{} golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/richtext/000077500000000000000000000000001476577054500232155ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/richtext/richtext.go000066400000000000000000000163121476577054500254010ustar00rootroot00000000000000package richtext import ( "unicode" "unicode/utf8" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/vxfw" "github.com/rivo/uniseg" ) type RichText struct { // The content of the Text widget Content []vaxis.Segment // Whether to softwrap the text or not Softwrap bool } func New(segments []vaxis.Segment) *RichText { return &RichText{ Content: segments, Softwrap: true, } } // Noop for text func (t *RichText) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) { return nil, nil } func (t *RichText) cells(ctx vxfw.DrawContext) []vaxis.Cell { cells := []vaxis.Cell{} for _, seg := range t.Content { for _, char := range ctx.Characters(seg.Text) { cell := vaxis.Cell{ Character: char, Style: seg.Style, } cells = append(cells, cell) } } return cells } func (t *RichText) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { if t.Softwrap { return t.drawSoftwrap(ctx) } cells := t.cells(ctx) size := t.findContainerSize(cells, ctx) s := vxfw.NewSurface(size.Width, size.Height, t) scanner := NewHardwrapScanner(cells) var row uint16 for scanner.Scan() { var col uint16 if row > ctx.Max.Height { return s, nil } chars := scanner.Line() cols: for i, char := range chars { if col >= ctx.Max.Width { break } // If this char would get us to or beyond the max width // (and we aren't the last char), then we print an // ellipse if col+uint16(char.Width) >= ctx.Max.Width && i < len(chars) { cell := vaxis.Cell{ Character: vaxis.Character{ Grapheme: "…", Width: 1, }, Style: char.Style, } s.WriteCell(col, row, cell) break cols } else { s.WriteCell(col, row, char) col += uint16(char.Width) } } row += 1 } return s, nil } func (t *RichText) drawSoftwrap(ctx vxfw.DrawContext) (vxfw.Surface, error) { cells := t.cells(ctx) size := t.findContainerSize(cells, ctx) s := vxfw.NewSurface(size.Width, size.Height, t) scanner := NewSoftwrapScanner(cells, ctx.Max.Width) var row uint16 for scanner.Scan() { var col uint16 if row > ctx.Max.Height { return s, nil } chars := scanner.Text() for _, char := range chars { // We should never get here because we softwrapped, but // we check just in case if col >= ctx.Max.Width { break } s.WriteCell(col, row, char) col += uint16(char.Width) } row += 1 } return s, nil } func (t *RichText) findContainerSize(cells []vaxis.Cell, ctx vxfw.DrawContext) vxfw.Size { var size vxfw.Size if t.Softwrap { scanner := NewSoftwrapScanner(cells, ctx.Max.Width) for scanner.Scan() { if size.Height > ctx.Max.Height { return size } size.Height += 1 chars := scanner.Text() var w uint16 for _, char := range chars { w += uint16(char.Width) } // Size is limited to the Max.Width if size.Width < w { size.Width = w } if size.Width > ctx.Max.Width { size.Width = ctx.Max.Width } } return size } scanner := NewHardwrapScanner(cells) for scanner.Scan() { if size.Height > ctx.Max.Height { return size } size.Height += 1 chars := scanner.Line() var w uint16 for _, char := range chars { w += uint16(char.Width) } // Size is limited to the Max.Width if size.Width < w { size.Width = w } if size.Width > ctx.Max.Width { size.Width = ctx.Max.Width } } return size } type SoftwrapScanner struct { rest []vaxis.Cell token []vaxis.Cell width uint16 } func NewSoftwrapScanner(s []vaxis.Cell, width uint16) SoftwrapScanner { return SoftwrapScanner{ rest: s, width: width, } } // Returns the first line segment in s.cells func firstLineSegment(cells []vaxis.Cell) ([]vaxis.Cell, bool) { var rest string for i, cell := range cells { if i == len(cells)-1 { // last one return cells, true } next := cells[i+1] // Check if we have a leading line break. We only need to do // this on the first iteration if i == 0 && uniseg.HasTrailingLineBreakInString(cell.Grapheme) { return cells[:i+1], true } // Check if the next grapheme has a trailing line break. We do // this here because we *always* have to check for it anyways, // and we can do so before the FirstLineSegment call if uniseg.HasTrailingLineBreakInString(next.Grapheme) { return cells[:i+2], true } _, rest, _, _ = uniseg.FirstLineSegmentInString( cell.Grapheme+next.Grapheme, -1, ) if len(rest) > 0 { return cells[:i+1], false } } return cells, false } func (s *SoftwrapScanner) Scan() bool { if len(s.rest) == 0 || s.width == 0 { return false } // Clear token s.token = []vaxis.Cell{} var w uint16 for { seg, br := firstLineSegment(s.rest) rest := []vaxis.Cell{} if len(seg) < len(s.rest) { rest = s.rest[len(seg):] } var ( word []vaxis.Cell wordLen uint16 spaceLen uint16 ) // "TrimRight" for i := len(seg) - 1; i >= 0; i -= 1 { cell := seg[i] r, _ := utf8.DecodeLastRuneInString(cell.Grapheme) if unicode.IsSpace(r) { continue } // First non-space char. Set our word here word = seg[:i+1] break } // Trailing space is anything after word trSpace := seg[len(word):] for _, ch := range word { wordLen += uint16(ch.Width) } for _, ch := range trSpace { spaceLen += uint16(ch.Width) } // This word is longer than the line. We have to break on // graphemes if wordLen > s.width { s.rest = []vaxis.Cell{} // Append characters to token until we reach the end for _, char := range word { if w >= s.width { // Append the rest to rest s.rest = append(s.rest, char) continue } s.token = append(s.token, char) w += uint16(char.Width) } // Append the trailing space s.rest = append(s.rest, trSpace...) // Append the rest... s.rest = append(s.rest, rest...) return true } // Check if this segment fits. If it doesn't we are done if w+wordLen > s.width { return true } s.rest = rest // Check if this segment contains a hard break. If it does, we // remove the hard break before adding it to token and then // return if br { last := seg[len(seg)-1] if uniseg.HasTrailingLineBreakInString(last.Grapheme) { seg = seg[:len(seg)-1] } s.token = append(s.token, seg...) return true } // Otherwise, add this word s.token = append(s.token, word...) w += wordLen // If the space doesn't fit, we return now if w+spaceLen > s.width { return true } s.token = append(s.token, trSpace...) w += spaceLen } } func (s *SoftwrapScanner) Text() []vaxis.Cell { return s.token } type HardwrapScanner struct { cells []vaxis.Cell line []vaxis.Cell } func NewHardwrapScanner(cells []vaxis.Cell) HardwrapScanner { return HardwrapScanner{ cells: cells, } } func (h *HardwrapScanner) Scan() bool { if len(h.cells) == 0 { return false } h.line = []vaxis.Cell{} // Iterate through cells until we find a linebreak for i, cell := range h.cells { if cell.Grapheme == "\n" { if i == len(h.cells)-1 { break } h.cells = h.cells[i+1:] return true } h.line = append(h.line, cell) } h.cells = []vaxis.Cell{} return true } func (h *HardwrapScanner) Line() []vaxis.Cell { return h.line } // Verify we meet the Widget interface var _ vxfw.Widget = &RichText{} golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/richtext/richtext_test.go000066400000000000000000000105131476577054500264350ustar00rootroot00000000000000package richtext import ( "strings" "testing" "git.sr.ht/~rockorager/vaxis" "github.com/stretchr/testify/assert" ) func TestHardwrapScanner(t *testing.T) { tests := []struct { name string input string expected []string }{ { name: "no breaks", input: "foo", expected: []string{"foo"}, }, { name: "single hard break", input: "each line\nfits", expected: []string{ "each line", "fits", }, }, { name: "sequential hardbreak", input: "each line\n\nfits", expected: []string{ "each line", "", "fits", }, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { chars := vaxis.Characters(test.input) cells := make([]vaxis.Cell, 0, len(chars)) for _, char := range chars { cell := vaxis.Cell{Character: char} cells = append(cells, cell) } scanner := NewHardwrapScanner(cells) lines := []string{} for scanner.Scan() { str := strings.Builder{} line := scanner.Line() for _, cell := range line { str.WriteString(cell.Grapheme) } lines = append(lines, str.String()) } assert.Equal(t, test.expected, lines) }) } } func TestFirstLineSegment(t *testing.T) { tests := []struct { name string input string expected string expectedBr bool }{ { name: "no breaks", input: "foo", expected: "foo", expectedBr: true, }, { name: "trailing break", input: "foo\n", expected: "foo\n", expectedBr: true, }, { name: "leading break", input: "\nbar", expected: "\n", expectedBr: true, }, { name: "middle break", input: "foo\nbar", expected: "foo\n", expectedBr: true, }, { name: "word break", input: "foo bar", expected: "foo ", expectedBr: false, }, { name: "word break with hard break", input: "foo \nbar", expected: "foo \n", expectedBr: true, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { chars := vaxis.Characters(test.input) cells := make([]vaxis.Cell, 0, len(chars)) for _, char := range chars { cell := vaxis.Cell{Character: char} cells = append(cells, cell) } seg, br := firstLineSegment(cells) str := strings.Builder{} for _, cell := range seg { str.WriteString(cell.Grapheme) } assert.Equal(t, test.expected, str.String()) assert.Equal(t, test.expectedBr, br) }) } } func TestSoftWrapScanner(t *testing.T) { tests := []struct { name string input string expected []string width uint16 }{ { name: "no wrap, perfect width", input: "foo", expected: []string{"foo"}, width: 3, }, { name: "no wrap, large", input: "foo", expected: []string{"foo"}, width: 4, }, { name: "simple", input: "foo bar", expected: []string{ "foo", "bar", }, width: 3, }, { name: "hard break", input: "foo\nbar", expected: []string{ "foo", "bar", }, width: 3, }, { name: "lots of space", input: "foo bar", expected: []string{ "foo", "bar", }, width: 3, }, { name: "hard break and leading space", input: " foo\n bar", expected: []string{ " foo", " bar", }, width: 4, }, { name: "long word", input: "longwordwithnobreaks", expected: []string{ "long", "word", "with", "nobr", "eaks", }, width: 4, }, { name: "erock: 3 lines", input: "Line 1\nLine 2\nLine 3\n", expected: []string{ "Line 1", "Line 2", "Line 3", }, width: 6, }, { name: "no soft wrap needed", input: "each line\nfits", expected: []string{ "each line", "fits", }, width: 10, }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { chars := vaxis.Characters(test.input) cells := make([]vaxis.Cell, 0, len(chars)) for _, char := range chars { cell := vaxis.Cell{Character: char} cells = append(cells, cell) } scanner := NewSoftwrapScanner(cells, test.width) lines := []string{} for scanner.Scan() { line := scanner.Text() str := strings.Builder{} for _, ch := range line { str.WriteString(ch.Grapheme) } lines = append(lines, str.String()) } assert.Equal(t, test.expected, lines) }) } } golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/text/000077500000000000000000000000001476577054500223475ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/text/text.go000066400000000000000000000131441476577054500236650ustar00rootroot00000000000000package text import ( "bufio" "bytes" "strings" "unicode" "unicode/utf8" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/vxfw" "github.com/rivo/uniseg" ) type Text struct { // The content of the Text widget Content string // The style to draw the text as Style vaxis.Style // Whether to softwrap the text or not Softwrap bool } func New(content string) *Text { return &Text{ Content: content, Softwrap: true, } } // Noop for text func (t *Text) HandleEvent(ev vaxis.Event, phase vxfw.EventPhase) (vxfw.Command, error) { return nil, nil } func (t *Text) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { if t.Softwrap { return t.drawSoftwrap(ctx) } size := t.findContainerSize(ctx) s := vxfw.NewSurface(size.Width, size.Height, t) s.Fill(t.Style) scanner := bufio.NewScanner(strings.NewReader(t.Content)) var row uint16 for scanner.Scan() { var col uint16 if row > ctx.Max.Height { return s, nil } chars := ctx.Characters(scanner.Text()) cols: for i, char := range chars { if col >= ctx.Max.Width { break } // If this char would get us to or beyond the max width // (and we aren't the last char), then we print an // ellipse if col+uint16(char.Width) >= ctx.Max.Width && i < len(chars) { cell := vaxis.Cell{ Character: vaxis.Character{ Grapheme: "…", Width: 1, }, Style: t.Style, } s.WriteCell(col, row, cell) break cols } else { cell := vaxis.Cell{ Character: char, Style: t.Style, } s.WriteCell(col, row, cell) col += uint16(char.Width) } } row += 1 } return s, nil } func (t *Text) drawSoftwrap(ctx vxfw.DrawContext) (vxfw.Surface, error) { size := t.findContainerSize(ctx) s := vxfw.NewSurface(size.Width, size.Height, t) s.Fill(t.Style) scanner := NewSoftwrapScanner(t.Content, ctx.Max.Width) var row uint16 for scanner.Scan(ctx) { var col uint16 if row > ctx.Max.Height { return s, nil } chars := ctx.Characters(scanner.Text()) for _, char := range chars { // We should never get here because we softwrapped, but // we check just in case if col >= ctx.Max.Width { break } cell := vaxis.Cell{ Character: char, Style: t.Style, } s.WriteCell(col, row, cell) col += uint16(char.Width) } row += 1 } return s, nil } func (t *Text) findContainerSize(ctx vxfw.DrawContext) vxfw.Size { var size vxfw.Size if t.Softwrap { scanner := NewSoftwrapScanner(t.Content, ctx.Max.Width) for scanner.Scan(ctx) { if size.Height > ctx.Max.Height { return size } size.Height += 1 chars := ctx.Characters(scanner.Text()) var w uint16 for _, char := range chars { w += uint16(char.Width) } // Size is limited to the Max.Width if size.Width < w { size.Width = w } if size.Width > ctx.Max.Width { size.Width = ctx.Max.Width } } return size } scanner := bufio.NewScanner(strings.NewReader(t.Content)) for scanner.Scan() { if size.Height > ctx.Max.Height { return size } size.Height += 1 chars := ctx.Characters(scanner.Text()) var w uint16 for _, char := range chars { w += uint16(char.Width) } // Size is limited to the Max.Width if size.Width < w { size.Width = w } if size.Width > ctx.Max.Width { size.Width = ctx.Max.Width } } return size } type SoftwrapScanner struct { state int rest []byte token []byte width uint16 } func NewSoftwrapScanner(s string, width uint16) SoftwrapScanner { return SoftwrapScanner{ state: -1, rest: []byte(s), width: width, } } func (s *SoftwrapScanner) Scan(ctx vxfw.DrawContext) bool { if len(s.rest) == 0 || s.width == 0 { return false } // Clear token s.token = []byte{} var w uint16 for { seg, rest, br, state := uniseg.FirstLineSegment(s.rest, s.state) // trim trailing whitespace to get our word word := bytes.TrimRightFunc(seg, unicode.IsSpace) // trailing space trSpace := seg[len(word):] wordChars := ctx.Characters(string(word)) var wordLen uint16 for _, char := range wordChars { wordLen += uint16(char.Width) } spaceChars := ctx.Characters(string(trSpace)) var spaceLen uint16 for _, char := range spaceChars { spaceLen += uint16(char.Width) } // This word is longer than the line. We have to break on // graphemes if wordLen > s.width { s.rest = []byte{} // Append characters to token until we reach the end for _, char := range wordChars { if w >= s.width { // Append the rest to rest s.rest = append(s.rest, []byte(char.Grapheme)...) continue } s.token = append(s.token, []byte(char.Grapheme)...) w += uint16(char.Width) } // Append the trailing space s.rest = append(s.rest, trSpace...) // Append the rest... s.rest = append(s.rest, rest...) return true } // Check if this segment fits. If it doesn't we are done if w+wordLen > s.width { return true } s.rest = rest s.state = state // Check if this segment contains a hard break. If it does, we // remove the hard break before adding it to token and then // return if br { if uniseg.HasTrailingLineBreak(seg) { _, l := utf8.DecodeLastRune(seg) // trim the trailing rune seg = seg[:len(seg)-l] } s.token = append(s.token, seg...) return true } // Otherwise, add this word s.token = append(s.token, word...) w += wordLen // If the space doesn't fit, we return now if w+spaceLen > s.width { return true } s.token = append(s.token, trSpace...) w += spaceLen } } func (s *SoftwrapScanner) Text() string { return string(s.token) } // Verify we meet the Widget interface var _ vxfw.Widget = &Text{} golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/text/text_test.go000066400000000000000000000031571476577054500247270ustar00rootroot00000000000000package text import ( "testing" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/vxfw" "github.com/stretchr/testify/assert" ) func TestText(t *testing.T) { tests := []struct { name string input string expected []string width uint16 }{ { name: "no wrap, perfect width", input: "foo", expected: []string{"foo"}, width: 3, }, { name: "no wrap, large", input: "foo", expected: []string{"foo"}, width: 4, }, { name: "simple", input: "foo bar", expected: []string{ "foo", "bar", }, width: 3, }, { name: "hard break", input: "foo\nbar", expected: []string{ "foo", "bar", }, width: 3, }, { name: "lots of space", input: "foo bar", expected: []string{ "foo", "bar", }, width: 3, }, { name: "hard break and leading space", input: " foo\n bar", expected: []string{ " foo", " bar", }, width: 4, }, { name: "long word", input: "longwordwithnobreaks", expected: []string{ "long", "word", "with", "nobr", "eaks", }, width: 4, }, { name: "no soft wrap needed", input: "each line\nfits", expected: []string{ "each line", "fits", }, width: 10, }, } ctx := vxfw.DrawContext{ Characters: vaxis.Characters, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { scanner := NewSoftwrapScanner(test.input, test.width) lines := []string{} for scanner.Scan(ctx) { lines = append(lines, scanner.Text()) } assert.Equal(t, test.expected, lines) }) } } golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/textfield/000077500000000000000000000000001476577054500233535ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/textfield/textfield.go000066400000000000000000000136661476577054500257060ustar00rootroot00000000000000package textfield import ( "strings" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/vxfw" "github.com/rivo/uniseg" ) const scrolloff = 4 var truncator = vaxis.Character{ Grapheme: "…", Width: 1, } type TextField struct { Value string Style vaxis.Style OnSubmit func(line string) (vxfw.Command, error) OnChange func(line string) (vxfw.Command, error) cursor uint n uint } func New() *TextField { return &TextField{} } func (tf *TextField) HandleEvent(ev vaxis.Event, ph vxfw.EventPhase) (vxfw.Command, error) { switch ev := ev.(type) { case vaxis.Key: if ev.EventType == vaxis.EventRelease { return nil, nil } if len(ev.Text) > 0 { pre := tf.Value cmd := tf.InsertStringAtCursor(ev.Text) return tf.checkChanged(cmd, pre) } // Cursor to Beginning of line if ev.Matches('a', vaxis.ModCtrl) || ev.Matches(vaxis.KeyHome) { return tf.CursorTo(0), nil } // Cursor to end of line if ev.Matches('e', vaxis.ModCtrl) || ev.Matches(vaxis.KeyEnd) { return tf.CursorTo(tf.n), nil } // Cursor forward one character if ev.Matches('f', vaxis.ModCtrl) || ev.Matches(vaxis.KeyRight) { return tf.CursorTo(tf.cursor + 1), nil } // Cursor backward one character if ev.Matches('b', vaxis.ModCtrl) || ev.Matches(vaxis.KeyLeft) { if tf.cursor == 0 { return nil, nil } return tf.CursorTo(tf.cursor - 1), nil } // Delete character right of cursor if ev.Matches('d', vaxis.ModCtrl) || ev.Matches(vaxis.KeyDelete) { pre := tf.Value cmd := tf.DeleteCharRightOfCursor() return tf.checkChanged(cmd, pre) } // Delete character left of cursor if ev.Matches('h', vaxis.ModCtrl) || ev.Matches(vaxis.KeyBackspace) { pre := tf.Value cmd := tf.DeleteCharLeftOfCursor() return tf.checkChanged(cmd, pre) } // Delete to end of line if ev.Matches('k', vaxis.ModCtrl) { pre := tf.Value cmd := tf.DeleteCursorToEndOfLine() return tf.checkChanged(cmd, pre) } // Submit if ev.Matches(vaxis.KeyEnter) { defer tf.Reset() if tf.OnSubmit != nil { return tf.OnSubmit(tf.Value) } return vxfw.ConsumeAndRedraw(), nil } } return nil, nil } func (tf *TextField) checkChanged(cmd vxfw.Command, pre string) (vxfw.Command, error) { // If the value is the same, we return the cmd we were passed if tf.Value == pre { return cmd, nil } // Value is different. If we have an OnChange handler, we call it if tf.OnChange != nil { cmd2, err := tf.OnChange(tf.Value) if err != nil { return nil, err } return []vxfw.Command{cmd, cmd2}, nil } // Otherwise, return what we had return cmd, nil } func (tf *TextField) Reset() { tf.n = 0 tf.Value = "" tf.cursor = 0 } func (tf *TextField) InsertStringAtCursor(s string) vxfw.Command { tf.insertStringAtCursor(s) tf.n = graphemeCountInString(tf.Value) return vxfw.ConsumeAndRedraw() } func (tf *TextField) CursorTo(i uint) vxfw.Command { if i > tf.n { i = tf.n } // Nothing to do if state is the same if i == tf.cursor { return nil } tf.cursor = i return vxfw.ConsumeAndRedraw() } func (tf *TextField) DeleteCharRightOfCursor() vxfw.Command { // Nothing to do if at end of line if tf.n == tf.cursor { return nil } var ( cluster = "" rest = tf.Value state = -1 i uint = 0 next = strings.Builder{} ) for len(rest) > 0 { cluster, rest, _, state = uniseg.FirstGraphemeClusterInString(rest, state) if i == tf.cursor { i += 1 continue } i += 1 next.WriteString(cluster) } tf.Value = next.String() return vxfw.ConsumeAndRedraw() } func (tf *TextField) DeleteCharLeftOfCursor() vxfw.Command { // Nothing to do if at beginning of line if tf.cursor == 0 { return nil } var ( cluster = "" rest = tf.Value state = -1 i uint = 0 next = strings.Builder{} ) for len(rest) > 0 { cluster, rest, _, state = uniseg.FirstGraphemeClusterInString(rest, state) i += 1 if i == tf.cursor { continue } // insert the string next.WriteString(cluster) } tf.Value = next.String() tf.cursor -= 1 return vxfw.ConsumeAndRedraw() } func (tf *TextField) DeleteCursorToEndOfLine() vxfw.Command { if tf.cursor == tf.n { return nil } var ( cluster = "" rest = tf.Value state = -1 i uint = 0 next = strings.Builder{} ) for len(rest) > 0 { cluster, rest, _, state = uniseg.FirstGraphemeClusterInString(rest, state) if i == tf.cursor { break } i += 1 next.WriteString(cluster) } tf.Value = next.String() return vxfw.ConsumeAndRedraw() } func (tf *TextField) Draw(ctx vxfw.DrawContext) (vxfw.Surface, error) { if ctx.Max.Width == 0 || ctx.Max.Height == 0 { return vxfw.Surface{}, nil } s := vxfw.NewSurface(ctx.Max.Width, 1, tf) s.Cursor = &vxfw.CursorState{ Row: 0, Col: 0, Shape: vaxis.CursorBlock, } chars := ctx.Characters(tf.Value) var ( i uint col uint16 ) for _, char := range chars { cell := vaxis.Cell{ Character: char, Style: tf.Style, } s.WriteCell(col, 0, cell) col += uint16(char.Width) i += 1 if i == tf.cursor { s.Cursor.Col = col } } if i < tf.cursor { s.Cursor.Col = col } return s, nil } func (tf *TextField) insertStringAtCursor(s string) { // Find the cursor position var ( cluster = "" rest = tf.Value state = -1 i uint = 0 next = strings.Builder{} ) count := graphemeCountInString(s) for { if len(rest) > 0 && i < tf.cursor { cluster, rest, _, state = uniseg.FirstGraphemeClusterInString(rest, state) next.WriteString(cluster) i += 1 continue } // insert the string next.WriteString(s) // advance the cursor tf.cursor += count next.WriteString(rest) break } tf.Value = next.String() } func graphemeCountInString(s string) uint { var ( rest = s state = -1 count uint = 0 ) for len(rest) > 0 { _, rest, _, state = uniseg.FirstGraphemeClusterInString(rest, state) count += 1 } return count } golang-sourcehut-rockorager-vaxis-0.13.0/vxfw/vxfw.go000066400000000000000000000367131476577054500227160ustar00rootroot00000000000000package vxfw import ( "math" "sort" "strings" "time" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/log" ) type Widget interface { HandleEvent(vaxis.Event, EventPhase) (Command, error) Draw(DrawContext) (Surface, error) } // EventCapturer is a Widget which can capture events before they are delivered // to the target widget. To capture an event, the EventCapturer must be an // ancestor of the target widget type EventCapturer interface { CaptureEvent(vaxis.Event) (Command, error) } type Event interface{} type ( // Sent as the first event to the root widget Init struct{} MouseEnter struct{} MouseLeave struct{} ) type Command interface{} type ( // RedrawCmd tells the UI to redraw RedrawCmd struct{} // RefreshCmd tells the UI to flush a complete redraw RefreshCmd struct{} // QuitCmd tells the application to exit QuitCmd struct{} // ConsumeEventCmd tells the application to stop the event propagation ConsumeEventCmd struct{} // BatchCmd is a batch of other commands BatchCmd []Command // FocusWidgetCmd sets the focus to the widget FocusWidgetCmd Widget // SetMouseShapeCmd sets the mouse shape SetMouseShapeCmd vaxis.MouseShape // SetTitleCmd sets the title of the terminal SetTitleCmd string // CopyToClipboard copies the provided string to the host clipboard CopyToClipboardCmd string // SendNotificationCmd sends a system notification SendNotificationCmd struct { Title string Body string } // DebugCmd tells the runtime to print the Surface tree each render DebugCmd struct{} ) type DrawContext struct { // The minimum size the widget must render as Min Size // The maximum size the widget must render as. A value of math.MaxUint16 // in either dimension means that dimension has no limit Max Size // Function to turn a string into a slice of characters. This splits the // string into graphemes and measures each grapheme Characters func(string) []vaxis.Character } type Size struct { Width uint16 Height uint16 } func (s Size) HasUnboundedWidth() bool { return s.Width == math.MaxUint16 } func (s Size) HasUnboundedHeight() bool { return s.Height == math.MaxUint16 } // EventPhase is the phase of the event during the event handling process. // Possible values are // // CapturePhase // TargetPhase // BubblePhase type EventPhase uint8 const ( CapturePhase EventPhase = iota TargetPhase BubblePhase ) type Surface struct { Size Size Widget Widget Cursor *CursorState Buffer []vaxis.Cell Children []SubSurface } // Creates a new surface. The resulting surface will have a Buffer with capacity // large enough for Size func NewSurface(width uint16, height uint16, w Widget) Surface { return Surface{ Size: Size{ Width: width, Height: height, }, Widget: w, Buffer: make([]vaxis.Cell, height*width), } } func (s *Surface) AddChild(col int, row int, child Surface) { ss := NewSubSurface(col, row, child) s.Children = append(s.Children, ss) } func (s *Surface) WriteCell(col uint16, row uint16, cell vaxis.Cell) { if col >= s.Size.Width || row > s.Size.Height { return } i := (row * s.Size.Width) + col s.Buffer[i] = cell } func (s *Surface) Fill(style vaxis.Style) { for i := range s.Buffer { s.Buffer[i].Style = style } } func (s Surface) render(win vaxis.Window, focused Widget) { // Render ourself first for i, cell := range s.Buffer { row := i / int(s.Size.Width) col := i % int(s.Size.Width) win.SetCell(col, row, cell) } // If we have a cursor state and we are the focused widget, draw the // cursor if s.Cursor != nil && s.Widget == focused { win.ShowCursor( int(s.Cursor.Col), int(s.Cursor.Row), s.Cursor.Shape, ) } // Sort the Children by z-index sort.Slice(s.Children, func(i int, j int) bool { return s.Children[i].ZIndex < s.Children[j].ZIndex }) for _, child := range s.Children { childWin := win.New( int(child.Origin.Col), int(child.Origin.Row), int(child.Surface.Size.Width), int(child.Surface.Size.Height), ) child.Surface.render(childWin, focused) } } type CursorState struct { Row uint16 Col uint16 Shape vaxis.CursorStyle } type SubSurface struct { Origin RelativePoint Surface Surface ZIndex int } func NewSubSurface(col int, row int, s Surface) SubSurface { return SubSurface{ Origin: RelativePoint{ Row: row, Col: col, }, Surface: s, ZIndex: 0, } } func (ss *SubSurface) containsPoint(col int, row int) bool { return col >= ss.Origin.Col && col < (ss.Origin.Col+int(ss.Surface.Size.Width)) && row >= ss.Origin.Row && row < (ss.Origin.Row+int(ss.Surface.Size.Height)) } type RelativePoint struct { Row int Col int } type focusHandler struct { // Current focused focused focused Widget // Root widget root Widget // path is the path to the focused widet path []Widget } func (f *focusHandler) handleEvent(app *App, ev vaxis.Event) error { app.consumeEvent = false // Capture phase for _, w := range f.path { c, ok := w.(EventCapturer) if !ok { continue } cmd, err := c.CaptureEvent(ev) if err != nil { return err } app.handleCommand(cmd) if app.consumeEvent { app.consumeEvent = false return nil } } // Target phase cmd, err := f.focused.HandleEvent(ev, TargetPhase) if err != nil { return err } app.handleCommand(cmd) if app.consumeEvent { app.consumeEvent = false return nil } // Bubble phase. We don't bubble to the focused widget (which is the // last one in the list). Hence, - 2 for i := len(f.path) - 2; i >= 0; i -= 1 { w := f.path[i] cmd, err := w.HandleEvent(ev, BubblePhase) if err != nil { return err } app.handleCommand(cmd) if app.consumeEvent { app.consumeEvent = false return nil } } return nil } func (f *focusHandler) updatePath(app *App, root Surface) { // Clear the path f.path = []Widget{} ok := f.childHasFocus(root) if !ok { // Best effort refocus _ = f.focusWidget(app, f.root) } if f.root != root.Widget || len(f.path) == 0 { // Make sure that we always add the original root widget as the // last node. We will reverse the list, making this widget the // first one with the opportunity to capture events f.path = append(f.path, f.root) } // Reverse the list since it is ordered target to root, and we want the // opposite for i := 0; i < len(f.path)/2; i++ { f.path[i], f.path[len(f.path)-1-i] = f.path[len(f.path)-1-i], f.path[i] } } func (f *focusHandler) childHasFocus(s Surface) bool { // If s is our focused widget, we add to path and return true if s.Widget == f.focused { f.path = append(f.path, s.Widget) return true } // Loop through children to find the focused widget for _, c := range s.Children { if !f.childHasFocus(c.Surface) { continue } f.path = append(f.path, s.Widget) return true } return false } func (f *focusHandler) focusWidget(app *App, w Widget) error { if f.focused == w { return nil } cmd, err := f.focused.HandleEvent(vaxis.FocusOut{}, TargetPhase) if err != nil { return err } app.handleCommand(cmd) // Change the focused widget before we send the focus in event. If the // newly focused widget changes focus again, we need to set this before // the handleCommand call f.focused = w cmd, err = w.HandleEvent(vaxis.FocusIn{}, TargetPhase) if err != nil { return err } app.handleCommand(cmd) return nil } type App struct { vx *vaxis.Vaxis redraw bool refresh bool shouldQuit bool consumeEvent bool debug bool charCache map[string]int fh focusHandler } func NewApp(opts vaxis.Options) (*App, error) { vx, err := vaxis.New(opts) if err != nil { return nil, err } app := &App{ vx: vx, charCache: make(map[string]int, 256), } return app, nil } func (a *App) Suspend() error { return a.vx.Suspend() } func (a *App) Resume() error { return a.vx.Resume() } // Run the application func (a *App) Run(w Widget) error { defer a.vx.Close() // Initialize the focus handler. Our root, focused, and first node of // the path is the root widget at init a.fh = focusHandler{ root: w, focused: w, path: []Widget{w}, } err := a.fh.handleEvent(a, Init{}) if err != nil { return err } s, err := a.layout(w) if err != nil { return err } mh := mouseHandler{ lastFrame: s, } // This is the main event loop. We first wait for events with an 8ms // timeout. If we have an event, we handle it immediately and process // any commands it returns. // // Then we check if we should quit for { select { case ev := <-a.vx.Events(): switch ev := ev.(type) { case vaxis.Resize: // Trigger a redraw on resize a.redraw = true case vaxis.Mouse: err := mh.handleEvent(a, ev) if err != nil { return err } case vaxis.FocusIn: cmd, err := w.HandleEvent(MouseEnter{}, TargetPhase) if err != nil { return err } a.handleCommand(cmd) case vaxis.FocusOut: mh.mouse = nil err := mh.mouseExit(a) if err != nil { return err } case vaxis.Key: err := a.fh.handleEvent(a, ev) if err != nil { return err } case vaxis.Redraw: a.redraw = true default: // Anything else we let the application handle err := a.fh.handleEvent(a, ev) if err != nil { return err } } if a.shouldQuit { return nil } case <-time.After(8 * time.Millisecond): if !a.redraw { continue } a.redraw = false s, err := a.layout(w) if err != nil { return err } // Update mouse err = mh.update(a, s) if err != nil { return err } // mh.update can trigger a redraw based on mouse enter / // mouse exit events. check and redo the layout if // needed if a.redraw { a.redraw = false s, err = a.layout(w) if err != nil { return err } } win := a.vx.Window() win.Clear() a.vx.HideCursor() s.render(win, a.fh.focused) switch a.refresh { case true: a.vx.Refresh() a.refresh = false case false: a.vx.Render() } if a.debug { debugPrintWidget(s, 0, a.fh.focused) a.debug = false } // Update focus handler a.fh.updatePath(a, s) // Update the mouse last frame mh.lastFrame = s } } } func (a *App) layout(root Widget) (Surface, error) { win := a.vx.Window() min := Size{Width: 0, Height: 0} max := Size{ Width: uint16(win.Width), Height: uint16(win.Height), } return root.Draw(DrawContext{ Min: min, Max: max, Characters: a.Characters, }) } func (a *App) handleCommand(cmd Command) { switch cmd := cmd.(type) { case BatchCmd: for _, c := range cmd { a.handleCommand(c) } case []Command: for _, c := range cmd { a.handleCommand(c) } case RedrawCmd: a.redraw = true case RefreshCmd: a.refresh = true case QuitCmd: a.shouldQuit = true case ConsumeEventCmd: a.consumeEvent = true case FocusWidgetCmd: err := a.fh.focusWidget(a, cmd) if err != nil { log.Error("focusWidget error: %s", err) return } case SetMouseShapeCmd: a.vx.SetMouseShape(vaxis.MouseShape(cmd)) case SetTitleCmd: a.vx.SetTitle(string(cmd)) case CopyToClipboardCmd: a.vx.ClipboardPush(string(cmd)) case SendNotificationCmd: a.vx.Notify(cmd.Title, cmd.Body) case DebugCmd: a.debug = true a.redraw = true } } func (a App) PostEvent(ev vaxis.Event) { a.vx.PostEvent(ev) } // Characters turns a string into a slice of measured graphemes func (a *App) Characters(s string) []vaxis.Character { chars := vaxis.Characters(s) if !a.vx.CanUnicodeCore() { // If we don't have unicode core, we need to remeasure // everything. We cache the results for i := range chars { g := chars[i].Grapheme w, ok := a.charCache[g] if !ok { // Put the result in the cache w = a.vx.RenderedWidth(g) a.charCache[g] = w } chars[i].Width = w } } return chars } type hitResult struct { col uint16 row uint16 w Widget } type mouseHandler struct { lastFrame Surface lastHits []hitResult mouse *vaxis.Mouse } func (m *mouseHandler) handleEvent(app *App, ev vaxis.Mouse) error { m.mouse = &ev // Always do an update err := m.update(app, m.lastFrame) if err != nil { return err } if len(m.lastHits) == 0 { return nil } // Handle the mouse event app.consumeEvent = false // Capture phase for _, h := range m.lastHits { c, ok := h.w.(EventCapturer) if !ok { continue } cmd, err := c.CaptureEvent(ev) if err != nil { return err } app.handleCommand(cmd) if app.consumeEvent { app.consumeEvent = false return nil } } target := m.lastHits[len(m.lastHits)-1] // Target phase cmd, err := target.w.HandleEvent(ev, TargetPhase) if err != nil { return err } app.handleCommand(cmd) if app.consumeEvent { app.consumeEvent = false return nil } // Bubble phase. We don't bubble to the focused widget (which is the // last one in the list). Hence, - 2 for i := len(m.lastHits) - 2; i >= 0; i -= 1 { h := m.lastHits[i] cmd, err := h.w.HandleEvent(ev, BubblePhase) if err != nil { return err } app.handleCommand(cmd) if app.consumeEvent { app.consumeEvent = false return nil } } return nil } // update hit tests s. It delivers mouse leave and mouse enter events to all // relevant widgets which are different between the last hit list. The new // hitlist is saved to mouseHandler func (m *mouseHandler) update(app *App, s Surface) error { // Nothing to do if we don't have a mouse event if m.mouse == nil { return nil } hits := []hitResult{} ss := NewSubSurface(0, 0, s) if ss.containsPoint(m.mouse.Col, m.mouse.Row) { hits = hitTest(s, hits, uint16(m.mouse.Col), uint16(m.mouse.Row)) } // Handle mouse exit events. These are widgets in lastHits but not in // hits outer_exit: for _, h1 := range m.lastHits { for _, h2 := range hits { if h1 == h2 { continue outer_exit } } // h1 was not found in the new hitlist send it a mouse leave // event cmd, err := h1.w.HandleEvent(MouseLeave{}, TargetPhase) if err != nil { return err } app.handleCommand(cmd) } // Handle mouse enter events. These are widgets in hits but not in // lastHits outer_enter: for _, h1 := range hits { for _, h2 := range m.lastHits { if h1 == h2 { continue outer_enter } } // h1 was not found in the old hitlist send it a mouse enter // event cmd, err := h1.w.HandleEvent(MouseEnter{}, TargetPhase) if err != nil { return err } app.handleCommand(cmd) } // Save this list as our current hit list m.lastHits = hits return nil } // mouseExit send a mouseLeave event to each widget in the last hit list func (m *mouseHandler) mouseExit(app *App) error { for _, h := range m.lastHits { cmd, err := h.w.HandleEvent(MouseLeave{}, TargetPhase) if err != nil { return err } app.handleCommand(cmd) } // Clear the last hit list m.lastHits = []hitResult{} return nil } func hitTest(s Surface, hits []hitResult, col uint16, row uint16) []hitResult { r := hitResult{ col: col, row: row, w: s.Widget, } hits = append(hits, r) for _, ss := range s.Children { if !ss.containsPoint(int(col), int(row)) { continue } local_col := col - uint16(ss.Origin.Col) local_row := row - uint16(ss.Origin.Row) hits = hitTest(ss.Surface, hits, local_col, local_row) } return hits } func debugPrintWidget(s Surface, indent int, focused Widget) { if s.Widget == focused { log.Info("\x1b[31m%s%T\x1b[m", strings.Repeat(" ", indent*4), s.Widget) } else { log.Info("%s%T", strings.Repeat(" ", indent*4), s.Widget) } for _, ch := range s.Children { debugPrintWidget(ch.Surface, indent+1, focused) } } func ConsumeAndRedraw() BatchCmd { return []Command{ RedrawCmd{}, ConsumeEventCmd{}, } } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/000077500000000000000000000000001476577054500220375ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/widgets/align/000077500000000000000000000000001476577054500231315ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/widgets/align/align.go000066400000000000000000000025111476577054500245510ustar00rootroot00000000000000package align import "git.sr.ht/~rockorager/vaxis" // Center returns a Surface centered vertically and horizontally within the // parent surface. func Center(parent vaxis.Window, cols int, rows int) vaxis.Window { pCols, pRows := parent.Size() row := (pRows / 2) - (rows / 2) col := (pCols / 2) - (cols / 2) return parent.New(col, row, cols, rows) } func TopLeft(parent vaxis.Window, cols int, rows int) vaxis.Window { return parent.New(0, 0, cols, rows) } func TopMiddle(parent vaxis.Window, cols int, rows int) vaxis.Window { pCols, _ := parent.Size() col := (pCols / 2) - (cols / 2) return parent.New(col, 0, cols, rows) } func TopRight(parent vaxis.Window, cols int, rows int) vaxis.Window { pCols, _ := parent.Size() col := pCols - cols return parent.New(col, 0, cols, rows) } func BottomLeft(parent vaxis.Window, cols int, rows int) vaxis.Window { _, pRows := parent.Size() row := pRows - rows return parent.New(0, row, cols, rows) } func BottomMiddle(parent vaxis.Window, cols int, rows int) vaxis.Window { pCols, pRows := parent.Size() row := pRows - rows col := (pCols / 2) - (cols / 2) return parent.New(col, row, cols, rows) } func BottomRight(parent vaxis.Window, cols int, rows int) vaxis.Window { pCols, pRows := parent.Size() row := pRows - rows col := pCols - cols return parent.New(col, row, cols, rows) } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/border/000077500000000000000000000000001476577054500233145ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/widgets/border/border.go000066400000000000000000000042161476577054500251230ustar00rootroot00000000000000package border import "git.sr.ht/~rockorager/vaxis" var ( horizontal = vaxis.Character{Grapheme: "─", Width: 1} vertical = vaxis.Character{Grapheme: "│", Width: 1} topLeft = vaxis.Character{Grapheme: "╭", Width: 1} topRight = vaxis.Character{Grapheme: "╮", Width: 1} bottomRight = vaxis.Character{Grapheme: "╯", Width: 1} bottomLeft = vaxis.Character{Grapheme: "╰", Width: 1} ) func All(win vaxis.Window, style vaxis.Style) vaxis.Window { w, h := win.Size() win.SetCell(0, 0, vaxis.Cell{ Character: topLeft, Style: style, }) win.SetCell(0, h-1, vaxis.Cell{ Character: bottomLeft, Style: style, }) win.SetCell(w-1, 0, vaxis.Cell{ Character: topRight, Style: style, }) win.SetCell(w-1, h-1, vaxis.Cell{ Character: bottomRight, Style: style, }) for i := 1; i < (w - 1); i += 1 { win.SetCell(i, 0, vaxis.Cell{ Character: horizontal, Style: style, }) win.SetCell(i, h-1, vaxis.Cell{ Character: horizontal, Style: style, }) } for i := 1; i < (h - 1); i += 1 { win.SetCell(0, i, vaxis.Cell{ Character: vertical, Style: style, }) win.SetCell(w-1, i, vaxis.Cell{ Character: vertical, Style: style, }) } return win.New(1, 1, w-2, h-2) } func Left(win vaxis.Window, style vaxis.Style) vaxis.Window { _, h := win.Size() for i := 0; i < h; i += 1 { win.SetCell(0, i, vaxis.Cell{ Character: vertical, Style: style, }) } return win.New(1, 0, -1, -1) } func Right(win vaxis.Window, style vaxis.Style) vaxis.Window { w, h := win.Size() for i := 0; i < h; i += 1 { win.SetCell(w-1, i, vaxis.Cell{ Character: vertical, Style: style, }) } return win.New(0, 0, w-1, -1) } func Bottom(win vaxis.Window, style vaxis.Style) vaxis.Window { w, h := win.Size() for i := 0; i < w; i += 1 { win.SetCell(i, h-1, vaxis.Cell{ Character: horizontal, Style: style, }) } return win.New(0, 0, -1, h-1) } func Top(win vaxis.Window, style vaxis.Style) vaxis.Window { w, _ := win.Size() for i := 0; i < w; i += 1 { win.SetCell(i, 0, vaxis.Cell{ Character: horizontal, Style: style, }) } return win.New(0, 1, -1, -1) } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/list/000077500000000000000000000000001476577054500230125ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/widgets/list/list.go000066400000000000000000000031161476577054500243150ustar00rootroot00000000000000package list import ( "git.sr.ht/~rockorager/vaxis" ) type List struct { index int items []string offset int } func New(items []string) List { return List{ items: items, } } func (m *List) Draw(win vaxis.Window) { _, height := win.Size() if m.index >= m.offset+height { m.offset = m.index - height + 1 } else if m.index < m.offset { m.offset = m.index } defaultStyle := vaxis.Style{} selectedStyle := vaxis.Style{Attribute: vaxis.AttrReverse} index := m.index - m.offset for i, subject := range m.items[m.offset:] { var style vaxis.Style if i == index { style = selectedStyle } else { style = defaultStyle } win.Println(i, vaxis.Segment{Text: subject, Style: style}) } } func (m *List) Down() { m.index = min(len(m.items)-1, m.index+1) } func (m *List) Up() { m.index = max(0, m.index-1) } func (m *List) Home() { m.index = 0 } func (m *List) End() { m.index = len(m.items) - 1 } func (m *List) PageDown(win vaxis.Window) { _, height := win.Size() m.index = min(len(m.items)-1, m.index+height) } func (m *List) PageUp(win vaxis.Window) { _, height := win.Size() m.index = max(0, m.index-height) } func (m *List) SetItems(items []string) { m.items = items m.index = min(len(items) - 1, m.index) } // Returns the index of the currently selected item. func (m *List) Index() int { return m.index } // Can be deleted once minimal go bumps to 1.21 func min(a, b int) int { if a < b { return a } return b } // Can be deleted once minimal go bumps to 1.21 func max(a, b int) int { if a > b { return a } return b } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/pager/000077500000000000000000000000001476577054500231355ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/widgets/pager/pager.go000066400000000000000000000030441476577054500245630ustar00rootroot00000000000000package pager import ( "strings" "git.sr.ht/~rockorager/vaxis" ) const ( WrapFast = iota ) var defaultFill = vaxis.Character{Grapheme: " "} type Model struct { Segments []vaxis.Segment lines []*line Fill vaxis.Cell Offset int WrapMode int width int } type line struct { characters []vaxis.Cell } func (l *line) append(t vaxis.Cell) { l.characters = append(l.characters, t) } func (m *Model) Draw(win vaxis.Window) { w, h := win.Size() if w != m.width { m.width = w m.Layout() } if len(m.lines)-m.Offset < h { m.Offset = len(m.lines) - h } if m.Offset < 0 { m.Offset = 0 } if m.Fill.Grapheme == "" { m.Fill.Character = defaultFill } win.Fill(m.Fill) for row, l := range m.lines { if row < m.Offset { continue } if (row - m.Offset) >= h { return } col := 0 for _, cell := range l.characters { win.SetCell(col, row-m.Offset, cell) col += cell.Width } } } func (m *Model) Layout() { m.lines = []*line{} l := &line{} col := 0 for _, seg := range m.Segments { for _, char := range vaxis.Characters(seg.Text) { if strings.ContainsRune(char.Grapheme, '\n') { m.lines = append(m.lines, l) l = &line{} col = 0 continue } cell := vaxis.Cell{ Character: char, Style: seg.Style, } l.append(cell) col += char.Width if col >= m.width { m.lines = append(m.lines, l) l = &line{} col = 0 } } } } // Scrolls the pager down n lines, if it can func (m *Model) ScrollDown() { m.Offset += 1 } func (m *Model) ScrollUp() { m.Offset -= 1 } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/progress/000077500000000000000000000000001476577054500237035ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/widgets/progress/progress.go000066400000000000000000000055501476577054500261030ustar00rootroot00000000000000package progress import ( "io" "math" "git.sr.ht/~rockorager/vaxis" ) // Model represents a progress bar. A progress bar is also an io.Reader and an // io.Writer. If you pass it a DataMsg with a Total before calling Read or // Write, it will pass through the R/W and display the progress type Model struct { Style vaxis.Style Reader io.Reader Writer io.Writer Progress float64 Total float64 vx *vaxis.Vaxis } func New(vx *vaxis.Vaxis) *Model { return &Model{vx: vx} } var ( full = vaxis.Character{Grapheme: "█", Width: 1} sevenEighths = vaxis.Character{Grapheme: "▉", Width: 1} threeFourths = vaxis.Character{Grapheme: "▊", Width: 1} fiveEighths = vaxis.Character{Grapheme: "▋", Width: 1} half = vaxis.Character{Grapheme: "▌", Width: 1} threeEighths = vaxis.Character{Grapheme: "▍", Width: 1} oneFourth = vaxis.Character{Grapheme: "▎", Width: 1} oneEighth = vaxis.Character{Grapheme: "▏", Width: 1} ) func (m *Model) Draw(win vaxis.Window) { if m.Total == 0 { return } _, w := win.Size() fracBlocks := (m.Progress / m.Total) * float64(w) fullBlocks := math.Floor(fracBlocks) remainder := fracBlocks - fullBlocks for i := 0; i <= int(fullBlocks); i += 1 { win.SetCell(i, 0, vaxis.Cell{ Character: full, Style: m.Style, }) } switch { case remainder >= 0.875: win.SetCell(int(fullBlocks)+1, 0, vaxis.Cell{ Character: sevenEighths, Style: m.Style, }) case remainder >= 0.75: win.SetCell(int(fullBlocks)+1, 0, vaxis.Cell{ Character: threeFourths, Style: m.Style, }) case remainder >= 0.625: win.SetCell(int(fullBlocks)+1, 0, vaxis.Cell{ Character: fiveEighths, Style: m.Style, }) case remainder >= 0.5: win.SetCell(int(fullBlocks)+1, 0, vaxis.Cell{ Character: half, Style: m.Style, }) case remainder >= 0.375: win.SetCell(int(fullBlocks)+1, 0, vaxis.Cell{ Character: threeEighths, Style: m.Style, }) case remainder >= 0.25: win.SetCell(int(fullBlocks)+1, 0, vaxis.Cell{ Character: oneFourth, Style: m.Style, }) case remainder >= 0.125: win.SetCell(int(fullBlocks)+1, 0, vaxis.Cell{ Character: oneEighth, Style: m.Style, }) } } // Read counts the bytes read from Reader and sends the Model an updated // progress message. The Total field should be set to an expected value for this // to work properly func (m *Model) Read(p []byte) (int, error) { n, err := m.Reader.Read(p) fn := func() { m.Progress = m.Progress + float64(n) } m.vx.PostEvent(fn) return n, err } // Write counts the bytes written to Writer and sends the Model an updated // progress message. The Total field should be set to an expected value for this // to work properly func (m *Model) Write(p []byte) (int, error) { n, err := m.Writer.Write(p) fn := func() { m.Progress = m.Progress + float64(n) } m.vx.PostEvent(fn) return n, err } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/scrollbar/000077500000000000000000000000001476577054500240225ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/widgets/scrollbar/scrollbar.go000066400000000000000000000016441476577054500263410ustar00rootroot00000000000000package scrollbar import "git.sr.ht/~rockorager/vaxis" type Model struct { // The character to display for the bar, defaults to '▐' Character vaxis.Character Style vaxis.Style // Number of items in the scrolling area TotalHeight int // Number of items in the visible area ViewHeight int // Index of the item at the top of the visible area Top int } var defaultChar = vaxis.Character{ Grapheme: "▐", Width: 1, } func (m *Model) Draw(win vaxis.Window) { if m.TotalHeight < 1 { return } if m.ViewHeight >= m.TotalHeight { // Only draw if needed return } _, h := win.Size() barH := (m.ViewHeight * h) / m.TotalHeight if barH < 1 { barH = 1 } barTop := (m.Top * h) / m.TotalHeight if m.Character.Grapheme == "" { m.Character = defaultChar } for i := 0; i < barH; i += 1 { cell := vaxis.Cell{ Character: m.Character, Style: m.Style, } win.SetCell(0, barTop+i, cell) } } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/spinner/000077500000000000000000000000001476577054500235155ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/widgets/spinner/spinner.go000066400000000000000000000037331476577054500255300ustar00rootroot00000000000000package spinner import ( "context" "sync" "time" "git.sr.ht/~rockorager/vaxis" ) // Model is a spinner. It has a duration and a set of frames. It will request // partial-draws using the last provided Surface at the duration specified type Model struct { Duration time.Duration Frames []rune Style vaxis.Style frame int mu sync.Mutex spinning bool cancel context.CancelFunc vx *vaxis.Vaxis } // New creates a new spinner func New(vx *vaxis.Vaxis, dur time.Duration) *Model { return &Model{ Frames: []rune{'-', '\\', '|', '/'}, Duration: dur, vx: vx, } } func (m *Model) Draw(w vaxis.Window) { m.mu.Lock() defer m.mu.Unlock() if m.spinning { w.SetCell(0, 0, vaxis.Cell{ Character: vaxis.Character{ Grapheme: string(m.Frames[m.frame]), Width: 1, }, Style: m.Style, }) } } // Start the spinner. Start is thread safe and non-blocking func (m *Model) Start() { m.vx.SyncFunc(func() { m.start() }) } func (m *Model) start() { if m.spinning { return } if len(m.Frames) == 0 { m.Frames = []rune{'-', '\\', '|', '/'} } var ctx context.Context ctx, m.cancel = context.WithCancel(context.Background()) m.spinning = true ticker := time.NewTicker(m.Duration) go func() { defer func() { if err := recover(); err != nil { m.vx.Close() panic(err) } }() for { select { case <-ctx.Done(): ticker.Stop() return case <-ticker.C: m.mu.Lock() m.frame = (m.frame + 1) % len(m.Frames) m.vx.PostEvent(vaxis.Redraw{}) m.mu.Unlock() } } }() } // Stop the spinner. Stop is thread safe and non-blocking func (m *Model) Stop() { m.vx.SyncFunc(func() { m.stop() }) } func (m *Model) stop() { if m.cancel != nil { m.cancel() } m.spinning = false } // Toggle the spinner. Stop is thread safe and non-blocking func (m *Model) Toggle() { m.vx.SyncFunc(func() { m.toggle() }) } func (m *Model) toggle() { on := m.spinning if on { m.stop() return } m.start() } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/000077500000000000000000000000001476577054500230065ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/c0.go000066400000000000000000000017151476577054500236430ustar00rootroot00000000000000package term func (vt *Model) c0(r rune) { switch r { case 0x07: vt.postEvent(EventBell{}) case 0x08: vt.bs() case 0x09: vt.ht() case 0x0A: vt.lf() case 0x0B: vt.vt() case 0x0C: vt.ff() case 0x0D: vt.cr() case 0x0E: vt.charsets.selected = g1 case 0x0F: vt.charsets.selected = g2 } } // Backspace 0x08 func (vt *Model) bs() { vt.lastCol = false if vt.cursor.col == vt.margin.left { if vt.cursor.row == vt.margin.top { return } // reverse wrap vt.cursor.col = vt.margin.right vt.cursor.row -= 1 return } vt.cursor.col -= 1 } // Horizontal tab 0x09 func (vt *Model) ht() { vt.cht(1) } // Linefeed 0x10 func (vt *Model) lf() { vt.ind() if !vt.mode.lnm { return } vt.cursor.col = vt.margin.left } // Vertical tabulation 0x11 func (vt *Model) vt() { vt.lf() } // Form feed 0x12 func (vt *Model) ff() { vt.lf() } // Carriage return 0x13 func (vt *Model) cr() { vt.lastCol = false vt.cursor.col = vt.margin.left } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/cell.go000066400000000000000000000014341476577054500242560ustar00rootroot00000000000000package term import ( "git.sr.ht/~rockorager/vaxis" ) type cell struct { vaxis.Cell wrapped bool } func (c *cell) rune() string { if c.Grapheme == "" { return " " } return c.Grapheme } // Erasing removes characters from the screen without affecting other characters // on the screen. Erased characters are lost. The cursor position does not // change when erasing characters or lines. Erasing resets the attributes, but // applies the background color of the passed style func (c *cell) erase(bg vaxis.Color) { c.Grapheme = "" c.Attribute = 0 c.UnderlineStyle = vaxis.UnderlineOff c.Background = bg c.Hyperlink = "" c.HyperlinkParams = "" c.Width = 0 } // selectiveErase removes the cell content, but keeps the attributes func (c *cell) selectiveErase() { c.Grapheme = " " } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/charset.go000066400000000000000000000032641476577054500247730ustar00rootroot00000000000000package term type charset int const ( ascii charset = iota decSpecialAndLineDrawing ) type charsets struct { designations map[charsetDesignator]charset selected charsetDesignator saved charsetDesignator singleShift bool } type charsetDesignator int const ( g0 = iota g1 g2 g3 ) var decSpecial = map[byte]rune{ 0x5f: 0x00A0, // NO-BREAK SPACE 0x60: 0x25C6, // BLACK DIAMOND 0x61: 0x2592, // MEDIUM SHADE 0x62: 0x2409, // SYMBOL FOR HORIZONTAL TABULATION 0x63: 0x240C, // SYMBOL FOR FORM FEED 0x64: 0x240D, // SYMBOL FOR CARRIAGE RETURN 0x65: 0x240A, // SYMBOL FOR LINE FEED 0x66: 0x00B0, // DEGREE SIGN 0x67: 0x00B1, // PLUS-MINUS SIGN 0x68: 0x2424, // SYMBOL FOR NEWLINE 0x69: 0x240B, // SYMBOL FOR VERTICAL TABULATION 0x6a: 0x2518, // BOX DRAWINGS LIGHT UP AND LEFT 0x6b: 0x2510, // BOX DRAWINGS LIGHT DOWN AND LEFT 0x6c: 0x250C, // BOX DRAWINGS LIGHT DOWN AND RIGHT 0x6d: 0x2514, // BOX DRAWINGS LIGHT UP AND RIGHT 0x6e: 0x253C, // BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL 0x6f: 0x23BA, // HORIZONTAL SCAN LINE-1 0x70: 0x23BB, // HORIZONTAL SCAN LINE-3 0x71: 0x2500, // BOX DRAWINGS LIGHT HORIZONTAL 0x72: 0x23BC, // HORIZONTAL SCAN LINE-7 0x73: 0x23BD, // HORIZONTAL SCAN LINE-9 0x74: 0x251C, // BOX DRAWINGS LIGHT VERTICAL AND RIGHT 0x75: 0x2524, // BOX DRAWINGS LIGHT VERTICAL AND LEFT 0x76: 0x2534, // BOX DRAWINGS LIGHT UP AND HORIZONTAL 0x77: 0x252C, // BOX DRAWINGS LIGHT DOWN AND HORIZONTAL 0x78: 0x2502, // BOX DRAWINGS LIGHT VERTICAL 0x79: 0x2264, // LESS-THAN OR EQUAL TO 0x7a: 0x2265, // GREATER-THAN OR EQUAL TO 0x7b: 0x03C0, // GREEK SMALL LETTER PI 0x7c: 0x2260, // NOT EQUAL TO 0x7d: 0x00A3, // POUND SIGN 0x7e: 0x00B7, // MIDDLE DOT } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/csi.go000066400000000000000000000343651476577054500241260ustar00rootroot00000000000000package term import ( "fmt" "strings" "git.sr.ht/~rockorager/vaxis" ) func (vt *Model) csi(csi string, params [][]int) { switch csi { case "@": vt.ich(ps(params)) case "A": vt.cuu(ps(params)) case "B": vt.cud(ps(params)) case "C": vt.cuf(ps(params)) case "D": vt.cub(ps(params)) case "E": vt.cnl(ps(params)) case "F": vt.cpl(ps(params)) case "G": vt.cha(ps(params)) case "H": vt.cup(params) case "I": vt.cht(ps(params)) case "J": vt.ed(ps(params)) case "K": vt.el(ps(params)) case "L": vt.il(ps(params)) case "M": vt.dl(ps(params)) case "P": vt.dch(ps(params)) case "S": ps := ps(params) if ps == 0 { ps = 1 } vt.scrollUp(ps) case "T": // 5 params is XTHIMOUSE, ignore if len(params) == 5 { return } ps := ps(params) if ps == 0 { ps = 1 } vt.scrollDown(ps) case "X": vt.ech(ps(params)) case "Z": vt.cbt(ps(params)) case "`": vt.hpa(ps(params)) case "a": vt.hpr(ps(params)) case "b": vt.rep(ps(params)) case "c": // Send device attributes resp := strings.Builder{} // Response introducer resp.WriteString("\x1B[?") // We are a vt220 resp.WriteString("62;") // We have sixel support resp.WriteString("4;") // We have ANSI color support resp.WriteString("22") // Response terminator resp.WriteString("c") vt.pty.WriteString(resp.String()) case ">c": // vt220 vt.pty.WriteString("\x1b[>1;0;0c") case "d": vt.vpa(ps(params)) case "e": vt.vpr(ps(params)) case "f": // Same as CUP vt.cup(params) case "g": vt.tbc(ps(params)) case "h": vt.sm(params) case "?h": vt.decset(params) case "l": vt.rm(params) case "?l": vt.decrst(params) case "m": vt.sgr(params) case "n": // Send device status report switch ps(params) { case 5: // "Ok" vt.pty.WriteString("\x1B[0n") case 6: // report cursor position // This sequence can be identical to a function key? // CSI r ; c R resp := fmt.Sprintf("\x1B[%d;%dR", vt.cursor.row+1, vt.cursor.col+1) vt.pty.WriteString(resp) } case "$p": // TODO: DECRQM for ANSI modes case "?$p": // DECRQM vt.decrqm(ps(params)) case "r": vt.decstbm(params) case "s": vt.decsc() case "u": vt.decrc() case " q": vt.cursor.style = vaxis.CursorStyle(ps(params)) } } // Returns a single parameter from a slice of parameters, or 0 if the slice is // empty func ps(params [][]int) int { var ps int if len(params) > 0 { ps = params[0][0] } return ps } // Insert Blank Character (ICH) CSI Ps @ // Insert Ps blank characters. Cursor does not change position. func (vt *Model) ich(ps int) { if ps == 0 { ps = 1 } col := vt.cursor.col row := vt.cursor.row line := vt.activeScreen[row] for i := vt.margin.right; i > col; i -= 1 { if (i - column(ps)) < 0 { continue } line[i] = line[i-column(ps)] } for i := 0; i < ps; i += 1 { if int(col)+i >= (vt.width() - 1) { break } line[col+column(i)] = cell{ Cell: vaxis.Cell{ Character: vaxis.Character{ Grapheme: " ", Width: 1, }, }, } } } // Cursur Up (CUU) CSI Ps A // Move cursor up in same column, stopping at top margin func (vt *Model) cuu(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } clamp := row(0) if vt.cursor.row >= vt.margin.top { clamp = vt.margin.top } vt.cursor.row -= row(ps) if vt.cursor.row < clamp { vt.cursor.row = clamp } } // Cursur Down (CUD) CSI Ps B // Move cursor down in same column, stopping at bottom margin func (vt *Model) cud(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } vt.cursor.row += row(ps) if vt.cursor.row > vt.margin.bottom { vt.cursor.row = vt.margin.bottom } } // Cursur Forward (CUF) CSI Ps C // Move cursor forward Ps columns, stopping at the right margin func (vt *Model) cuf(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } vt.cursor.col += column(ps) if vt.cursor.col > vt.margin.right { vt.cursor.col = vt.margin.right } } // Cursur Backward (CUB) CSI Ps D // Move cursor backward Ps columns, stopping at the left margin func (vt *Model) cub(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } vt.cursor.col -= column(ps) if vt.cursor.col < vt.margin.left { vt.cursor.col = vt.margin.left } } // Cursor Next Line (CNL) CSI Ps E // Move cursor to left margin Ps lines down, scrolling if necessary func (vt *Model) cnl(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } for i := 0; i < ps; i += 1 { vt.nel() } } // Cursor Preceding Line (CPL) CSI Ps F // Move cursor to left margin Ps lines down, scrolling if necessary func (vt *Model) cpl(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } for i := 0; i < ps; i += 1 { vt.ri() } vt.cursor.col = vt.margin.left } // Cursor Character Absolute (CHA) CSI Ps G // Move cursor to Ps column, stopping at right/left margin. Default is 1, but we // default to 0 since our columns our 0 indexed func (vt *Model) cha(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } vt.cursor.col = column(ps - 1) if vt.cursor.col > vt.margin.right { vt.cursor.col = vt.margin.right } if vt.cursor.col < vt.margin.left { vt.cursor.col = vt.margin.left } } // Cursor Position (CUP) CSI Ps;Ps H // Move cursor to the absolute position func (vt *Model) cup(pm [][]int) { vt.lastCol = false switch len(pm) { case 0: vt.cursor.row = 0 vt.cursor.col = 0 case 1: vt.cursor.row = row(pm[0][0] - 1) vt.cursor.col = 0 case 2: vt.cursor.row = row(pm[0][0] - 1) vt.cursor.col = column(pm[1][0] - 1) } if vt.cursor.col > column(vt.width()-1) { vt.cursor.col = column(vt.width() - 1) } if vt.cursor.row > row(vt.height()-1) { vt.cursor.row = row(vt.height() - 1) } } // Cursor Forward Tabulation (CHT) CSI Ps I // Move cursor forward Ps tab stops func (vt *Model) cht(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } n := 0 for _, ts := range vt.tabStop { if n == ps { break } if vt.cursor.col > ts { continue } vt.cursor.col = ts n += 1 } } // Erase in Display (ED) CSI Ps J func (vt *Model) ed(ps int) { switch ps { // Erases from the cursor to the end of the screen, including the cursor // position. Line attribute becomes single-height, single-width for all // completely erased lines. case 0: vt.lastCol = false for r := vt.cursor.row; r < row(vt.height()); r += 1 { for col := column(0); col < column(vt.width()); col += 1 { if r == vt.cursor.row && col < vt.cursor.col { // Don't erase current row before cursor continue } vt.activeScreen[r][col].erase(vt.cursor.Style.Background) } } // Erases from the beginning of the screen to the cursor, including the // cursor position. Line attribute becomes single-height, single-width // for all completely erased lines. case 1: vt.lastCol = false for r := row(0); r <= vt.cursor.row; r += 1 { for col := column(0); col < column(vt.width()); col += 1 { if r == vt.cursor.row && col > vt.cursor.col { // Don't erase current row after current // column break } vt.activeScreen[r][col].erase(vt.cursor.Style.Background) } } // Erases the complete display. All lines are erased and changed to // single-width. The cursor does not move. case 2: vt.lastCol = false for r := row(0); r < row(vt.height()); r += 1 { for col := column(0); col < column(vt.width()); col += 1 { vt.activeScreen[r][col].erase(vt.cursor.Style.Background) } } } } // Erase in Line (EL) CSI Ps K func (vt *Model) el(ps int) { r := vt.cursor.row vt.lastCol = false switch ps { // Erases from the cursor to the end of the line, including the cursor // position. Line attribute is not affected. case 0: for col := vt.cursor.col; col < column(vt.width()); col += 1 { vt.activeScreen[r][col].erase(vt.cursor.Style.Background) } // Erases from the beginning of the line to the cursor, including the // cursor position. Line attribute is not affected. case 1: for col := column(0); col <= vt.cursor.col; col += 1 { vt.activeScreen[r][col].erase(vt.cursor.Style.Background) } // Erases the complete line. case 2: for col := column(0); col < column(vt.width()); col += 1 { vt.activeScreen[r][col].erase(vt.cursor.Style.Background) } } } // Insert Lines (IL) CSI Ps L // // Insert Ps lines at the cursor. If fewer than Ps lines remain from the current // line to the end of the scrolling region, the number of lines inserted is the // lesser number. Lines within the scrolling region at and below the cursor move // down. Lines moved past the bottom margin are lost. The cursor is reset to the // first column. This sequence is ignored when the cursor is outside the // scrolling region. func (vt *Model) il(ps int) { vt.lastCol = false if vt.cursor.row < vt.margin.top { return } if vt.cursor.row > vt.margin.bottom { return } if vt.cursor.col < vt.margin.left { return } if vt.cursor.col > vt.margin.right { return } if ps == 0 { ps = 1 } if int(vt.margin.bottom-vt.cursor.row) < (ps - 1) { ps = int(vt.margin.bottom - vt.cursor.row) } // move the lines first for r := vt.margin.bottom; r >= (vt.cursor.row + row(ps)); r -= 1 { copy(vt.activeScreen[r], vt.activeScreen[r-row(ps)]) } // insert the blank lines (we do this by erasing the cells) for r := row(0); r < row(ps); r += 1 { for col := vt.margin.left; col <= vt.margin.right; col += 1 { vt.activeScreen[vt.cursor.row+r][col].erase(vt.cursor.Style.Background) } } vt.cursor.col = vt.margin.left } // Delete Line (DL) CSI Ps M // // Deletes Ps lines starting at the line with the cursor. If fewer than Ps lines // remain from the current line to the end of the scrolling region, the number // of lines deleted is the lesser number. As lines are deleted, lines within the // scrolling region and below the cursor move up, and blank lines are added at // the bottom of the scrolling region. The cursor is reset to the first column. // This sequence is ignored when the cursor is outside the scrolling region. func (vt *Model) dl(ps int) { vt.lastCol = false if vt.cursor.row < vt.margin.top { return } if vt.cursor.row > vt.margin.bottom { return } if vt.cursor.col < vt.margin.left { return } if vt.cursor.col > vt.margin.right { return } if ps == 0 { ps = 1 } if int(vt.margin.bottom-vt.cursor.row) < (ps - 1) { ps = int(vt.margin.bottom - vt.cursor.row) } for r := vt.cursor.row; r <= vt.margin.bottom; r += 1 { if r <= vt.margin.bottom-row(ps) { copy(vt.activeScreen[r], vt.activeScreen[r+row(ps)]) continue } for col := vt.margin.left; col <= vt.margin.right; col += 1 { vt.activeScreen[r][col].erase(vt.cursor.Style.Background) } } vt.cursor.col = vt.margin.left } // Delete Characters (DCH) CSI Ps P // // Deletes Ps characters starting with the character at the cursor position. // When a character is deleted, all characters to the right of the cursor move // to the left. This creates a space character at the right margin for each // character deleted. Character attributes move with the characters. The spaces // created at the end of the line have all their character attributes off. func (vt *Model) dch(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } row := vt.cursor.row for col := vt.cursor.col; col <= vt.margin.right; col += 1 { if col+column(ps) > vt.margin.right { vt.activeScreen[row][col].erase(vt.cursor.Style.Background) continue } vt.activeScreen[row][col] = vt.activeScreen[row][col+column(ps)] } } // Erase Characters (ECH) CSI Ps X // // Erases characters at the cursor position and the next Ps-1 characters. A // parameter of 0 or 1 erases a single character. Character attributes are set // to normal. No reformatting of data on the line occurs. The cursor remains in // the same position. func (vt *Model) ech(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } for i := column(0); i < column(ps); i += 1 { if vt.cursor.col+i == column(vt.width()) { return } vt.activeScreen[vt.cursor.row][vt.cursor.col+i].erase(vt.cursor.Style.Background) } } // Cursor Backward Tabulation (CBT) CSI Ps Z // // Move cursor backward Ps tabulations func (vt *Model) cbt(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } n := 0 for i := len(vt.tabStop) - 1; i >= 0; i -= 1 { if n == ps { break } if vt.cursor.col < vt.tabStop[i] { break } vt.cursor.col = vt.tabStop[i] n += 1 } } // Tab Clear (TBC) CSI Ps g func (vt *Model) tbc(ps int) { switch ps { case 0: tabs := []column{} for _, tab := range vt.tabStop { if tab == vt.cursor.col { continue } tabs = append(tabs, tab) } vt.tabStop = tabs case 3: vt.tabStop = []column{} } } // Line Position Absolute (VPA) CSI Ps d // // Move cursor to line Ps func (vt *Model) vpa(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } vt.cursor.row = row(ps - 1) if vt.cursor.row > row(vt.height()-1) { vt.cursor.row = row(vt.height() - 1) } } // Line Position Relative (VPR) CSI Ps e // // Move down Ps lines func (vt *Model) vpr(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } vt.cursor.row += row(ps) if vt.cursor.row > row(vt.height()-1) { vt.cursor.row = row(vt.height() - 1) } } // Character Position Absolute (HPA) CSI Ps ` // // Move cursor to column Ps func (vt *Model) hpa(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } vt.cursor.col = column(ps - 1) if vt.cursor.col > column(vt.width()-1) { vt.cursor.col = column(vt.width() - 1) } } // Character Position Relative (HPR) CSI Ps a // // Move cursor to the right Ps times func (vt *Model) hpr(ps int) { vt.lastCol = false if ps == 0 { ps = 1 } vt.cursor.col += column(ps) if vt.cursor.col > column(vt.width()-1) { vt.cursor.col = column(vt.width() - 1) } } // Repeat (REP) CSI Ps b // // Repeat preceding graphic character Ps times func (vt *Model) rep(ps int) { vt.lastCol = false col := vt.cursor.col if col == 0 { return } ch := vt.activeScreen[vt.cursor.row][col-1] for i := 0; i < ps; i += 1 { if col+column(i) == vt.margin.right { return } vt.activeScreen[vt.cursor.row][vt.cursor.col+column(i)].Character = ch.Character } } // Set top and bottom margins CSI Ps ; Ps r func (vt *Model) decstbm(pm [][]int) { var ( top row bot row ) switch len(pm) { case 0: top = 0 bot = row(vt.height()) - 1 case 1: top = row(pm[0][0] - 1) bot = row(vt.height()) - 1 case 2: top = row(pm[0][0] - 1) bot = row(pm[1][0] - 1) } if top >= bot { return } vt.lastCol = false vt.margin.top = top vt.margin.bottom = bot vt.cursor.row = 0 vt.cursor.col = 0 } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/cursor.go000066400000000000000000000002651476577054500246550ustar00rootroot00000000000000package term import ( "git.sr.ht/~rockorager/vaxis" ) type cursor struct { vaxis.Cell style vaxis.CursorStyle // position row row // 0-indexed col column // 0-indexed } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/esc.go000066400000000000000000000073451476577054500241200ustar00rootroot00000000000000package term func (vt *Model) esc(esc string) { switch esc { case "7": vt.decsc() case "8": vt.decrc() case "D": vt.ind() case "E": vt.nel() case "H": vt.hts() case "M": vt.ri() case "N": vt.charsets.singleShift = true vt.charsets.selected = g2 case "O": vt.charsets.singleShift = true vt.charsets.selected = g3 case "=": vt.mode.deckpam = true vt.mode.deckpnm = false case ">": vt.mode.deckpnm = true vt.mode.deckpam = false case "c": vt.ris() case "(0": vt.charsets.designations[g0] = decSpecialAndLineDrawing case ")0": vt.charsets.designations[g1] = decSpecialAndLineDrawing case "*0": vt.charsets.designations[g2] = decSpecialAndLineDrawing case "+0": vt.charsets.designations[g3] = decSpecialAndLineDrawing case "(B": vt.charsets.designations[g0] = ascii case ")B": vt.charsets.designations[g1] = ascii case "*B": vt.charsets.designations[g2] = ascii case "+B": vt.charsets.designations[g3] = ascii case "#8": // DECALN // Fill the screen with capital Es // Not supported } } // Index ESC-D func (vt *Model) ind() { vt.lastCol = false if vt.cursor.row == vt.margin.bottom { vt.scrollUp(1) return } if vt.cursor.row >= row(vt.height()-1) { // don't let row go beyond the height return } vt.cursor.row += 1 } // Next line ESC-E // Moves cursor to the left margin of the next line, scrolling if necessary func (vt *Model) nel() { vt.ind() vt.cursor.col = vt.margin.left } // Horizontal tab set ESC-H func (vt *Model) hts() { vt.tabStop = append(vt.tabStop, vt.cursor.col) } // Reverse Index ESC-M func (vt *Model) ri() { vt.lastCol = false if vt.cursor.row < 0 { return } if vt.cursor.row == vt.margin.top { vt.scrollDown(1) return } vt.cursor.row -= 1 } // Save Cursor DECSC ESC-7 func (vt *Model) decsc() { state := cursorState{ cursor: vt.cursor, decawm: vt.mode.decawm, decom: vt.mode.decom, charsets: charsets{ selected: vt.charsets.selected, saved: vt.charsets.saved, designations: map[charsetDesignator]charset{ g0: vt.charsets.designations[g0], g1: vt.charsets.designations[g1], g2: vt.charsets.designations[g2], g3: vt.charsets.designations[g3], }, }, } switch { case vt.mode.smcup: // We are in alt screen vt.altState = state default: vt.primaryState = state } } // Restore Cursor DECRC ESC-8 func (vt *Model) decrc() { var state cursorState switch { case vt.mode.smcup: // In the alt screen state = vt.altState default: state = vt.primaryState } vt.cursor = state.cursor vt.charsets = charsets{ selected: state.charsets.selected, saved: state.charsets.saved, designations: map[charsetDesignator]charset{ g0: state.charsets.designations[g0], g1: state.charsets.designations[g1], g2: state.charsets.designations[g2], g3: state.charsets.designations[g3], }, } vt.mode.decawm = state.decawm vt.mode.decom = state.decom // Reset wrap state vt.lastCol = false } // Reset Initial State (RIS) ESC-c func (vt *Model) ris() { w := vt.width() h := vt.height() vt.altScreen = make([][]cell, h) vt.primaryScreen = make([][]cell, h) for i := range vt.altScreen { vt.altScreen[i] = make([]cell, w) vt.primaryScreen[i] = make([]cell, w) } vt.margin.bottom = row(h) - 1 vt.margin.right = column(w) - 1 vt.cursor.row = 0 vt.cursor.col = 0 vt.lastCol = false vt.activeScreen = vt.primaryScreen vt.charsets = charsets{ selected: 0, saved: 0, designations: map[charsetDesignator]charset{ g0: ascii, g1: ascii, g2: ascii, g3: ascii, }, } vt.mode = mode{ decawm: true, dectcem: true, } vt.setDefaultTabStops() } func (vt *Model) setDefaultTabStops() { vt.tabStop = []column{} for i := 8; i < (50 * 7); i += 8 { vt.tabStop = append(vt.tabStop, column(i)) } } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/events.go000066400000000000000000000005451476577054500246450ustar00rootroot00000000000000package term // EventBell is emitted when BEL is received type EventBell struct{} type EventPanic error type EventClosed struct { Term *Model Error error } type EventTitle string type EventNotify struct { Title string Body string } // EventAPC is emitted when an APC sequence is received in the terminal type EventAPC struct { Payload string } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/image.go000066400000000000000000000005661476577054500244260ustar00rootroot00000000000000package term import ( "image" "git.sr.ht/~rockorager/vaxis" ) type Image struct { origin struct { row int col int } img image.Image vaxii []*vaxisImage } type vaxisImage struct { // A handle on the vaxis that created this image. This is in case // multiple vaxis instances are connected to the same term widget vx *vaxis.Vaxis vxImage vaxis.Image } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/key.go000066400000000000000000000116751476577054500241370ustar00rootroot00000000000000package term import ( "bytes" "fmt" "unicode" "git.sr.ht/~rockorager/vaxis" ) // TODO we assume it's always application keys. Add in the right modes and // encode properly func encodeXterm(key vaxis.Key, deckpam bool, decckm bool) string { // ignore any kitty mods xtermMods := key.Modifiers & vaxis.ModShift xtermMods |= key.Modifiers & vaxis.ModAlt xtermMods |= key.Modifiers & vaxis.ModCtrl if xtermMods == 0 { // function keys if val, ok := keymap[key.Keycode]; ok { return val } switch decckm { case true: if val, ok := cursorKeysApplicationMode[key.Keycode]; ok { return val } case false: if val, ok := cursorKeysNormalMode[key.Keycode]; ok { return val } } switch deckpam { case true: // Special keys if val, ok := applicationKeymap[key.Keycode]; ok { return val } case false: // Special keys if val, ok := numericKeymap[key.Keycode]; ok { return val } } if key.Keycode < unicode.MaxRune { // Unicode keys return string(key.Keycode) } } if val, ok := xtermKeymap[key.Keycode]; ok { return fmt.Sprintf("\x1B[%d;%d%c", val.number, int(xtermMods)+1, val.final) } if key.Text != "" && key.Modifiers&vaxis.ModCtrl == 0 && key.Modifiers&vaxis.ModAlt == 0 { return key.Text } buf := bytes.NewBuffer(nil) if key.Keycode < unicode.MaxRune { if xtermMods&vaxis.ModAlt != 0 { buf.WriteRune('\x1b') } if xtermMods&vaxis.ModCtrl != 0 { if unicode.IsLower(key.Keycode) { buf.WriteRune(key.Keycode - 0x60) return buf.String() } switch key.Keycode { case '1': buf.WriteRune('1') case '2': buf.WriteRune(0x00) case '3': buf.WriteRune(0x1b) case '4': buf.WriteRune(0x1c) case '5': buf.WriteRune(0x1d) case '6': buf.WriteRune(0x1e) case '7': buf.WriteRune(0x1f) case '8': buf.WriteRune(0x7f) case '9': default: buf.WriteRune(key.Keycode - 0x40) } return buf.String() } if xtermMods&vaxis.ModShift != 0 { if key.ShiftedCode > 0 { buf.WriteRune(key.ShiftedCode) } else { buf.WriteRune(key.Keycode) } return buf.String() } buf.WriteRune(key.Keycode) return buf.String() } return "" } type keycode struct { number int final rune } var xtermKeymap = map[rune]keycode{ vaxis.KeyUp: {1, 'A'}, vaxis.KeyDown: {1, 'B'}, vaxis.KeyRight: {1, 'C'}, vaxis.KeyLeft: {1, 'D'}, vaxis.KeyEnd: {1, 'F'}, vaxis.KeyHome: {1, 'H'}, vaxis.KeyInsert: {2, '~'}, vaxis.KeyDelete: {3, '~'}, vaxis.KeyPgUp: {5, '~'}, vaxis.KeyPgDown: {6, '~'}, vaxis.KeyF01: {1, 'P'}, vaxis.KeyF02: {1, 'Q'}, vaxis.KeyF03: {1, 'R'}, vaxis.KeyF04: {1, 'S'}, vaxis.KeyF05: {15, '~'}, vaxis.KeyF06: {17, '~'}, vaxis.KeyF07: {18, '~'}, vaxis.KeyF08: {19, '~'}, vaxis.KeyF09: {20, '~'}, vaxis.KeyF10: {21, '~'}, vaxis.KeyF11: {23, '~'}, vaxis.KeyF12: {24, '~'}, } var cursorKeysApplicationMode = map[rune]string{ vaxis.KeyUp: "\x1BOA", vaxis.KeyDown: "\x1BOB", vaxis.KeyRight: "\x1BOC", vaxis.KeyLeft: "\x1BOD", vaxis.KeyEnd: "\x1BOF", vaxis.KeyHome: "\x1BOH", } var cursorKeysNormalMode = map[rune]string{ vaxis.KeyUp: "\x1B[A", vaxis.KeyDown: "\x1B[B", vaxis.KeyRight: "\x1B[C", vaxis.KeyLeft: "\x1B[D", vaxis.KeyEnd: "\x1B[F", vaxis.KeyHome: "\x1B[H", } // TODO are these needed? can we even detect this from the host? I guess we can // with kitty keyboard enabled on host but not in subterm. Double check keypad // arrows in application mode vs other arrows (CSI vs SS3?) var numericKeymap = map[rune]string{ vaxis.KeyInsert: "\x1B[2~", vaxis.KeyDelete: "\x1B[3~", vaxis.KeyPgUp: "\x1B[5~", vaxis.KeyPgDown: "\x1B[6~", } var applicationKeymap = map[rune]string{ vaxis.KeyInsert: "\x1B[2~", vaxis.KeyDelete: "\x1B[3~", vaxis.KeyPgUp: "\x1B[5~", vaxis.KeyPgDown: "\x1B[6~", } var keymap = map[rune]string{ vaxis.KeyF01: "\x1BOP", vaxis.KeyF02: "\x1BOQ", vaxis.KeyF03: "\x1BOR", vaxis.KeyF04: "\x1BOS", vaxis.KeyF05: "\x1B[15~", vaxis.KeyF06: "\x1B[17~", vaxis.KeyF07: "\x1B[18~", vaxis.KeyF08: "\x1B[19~", vaxis.KeyF09: "\x1B[20~", vaxis.KeyF10: "\x1B[21~", vaxis.KeyF11: "\x1B[23~", vaxis.KeyF12: "\x1B[24~", vaxis.KeyF13: "\x1B[1;2P", vaxis.KeyF14: "\x1B[1;2Q", vaxis.KeyF15: "\x1B[1;2R", vaxis.KeyF16: "\x1B[1;2S", vaxis.KeyF17: "\x1B[15;2~", vaxis.KeyF18: "\x1B[17;2~", vaxis.KeyF19: "\x1B[18;2~", vaxis.KeyF20: "\x1B[19;2~", vaxis.KeyF21: "\x1B[20;2~", vaxis.KeyF22: "\x1B[21;2~", vaxis.KeyF23: "\x1B[23;2~", vaxis.KeyF24: "\x1B[24;2~", vaxis.KeyF25: "\x1B[1;5P", vaxis.KeyF26: "\x1B[1;5Q", vaxis.KeyF27: "\x1B[1;5R", vaxis.KeyF28: "\x1B[1;5S", vaxis.KeyF29: "\x1B[15;5~", vaxis.KeyF30: "\x1B[17;5~", vaxis.KeyF31: "\x1B[18;5~", vaxis.KeyF32: "\x1B[19;5~", vaxis.KeyF33: "\x1B[20;5~", vaxis.KeyF34: "\x1B[21;5~", vaxis.KeyF35: "\x1B[23;5~", vaxis.KeyF36: "\x1B[24;5~", vaxis.KeyF37: "\x1B[1;6P", vaxis.KeyF38: "\x1B[1;6Q", vaxis.KeyF39: "\x1B[1;6R", vaxis.KeyF40: "\x1B[1;6S", // TODO add in the rest } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/mode.go000066400000000000000000000110771476577054500242670ustar00rootroot00000000000000package term import ( "fmt" ) type mode struct { // ANSI-Standardized modes // // Keyboard Action mode kam bool // Insert/Replace mode irm bool // Send/Receive mode srm bool // Line feed/new line mode lnm bool // ANSI-Compatible DEC Private Modes // // Cursor Key mode decckm bool // ANSI/VT52 mode decanm bool // Column mode deccolm bool // Scroll mode decsclm bool // Origin mode decom bool // Autowrap mode decawm bool // Autorepeat mode decarm bool // Printer form feed mode decpff bool // Printer extent mode decpex bool // Text Cursor Enable mode dectcem bool // National replacement character sets decnrcm bool // Application keypad deckpam bool // Normal keypad deckpnm bool // xterm // // Use alternate screen smcup bool // Bracketed paste paste bool // vt220 mouse mouseButtons bool // vt220 + drag mouseDrag bool // vt220 + all motion mouseMotion bool // Mouse SGR mode mouseSGR bool // Alternate scroll altScroll bool } func (vt *Model) sm(params [][]int) { for _, param := range params { switch param[0] { case 2: vt.mode.kam = true case 4: vt.mode.irm = true case 12: vt.mode.srm = true case 20: vt.mode.lnm = true } } } func (vt *Model) rm(params [][]int) { for _, param := range params { switch param[0] { case 2: vt.mode.kam = false case 4: vt.mode.irm = false case 12: vt.mode.srm = false case 20: vt.mode.lnm = false } } } func (vt *Model) decset(params [][]int) { for _, param := range params { switch param[0] { case 1: vt.mode.decckm = true case 2: vt.mode.decanm = true case 3: vt.mode.deccolm = true case 4: vt.mode.decsclm = true case 5: case 6: vt.mode.decom = true case 7: vt.mode.decawm = true vt.lastCol = false case 8: vt.mode.decarm = true case 25: vt.mode.dectcem = true case 1000: vt.mode.mouseButtons = true case 1002: vt.mode.mouseDrag = true case 1003: vt.mode.mouseMotion = true case 1006: vt.mode.mouseSGR = true case 1007: vt.mode.altScroll = true case 1049: vt.decsc() vt.activeScreen = vt.altScreen vt.mode.smcup = true // Enable altScroll in the alt screen. This is only used // if the application doesn't enable mouse vt.mode.altScroll = true case 2004: vt.mode.paste = true } } } func (vt *Model) decrst(params [][]int) { for _, param := range params { switch param[0] { case 1: vt.mode.decckm = false case 2: vt.mode.decanm = false case 3: vt.mode.deccolm = false case 4: vt.mode.decsclm = false case 5: case 6: vt.mode.decom = false case 7: vt.mode.decawm = false vt.lastCol = false case 8: vt.mode.decarm = false case 25: vt.mode.dectcem = false case 1000: vt.mode.mouseButtons = false case 1002: vt.mode.mouseDrag = false case 1003: vt.mode.mouseMotion = false case 1006: vt.mode.mouseSGR = false case 1007: vt.mode.altScroll = false case 1049: if vt.mode.smcup { // Only clear if we were in the alternate vt.ed(2) } vt.activeScreen = vt.primaryScreen vt.mode.smcup = false vt.mode.altScroll = false vt.decrc() case 2004: vt.mode.paste = false } } } func (vt *Model) decrqm(pd int) { ps := 0 switch pd { case 1: switch vt.mode.decckm { case true: ps = 1 case false: ps = 2 } case 2: switch vt.mode.decanm { case true: ps = 1 case false: ps = 2 } case 3: switch vt.mode.deccolm { case true: ps = 1 case false: ps = 2 } case 4: switch vt.mode.decsclm { case true: ps = 1 case false: ps = 2 } case 5: case 6: switch vt.mode.decom { case true: ps = 1 case false: ps = 2 } case 7: switch vt.mode.decawm { case true: ps = 1 case false: ps = 2 } case 8: switch vt.mode.decarm { case true: ps = 1 case false: ps = 2 } case 25: switch vt.mode.dectcem { case true: ps = 1 case false: ps = 2 } case 1000: switch vt.mode.mouseButtons { case true: ps = 1 case false: ps = 2 } case 1002: switch vt.mode.mouseDrag { case true: ps = 1 case false: ps = 2 } case 1003: switch vt.mode.mouseMotion { case true: ps = 1 case false: ps = 2 } case 1006: switch vt.mode.mouseSGR { case true: ps = 1 case false: ps = 2 } case 1007: switch vt.mode.altScroll { case true: ps = 1 case false: ps = 2 } case 1049: switch vt.mode.smcup { case true: ps = 1 case false: ps = 2 } case 2004: switch vt.mode.paste { case true: ps = 1 case false: ps = 2 } } fmt.Fprintf(vt.pty, "\x1B[?%d;%d$y", pd, ps) } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/mouse.go000066400000000000000000000027201476577054500244660ustar00rootroot00000000000000package term import ( "fmt" "git.sr.ht/~rockorager/vaxis" ) func (vt *Model) handleMouse(msg vaxis.Mouse) string { if !vt.mode.mouseButtons && !vt.mode.mouseDrag && !vt.mode.mouseMotion && !vt.mode.mouseSGR { if vt.mode.altScroll && vt.mode.smcup { // Translate wheel motion into arrows up and down // 3x rows if msg.Button == vaxis.MouseWheelUp { vt.pty.WriteString("\x1bOA") vt.pty.WriteString("\x1bOA") vt.pty.WriteString("\x1bOA") } if msg.Button == vaxis.MouseWheelDown { vt.pty.WriteString("\x1bOB") vt.pty.WriteString("\x1bOB") vt.pty.WriteString("\x1bOB") } } return "" } // Return early if we aren't reporting motion if !vt.mode.mouseMotion && msg.EventType == vaxis.EventMotion && msg.Button == vaxis.MouseNoButton { return "" } // Return early if we aren't reporting drags if !vt.mode.mouseDrag && msg.EventType == vaxis.EventMotion { return "" } if vt.mode.mouseSGR { switch msg.EventType { case vaxis.EventMotion: return fmt.Sprintf("\x1b[<%d;%d;%dM", msg.Button+32, msg.Col+1, msg.Row+1) case vaxis.EventPress: return fmt.Sprintf("\x1b[<%d;%d;%dM", msg.Button, msg.Col+1, msg.Row+1) case vaxis.EventRelease: return fmt.Sprintf("\x1b[<%d;%d;%dm", msg.Button, msg.Col+1, msg.Row+1) default: // unhandled return "" } } // legacy encoding encodedCol := 32 + msg.Col + 1 encodedRow := 32 + msg.Row + 1 return fmt.Sprintf("\x1b[M%c%c%c", msg.Button+32, encodedCol, encodedRow) } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/osc.go000066400000000000000000000035641476577054500241310ustar00rootroot00000000000000package term import ( "encoding/base64" "fmt" "git.sr.ht/~rockorager/vaxis/log" "strings" ) func (vt *Model) osc(data string) { selector, val, found := cutString(data, ";") if !found { return } switch selector { case "0", "2": vt.postEvent(EventTitle(val)) case "8": if vt.OSC8 { params, url, found := cutString(val, ";") if !found { return } vt.cursor.Hyperlink = url vt.cursor.HyperlinkParams = params } case "9": vt.postEvent(EventNotify{Body: val}) case "11": if vt.vx == nil { return } rgb := vt.vx.QueryBackground().Params() if len(rgb) == 0 { return } resp := fmt.Sprintf("\x1b]11;rgb:%02x/%02x/%02x\x07", rgb[0], rgb[1], rgb[2]) vt.pty.WriteString(resp) case "52": _, val, _ := cutString(val, ";") decodedBytes, err := base64.StdEncoding.DecodeString(val) if err != nil { log.Error("[term] error decoding Base64") return } vt.vx.ClipboardPush(string(decodedBytes)) case "777": selector, val, found := cutString(val, ";") if !found { return } switch selector { case "notify": title, body, found := cutString(val, ";") if !found { return } vt.postEvent(EventNotify{ Title: title, Body: body, }) } } } // parses an osc8 payload into the URL and optional ID func osc8(val string) (string, string) { // OSC 8 ; params ; url ST // params: key1=value1:key2=value2 var id string params, url, found := cutString(val, ";") if !found { return "", "" } for _, param := range strings.Split(params, ":") { key, val, found := cutString(param, "=") if !found { continue } switch key { case "id": id = val } } return url, id } // Copied from stdlib to here for go 1.16 compat func cutString(s string, sep string) (before string, after string, found bool) { if i := strings.Index(s, sep); i >= 0 { return s[:i], s[i+len(sep):], true } return s, "", false } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/sgr.go000066400000000000000000000137241476577054500241370ustar00rootroot00000000000000package term import ( "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/log" ) func (vt *Model) sgr(params [][]int) { if len(params) == 0 { params = [][]int{{0}} } for i := 0; i < len(params); i += 1 { switch params[i][0] { case 0: vt.cursor.Attribute = 0 vt.cursor.Foreground = 0 vt.cursor.Background = 0 vt.cursor.UnderlineColor = 0 vt.cursor.UnderlineStyle = vaxis.UnderlineOff case 1: vt.cursor.Attribute |= vaxis.AttrBold case 2: vt.cursor.Attribute |= vaxis.AttrDim case 3: vt.cursor.Attribute |= vaxis.AttrItalic case 4: switch len(params[i]) { case 1: vt.cursor.UnderlineStyle = vaxis.UnderlineSingle case 2: switch params[i][1] { case 0: vt.cursor.UnderlineStyle = vaxis.UnderlineOff case 1: vt.cursor.UnderlineStyle = vaxis.UnderlineSingle case 2: vt.cursor.UnderlineStyle = vaxis.UnderlineDouble case 3: vt.cursor.UnderlineStyle = vaxis.UnderlineCurly case 4: vt.cursor.UnderlineStyle = vaxis.UnderlineDotted case 5: vt.cursor.UnderlineStyle = vaxis.UnderlineDashed } } case 5: vt.cursor.Attribute |= vaxis.AttrBlink case 7: vt.cursor.Attribute |= vaxis.AttrReverse case 8: vt.cursor.Attribute |= vaxis.AttrInvisible case 9: vt.cursor.Attribute |= vaxis.AttrStrikethrough case 21: // Double underlined, not supported case 22: vt.cursor.Attribute &^= vaxis.AttrBold vt.cursor.Attribute &^= vaxis.AttrDim case 23: vt.cursor.Attribute &^= vaxis.AttrItalic case 24: vt.cursor.UnderlineStyle = vaxis.UnderlineOff case 25: vt.cursor.Attribute &^= vaxis.AttrBlink case 27: vt.cursor.Attribute &^= vaxis.AttrReverse case 28: vt.cursor.Attribute &^= vaxis.AttrInvisible case 29: vt.cursor.Attribute &^= vaxis.AttrStrikethrough case 30, 31, 32, 33, 34, 35, 36, 37: vt.cursor.Foreground = vaxis.IndexColor(uint8(params[i][0] - 30)) case 38: switch len(params[i]) { case 1: if len(params[i:]) < 3 { log.Error("[term] malformed SGR sequence") return } switch params[i+1][0] { case 2: if len(params[i:]) < 5 { log.Error("[term] malformed SGR sequence") return } vt.cursor.Foreground = vaxis.RGBColor( uint8(params[i+2][0]), uint8(params[i+3][0]), uint8(params[i+4][0]), ) i += 4 case 5: vt.cursor.Foreground = vaxis.IndexColor(uint8(params[i+2][0])) i += 2 default: log.Error("[term] malformed SGR sequence") return } case 3: if params[i][1] != 5 { log.Error("[term] malformed SGR sequence") return } vt.cursor.Foreground = vaxis.IndexColor(uint8(params[i][2])) case 5: if params[i][1] != 2 { log.Error("[term] malformed SGR sequence") return } vt.cursor.Foreground = vaxis.RGBColor( uint8(params[i][2]), uint8(params[i][3]), uint8(params[i][4]), ) case 6: if params[i][1] != 2 { log.Error("[term] malformed SGR sequence") return } vt.cursor.Foreground = vaxis.RGBColor( uint8(params[i][3]), uint8(params[i][4]), uint8(params[i][5]), ) } case 39: vt.cursor.Foreground = 0 case 40, 41, 42, 43, 44, 45, 46, 47: vt.cursor.Background = vaxis.IndexColor(uint8(params[i][0] - 40)) case 48: switch len(params[i]) { case 1: if len(params[i:]) < 3 { log.Error("[term] malformed SGR sequence") return } switch params[i+1][0] { case 2: if len(params[i:]) < 5 { log.Error("[term] malformed SGR sequence") return } vt.cursor.Background = vaxis.RGBColor( uint8(params[i+2][0]), uint8(params[i+3][0]), uint8(params[i+4][0]), ) i += 4 case 5: vt.cursor.Background = vaxis.IndexColor(uint8(params[i+2][0])) i += 2 default: log.Error("[term] malformed SGR sequence") return } case 3: if params[i][1] != 5 { log.Error("[term] malformed SGR sequence") return } vt.cursor.Background = vaxis.IndexColor(uint8(params[i][2])) case 5: if params[i][1] != 2 { log.Error("[term] malformed SGR sequence") return } vt.cursor.Background = vaxis.RGBColor( uint8(params[i][2]), uint8(params[i][3]), uint8(params[i][4]), ) case 6: if params[i][1] != 2 { log.Error("[term] malformed SGR sequence") return } vt.cursor.Background = vaxis.RGBColor( uint8(params[i][3]), uint8(params[i][4]), uint8(params[i][5]), ) } case 49: vt.cursor.Background = 0 case 58: switch len(params[i]) { case 1: if len(params[i:]) < 3 { log.Error("[term] malformed SGR sequence") return } switch params[i+1][0] { case 2: if len(params[i:]) < 5 { log.Error("[term] malformed SGR sequence") return } vt.cursor.UnderlineColor = vaxis.RGBColor( uint8(params[i+2][0]), uint8(params[i+3][0]), uint8(params[i+4][0]), ) i += 4 case 5: vt.cursor.UnderlineColor = vaxis.IndexColor(uint8(params[i+2][0])) i += 2 default: log.Error("[term] malformed SGR sequence") return } case 3: if params[i][1] != 5 { log.Error("[term] malformed SGR sequence") return } vt.cursor.UnderlineColor = vaxis.IndexColor(uint8(params[i][2])) case 5: if params[i][1] != 2 { log.Error("[term] malformed SGR sequence") return } vt.cursor.UnderlineColor = vaxis.RGBColor( uint8(params[i][2]), uint8(params[i][3]), uint8(params[i][4]), ) case 6: if params[i][1] != 2 { log.Error("[term] malformed SGR sequence") return } vt.cursor.UnderlineColor = vaxis.RGBColor( uint8(params[i][3]), uint8(params[i][4]), uint8(params[i][5]), ) } case 90, 91, 92, 93, 94, 95, 96, 97: vt.cursor.Foreground = vaxis.IndexColor(uint8(params[i][0] - 90 + 8)) case 100, 101, 102, 103, 104, 105, 106, 107: vt.cursor.Background = vaxis.IndexColor(uint8(params[i][0] - 100 + 8)) } } } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/term/term.go000066400000000000000000000324501476577054500243100ustar00rootroot00000000000000package term import ( "bytes" "fmt" "os" "os/exec" "runtime/debug" "strconv" "strings" "sync" "sync/atomic" "syscall" "time" "git.sr.ht/~rockorager/vaxis" "git.sr.ht/~rockorager/vaxis/ansi" "git.sr.ht/~rockorager/vaxis/log" "github.com/creack/pty" "github.com/mattn/go-sixel" ) type ( column int row int ) // Model models a virtual terminal type Model struct { // If true, OSC8 enables the output of OSC8 strings. Otherwise, any OSC8 // sequences will be stripped OSC8 bool // Set the TERM environment variable to be passed to the command's // environment. If not set, xterm-256color will be used TERM string mu sync.Mutex vx *vaxis.Vaxis activeScreen [][]cell altScreen [][]cell primaryScreen [][]cell charsets charsets cursor cursor margin margin mode mode sShift charset tabStop []column // lastCol is a flag indicating we printed in the last col lastCol bool primaryState cursorState altState cursorState cmd *exec.Cmd dirty bool parser *ansi.Parser pty *os.File rows int cols int eventHandler func(vaxis.Event) events chan vaxis.Event focused int32 graphics []*Image timer *time.Timer } type cursorState struct { charsets charsets cursor cursor decawm bool decom bool } type margin struct { top row bottom row left column right column } func New() *Model { m := &Model{ OSC8: true, charsets: charsets{ designations: map[charsetDesignator]charset{ g0: ascii, g1: ascii, g2: ascii, g3: ascii, }, }, mode: mode{ decawm: true, dectcem: true, }, primaryState: cursorState{ charsets: charsets{ designations: map[charsetDesignator]charset{ g0: ascii, g1: ascii, g2: ascii, g3: ascii, }, }, decawm: true, }, altState: cursorState{ charsets: charsets{ designations: map[charsetDesignator]charset{ g0: ascii, g1: ascii, g2: ascii, g3: ascii, }, }, decawm: true, }, eventHandler: func(ev vaxis.Event) {}, // Buffering to 2 events. If there is ever a case where one // sequence can trigger two events, this should be increased events: make(chan vaxis.Event, 2), timer: time.NewTimer(0), } m.setDefaultTabStops() return m } func (vt *Model) StartWithSize(cmd *exec.Cmd, width int, height int) error { if cmd == nil { return fmt.Errorf("no command to run") } vt.cmd = cmd if vt.TERM == "" { vt.TERM = "xterm-kitty" } env := os.Environ() if cmd.Env != nil { env = cmd.Env } cmd.Env = append(env, "TERM="+vt.TERM) // Start the command with a pty. var err error winsize := pty.Winsize{ Cols: uint16(width), Rows: uint16(height), } vt.pty, err = pty.StartWithAttrs( cmd, &winsize, &syscall.SysProcAttr{ Setsid: true, Setctty: true, Ctty: 1, }) if err != nil { return err } vt.resize(width, height) vt.parser = ansi.NewParser(vt.pty) go func() { defer vt.recover() for { select { case seq := <-vt.parser.Next(): switch seq := seq.(type) { case ansi.EOF: err := cmd.Wait() vt.eventHandler(EventClosed{ Term: vt, Error: err, }) return default: vt.update(seq) } case ev := <-vt.events: vt.eventHandler(ev) case <-vt.timer.C: vt.mu.Lock() vt.timer.Stop() vt.mu.Unlock() vt.eventHandler(vaxis.Redraw{}) } } }() return nil } // Start starts the terminal with the specified command. Start returns when the // command has been successfully started. func (vt *Model) Start(cmd *exec.Cmd) error { return vt.StartWithSize(cmd, 80, 24) } // Update is called from the host application. This is user input func (vt *Model) Update(msg vaxis.Event) { vt.mu.Lock() defer vt.mu.Unlock() vt.invalidate() switch msg := msg.(type) { case vaxis.Key: str := encodeXterm(msg, vt.mode.deckpam, vt.mode.decckm) vt.pty.WriteString(str) case vaxis.PasteStartEvent: if vt.mode.paste { vt.pty.WriteString("\x1B[200~") return } case vaxis.PasteEndEvent: if vt.mode.paste { vt.pty.WriteString("\x1B[201~") return } case vaxis.Mouse: mouse := vt.handleMouse(msg) vt.pty.WriteString(mouse) return } } // only call invalidate while a lock is held func (vt *Model) invalidate() { if vt.dirty { return } vt.dirty = true vt.timer.Reset(8 * time.Millisecond) } // update is called from the PTY routine...this is updating the internal model // based on the underlying process func (vt *Model) update(seq ansi.Sequence) { vt.mu.Lock() defer vt.mu.Unlock() defer vt.parser.Finish(seq) defer vt.invalidate() switch seq := seq.(type) { case ansi.Print: vt.print(seq) case ansi.C0: vt.c0(rune(seq)) case ansi.ESC: esc := append(seq.Intermediate, seq.Final) vt.esc(string(esc)) case ansi.CSI: csi := append(seq.Intermediate, seq.Final) vt.csi(string(csi), seq.Parameters) case ansi.OSC: vt.osc(string(seq.Payload)) case ansi.DCS: switch seq.Final { case 'q': // mayb sixel if len(seq.Intermediate) > 0 { return } if len(seq.Parameters) > 0 { return } // Write the raw sequence to the writer buf := bytes.NewBuffer(nil) // DCS buf.Write([]byte{'\x1B', 'P'}) // Params for i, p := range seq.Parameters { buf.WriteString(strconv.Itoa(p)) if i <= len(seq.Parameters)-1 { buf.WriteByte(';') } } // Final buf.WriteByte('q') // Data buf.WriteString(string(seq.Data)) // ST buf.Write([]byte{0x1B, '\\'}) // Decode the sixel log.Info("SIXEL %d", buf.Len()) dec := sixel.NewDecoder(buf) img := &Image{} img.origin.row = int(vt.cursor.row) img.origin.col = int(vt.cursor.col) err := dec.Decode(&img.img) if err != nil { log.Error("couldn't decode sixel: %v", err) return } vt.graphics = append(vt.graphics, img) } case ansi.APC: vt.postEvent(EventAPC{Payload: seq.Data}) } } func (vt *Model) String() string { vt.mu.Lock() defer vt.mu.Unlock() str := strings.Builder{} for row := range vt.activeScreen { for col := range vt.activeScreen[row] { _, _ = str.WriteString(vt.activeScreen[row][col].rune()) } if row < vt.height()-1 { str.WriteRune('\n') } } return str.String() } func (vt *Model) postEvent(ev vaxis.Event) { vt.events <- ev } func (vt *Model) Attach(fn func(ev vaxis.Event)) { vt.mu.Lock() defer vt.mu.Unlock() vt.eventHandler = fn } func (vt *Model) Detach() { vt.mu.Lock() defer vt.mu.Unlock() vt.eventHandler = func(ev vaxis.Event) {} } func (vt *Model) recover() { err := recover() if err == nil { return } ret := strings.Builder{} ret.WriteString(fmt.Sprintf("cursor row=%d col=%d\n", vt.cursor.row, vt.cursor.col)) ret.WriteString(fmt.Sprintf("margin left=%d right=%d\n", vt.margin.left, vt.margin.right)) ret.WriteString(fmt.Sprintf("%s\n", err)) ret.Write(debug.Stack()) vt.postEvent(EventPanic(fmt.Errorf(ret.String()))) vt.Close() } func (vt *Model) Resize(w int, h int) { vt.resize(w, h) _ = pty.Setsize(vt.pty, &pty.Winsize{ Cols: uint16(w), Rows: uint16(h), }) } func (vt *Model) resize(w int, h int) { primary := vt.primaryScreen vt.altScreen = make([][]cell, h) vt.primaryScreen = make([][]cell, h) for i := range vt.altScreen { vt.altScreen[i] = make([]cell, w) vt.primaryScreen[i] = make([]cell, w) } last := vt.cursor.row vt.margin.bottom = row(h) - 1 vt.margin.right = column(w) - 1 vt.cursor.row = 0 vt.cursor.col = 0 vt.lastCol = false vt.activeScreen = vt.primaryScreen // transfer primary to new, skipping the last row for row := 0; row < len(primary); row += 1 { if row == int(last) { break } wrapped := false for col := 0; col < len(primary[0]); col += 1 { cell := primary[row][col] vt.cursor.Style = cell.Style vt.print(ansi.Print{ Grapheme: cell.Character.Grapheme, Width: cell.Character.Width, }) wrapped = cell.wrapped } if !wrapped { vt.nel() } } switch vt.mode.smcup { case false: vt.activeScreen = vt.primaryScreen default: vt.activeScreen = vt.altScreen } } func (vt *Model) width() int { if len(vt.activeScreen) > 0 { return len(vt.activeScreen[0]) } return 0 } func (vt *Model) height() int { return len(vt.activeScreen) } // print sets the current cell contents to the given rune. The attributes will // be copied from the current cursor attributes func (vt *Model) print(seq ansi.Print) { if len(seq.Grapheme) == 1 && vt.charsets.designations[vt.charsets.selected] == decSpecialAndLineDrawing { shifted, ok := decSpecial[seq.Grapheme[0]] if ok { seq.Grapheme = string(shifted) } } // If we are single-shifted, move the previous charset into the current if vt.charsets.singleShift { vt.charsets.selected = vt.charsets.saved } w := seq.Width // handle wrapping var wrap bool // We printed in the last column last time if vt.lastCol { wrap = true } // We don't have room for this character so wrap if vt.cursor.col+column(w)-1 > vt.margin.right { wrap = true } // We aren't in wrap mode, never wrap if !vt.mode.decawm { wrap = false } if wrap { vt.lastCol = false vt.activeScreen[vt.cursor.row][vt.width()-1].wrapped = true vt.nel() } col := vt.cursor.col rw := vt.cursor.row if vt.mode.irm { line := vt.activeScreen[rw] for i := vt.margin.right; i > col; i -= 1 { line[i] = line[i-column(w)] } } if col > column(vt.width())-1 { col = column(vt.width()) - 1 } if rw > row(vt.height()-1) { rw = row(vt.height() - 1) } if w == 0 { if col-1 < 0 { return } return } cell := cell{ Cell: vaxis.Cell{ Character: vaxis.Character{ Grapheme: seq.Grapheme, Width: seq.Width, }, Style: vt.cursor.Style, }, } vt.activeScreen[rw][col] = cell // Set trailing cells to a space if wide rune for i := column(1); i < column(w); i += 1 { if col+i > vt.margin.right { break } vt.activeScreen[rw][col+i].Character.Grapheme = " " vt.activeScreen[rw][col+i].Style = vt.cursor.Style } switch { case !vt.mode.decawm && vt.cursor.col+column(w) > vt.margin.right: default: vt.cursor.col += column(w) } if vt.cursor.col >= vt.margin.right+1 && vt.mode.decawm { vt.lastCol = true } } // scrollUp shifts all text upward by n rows. Semantically, this is backwards - // usually scroll up would mean you shift rows down func (vt *Model) scrollUp(n int) { for row := range vt.activeScreen { if row > int(vt.margin.bottom) { continue } if row < int(vt.margin.top) { continue } if row+n > int(vt.margin.bottom) { for col := vt.margin.left; col <= vt.margin.right; col += 1 { vt.activeScreen[row][col].erase(vt.cursor.Style.Background) } continue } copy(vt.activeScreen[row], vt.activeScreen[row+n]) } } // scrollDown shifts all lines down by n rows. func (vt *Model) scrollDown(n int) { for r := vt.margin.bottom; r >= vt.margin.top; r -= 1 { if r-row(n) < vt.margin.top { for col := vt.margin.left; col <= vt.margin.right; col += 1 { vt.activeScreen[r][col].erase(vt.cursor.Style.Background) } continue } copy(vt.activeScreen[r], vt.activeScreen[r-row(n)]) } } func (vt *Model) Close() { vt.mu.Lock() defer vt.mu.Unlock() if vt.cmd != nil && vt.cmd.Process != nil { vt.cmd.Process.Kill() vt.cmd.Wait() } vt.pty.Close() } func (vt *Model) Draw(win vaxis.Window) { vt.mu.Lock() defer vt.mu.Unlock() vt.dirty = false width, height := win.Size() if int(width) != vt.width() || int(height) != vt.height() { win.Width = width win.Height = height vt.Resize(width, height) } for row := 0; row < vt.height(); row += 1 { for col := 0; col < vt.width(); { cell := vt.activeScreen[row][col] w := cell.Width if cell.Grapheme == "" { cell.Grapheme = " " } win.SetCell(col, row, cell.Cell) if w == 0 { w = 1 } col += w } } if vt.mode.dectcem && atomicLoad(&vt.focused) { win.ShowCursor(int(vt.cursor.col), int(vt.cursor.row), vt.cursor.style) } vx := win.Vx vt.vx = vx outer: for _, img := range vt.graphics { for _, imgVx := range img.vaxii { if vx != imgVx.vx { continue } // We have already created an image for this // Vaxis. All we have to do is draw it win := win.New(img.origin.col, img.origin.row, -1, -1) imgVx.vxImage.Draw(win) continue outer } // We haven't encountered this vaxis before vxImg, err := vx.NewImage(img.img) if err != nil { log.Error("couldn't create Vaxis image: %v", err) continue } // We "resize" the image to the full window size. This will // trigger the encoding vxImg.Resize(win.Size()) img.vaxii = append(img.vaxii, &vaxisImage{ vx: vx, vxImage: vxImg, }) } } func (vt *Model) Focus() { atomicStore(&vt.focused, true) } func (vt *Model) Blur() { atomicStore(&vt.focused, false) } // func (vt *VT) HandleEvent(e tcell.Event) bool { // vt.mu.Lock() // defer vt.mu.Unlock() // switch e := e.(type) { // case *tcell.EventKey: // vt.pty.WriteString(keyCode(e)) // return true // case *tcell.EventPaste: // switch { // case vt.mode&paste == 0: // return false // case e.Start(): // vt.pty.WriteString(info.PasteStart) // return true // case e.End(): // vt.pty.WriteString(info.PasteEnd) // return true // } // case *tcell.EventMouse: // str := vt.handleMouse(e) // vt.pty.WriteString(str) // } // return false // } func atomicLoad(val *int32) bool { return atomic.LoadInt32(val) == 1 } func atomicStore(addr *int32, val bool) { if val { atomic.StoreInt32(addr, 1) } else { atomic.StoreInt32(addr, 0) } } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/textinput/000077500000000000000000000000001476577054500241035ustar00rootroot00000000000000golang-sourcehut-rockorager-vaxis-0.13.0/widgets/textinput/autocomplete.go000066400000000000000000000020241476577054500271310ustar00rootroot00000000000000package textinput import ( "unicode" "git.sr.ht/~rockorager/vaxis" ) type AutoComplete struct { vx *vaxis.Vaxis input *Model complete func(string) []string original string completions []string option int } func NewAutoComplete(vx *vaxis.Vaxis, complete func(string) []string) *AutoComplete { m := &AutoComplete{ vx: vx, input: New(), complete: complete, } return m } func (a *AutoComplete) Update(msg vaxis.Event) { switch msg := msg.(type) { case vaxis.Key: if msg.EventType == vaxis.EventRelease { return } switch msg.String() { case "Enter": a.reset() case "Escape": a.input.SetContent(a.original) a.reset() case "Backspace": a.reset() default: if len(a.completions) > 0 && unicode.IsGraphic(msg.Keycode) { a.reset() } } } a.input.Update(msg) } func (a *AutoComplete) reset() { a.option = 0 a.completions = []string{} a.original = "" } func (a *AutoComplete) Draw(win vaxis.Window) { // col, row := win.Origin() a.input.Draw(win) } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/textinput/menu_complete.go000066400000000000000000000030611476577054500272660ustar00rootroot00000000000000package textinput import ( "unicode" "git.sr.ht/~rockorager/vaxis" ) type MenuComplete struct { input *Model complete func(string) []string original string completions []string option int } func NewMenuComplete(complete func(string) []string) *MenuComplete { m := &MenuComplete{ input: New(), complete: complete, } return m } func (m *MenuComplete) Update(msg vaxis.Event) { switch msg := msg.(type) { case vaxis.Key: if msg.EventType == vaxis.EventRelease { return } switch msg.String() { case "Tab": // Trigger completion switch len(m.completions) { case 0: m.option = 0 m.original = m.input.String() m.completions = m.complete(m.input.String()) m.completions = append(m.completions, m.original) default: m.option += 1 if m.option >= len(m.completions) { m.option = 0 } } if len(m.completions) > 0 { m.input.SetContent(m.completions[m.option]) } return case "Shift+Tab": if len(m.completions) > 0 { m.option -= 1 if m.option < 0 { m.option = len(m.completions) - 1 } m.input.SetContent(m.completions[m.option]) return } case "Enter": m.reset() case "Escape": m.input.SetContent(m.original) m.reset() case "Backspace": m.reset() default: if len(m.completions) > 0 && unicode.IsGraphic(msg.Keycode) { m.reset() } } } m.input.Update(msg) } func (m *MenuComplete) reset() { m.option = 0 m.completions = []string{} m.original = "" } func (m *MenuComplete) Draw(win vaxis.Window) { m.input.Draw(win) } golang-sourcehut-rockorager-vaxis-0.13.0/widgets/textinput/textinput.go000066400000000000000000000147171476577054500265100ustar00rootroot00000000000000package textinput import ( "strings" "unicode" "git.sr.ht/~rockorager/vaxis" "golang.org/x/exp/slices" ) const scrolloff = 4 var truncator = vaxis.Character{ Grapheme: "…", Width: 1, } type Model struct { content []vaxis.Character prompt []vaxis.Character Content vaxis.Style Prompt vaxis.Style // HideCursor tells the textinput not to draw the cursor HideCursor bool // invisibleChar, if set, will be displayed instead of the pressed keys invisibleChar vaxis.Character cursor int // the x position of the cursor, relative to the start of Content offset int paste []rune } func New() *Model { return &Model{} } func (m *Model) SetPrompt(s string) *Model { m.prompt = vaxis.Characters(s) return m } func (m *Model) SetContent(s string) *Model { m.content = vaxis.Characters(s) m.cursor = len(m.content) return m } // SetInvisibleChar will use the first character of s to be displayed in the // textinput field instead of the pressed keys (password mode). func (m *Model) SetInvisibleChar(s string) *Model { chars := vaxis.Characters(s) if len(chars) > 0 { m.invisibleChar = chars[0] } return m } // Characters returns the characters of the content func (m *Model) Characters() []vaxis.Character { return m.content } // CursorPosition returns the cursor position, in characters. A cursor position // of '0' means the cursor is at the beginning of the line func (m *Model) CursorPosition() int { return m.cursor } func (m *Model) String() string { buf := strings.Builder{} for _, ch := range m.content { buf.WriteString(ch.Grapheme) } return buf.String() } func (m *Model) Update(msg vaxis.Event) { switch msg := msg.(type) { case vaxis.PasteEndEvent: chars := vaxis.Characters(string(m.paste)) m.content = slices.Insert(m.content, m.cursor, chars...) m.cursor += len(chars) m.paste = []rune{} case vaxis.Key: if msg.EventType == vaxis.EventRelease { return } if msg.EventType == vaxis.EventPaste { m.paste = append(m.paste, []rune(msg.Text)...) return } switch msg.String() { case "Ctrl+a", "Home": // Beginning of line m.cursor = 0 case "Ctrl+e", "End": // End of line m.cursor = len(m.content) case "Ctrl+f", "Right": // forward one character m.cursor += 1 case "Ctrl+b", "Left": // backward one character m.cursor -= 1 case "Alt+f", "Ctrl+Right": // Forward one word // skip non-alphanumerics for i := m.cursor; i < len(m.content); i += 1 { if !isAlphaNumeric(m.content[i]) { m.cursor += 1 continue } break } for i := m.cursor; i < len(m.content); i += 1 { if isAlphaNumeric(m.content[i]) { m.cursor += 1 continue } break } case "Alt+b", "Ctrl+Left": // backward one word // skip non-alphanumerics m.cursor -= 1 if m.cursor >= len(m.content) { m.cursor = len(m.content) - 1 } for i := m.cursor; i >= 0; i -= 1 { if !isAlphaNumeric(m.content[i]) { m.cursor -= 1 continue } break } for i := m.cursor; i >= 0; i -= 1 { if isAlphaNumeric(m.content[i]) { m.cursor -= 1 continue } m.cursor += 1 break } case "Ctrl+d", "Delete": // delete character under cursor switch { case m.cursor == len(m.content): m.content = m.content[:m.cursor] default: m.content = append(m.content[:m.cursor], m.content[m.cursor+1:]...) } case "Ctrl+k": m.content = m.content[:m.cursor] case "Ctrl+u": m.content = m.content[m.cursor:] m.cursor = 0 case "Ctrl+h", "BackSpace": // delete character behind cursor switch { case m.cursor == 0: return case m.cursor == len(m.content): m.content = m.content[:m.cursor-1] default: m.content = append(m.content[:m.cursor-1], m.content[m.cursor:]...) } m.cursor -= 1 case "Ctrl+w": if m.cursor == 0 { return } originalCursor := m.cursor // skip non-alphanumerics backwards for i := m.cursor - 1; i >= 0; i-- { if !isAlphaNumeric(m.content[i]) { m.cursor-- continue } break } // skip alphanumerics backwards for i := m.cursor - 1; i >= 0; i-- { if isAlphaNumeric(m.content[i]) { m.cursor-- continue } break } m.content = append(m.content[:m.cursor], m.content[originalCursor:]...) default: if msg.Modifiers&vaxis.ModCtrl != 0 { return } if msg.Modifiers&vaxis.ModAlt != 0 { return } if msg.Modifiers&vaxis.ModSuper != 0 { return } if msg.Text != "" { chars := vaxis.Characters(msg.Text) for _, char := range chars { m.content = slices.Insert(m.content, m.cursor, char) m.cursor += 1 } } } } if m.cursor > len(m.content) { m.cursor = len(m.content) } if m.cursor < 0 { m.cursor = 0 } } func (m *Model) Draw(win vaxis.Window) { winW, _ := win.Size() if winW == 0 { return } win.Fill(vaxis.Cell{ Character: vaxis.Character{ Grapheme: " ", Width: 1, }, Style: m.Content, }) col := 0 for _, char := range m.prompt { cell := vaxis.Cell{ Character: char, Style: m.Prompt, } win.SetCell(col, 0, cell) col += char.Width if col >= winW { return } } chars := m.content cursor := col // Make sure we've scrolled enough to have the cursor in the view for widthToCursor(chars, m.cursor, m.offset)+col+scrolloff >= winW { m.offset += 1 } // Or we need to scroll toward beginning of line if m.cursor-scrolloff-m.offset < 0 { m.offset = m.cursor - scrolloff } if m.offset < 0 { m.offset = 0 } for i, char := range m.content { if i < m.offset { continue } if i+1 == m.cursor { cursor = col + char.Width } cell := vaxis.Cell{ Character: char, Style: m.Content, } if m.invisibleChar.Grapheme != "" { cell.Character = m.invisibleChar } if m.offset > 0 && i == m.offset { cell.Character = truncator } if col+char.Width >= winW { cell.Character = truncator } win.SetCell(col, 0, cell) col += char.Width if col >= winW { break } } if !m.HideCursor { win.ShowCursor(cursor, 0, vaxis.CursorBlock) } } // isAlphaNumeric returns true if the character is a letter or a number func isAlphaNumeric(c vaxis.Character) bool { runes := []rune(c.Grapheme) if len(runes) > 1 { return false } if unicode.IsLetter(runes[0]) || unicode.IsNumber(runes[0]) { return true } return false } func widthToCursor(chars []vaxis.Character, cursor int, offset int) int { w := 0 for i, ch := range chars { if i < offset { continue } w += ch.Width if i == cursor { break } } return w } golang-sourcehut-rockorager-vaxis-0.13.0/window.go000066400000000000000000000170601476577054500222330ustar00rootroot00000000000000package vaxis import ( "strings" "github.com/rivo/uniseg" ) // Window is a Window with an offset from an optional parent and a specified // size. A Window can be instantiated directly, however the provided constructor // methods are recommended as they will enforce size constraints type Window struct { // Vx is a reference to the [Vx] instance Vx *Vaxis // Parent is a reference to a parent [Window], if nil then the offsets // and size will be relative to the underlying terminal window Parent *Window Column int // col offset from parent Row int // row offset from parent Width int // width of the surface, in cols Height int // height of the surface, in rows } // Window returns a window the full size of the screen. Child windows can be // created from the returned Window func (vx *Vaxis) Window() Window { vx.mu.Lock() w, h := vx.screenNext.size() vx.mu.Unlock() return Window{ Row: 0, Column: 0, Width: w, Height: h, Vx: vx, } } // New creates a new child Window with an offset relative to the parent window func (win Window) New(col, row, cols, rows int) Window { newWin := Window{ Row: row, Column: col, Width: cols, Height: rows, Parent: &win, Vx: win.Vx, } w, h := win.Size() switch { case cols < 0: newWin.Width = w - col case cols+col > w: newWin.Width = w - col } switch { case rows < 0: newWin.Height = h - row case rows+row > h: newWin.Height = h - row } return newWin } // Size returns the visible size of the Window in character cells. func (win Window) Size() (width int, height int) { return win.Width, win.Height } // SetCell is used to place data at the given cell location. Note that since // the Window doesn't retain this data, if the location is outside of the // visible area, it is simply discarded. func (win Window) SetCell(col int, row int, cell Cell) { if row >= win.Height || col >= win.Width { return } if row < 0 || col < 0 { return } switch win.Parent { case nil: win.Vx.screenNext.setCell(col+win.Column, row+win.Row, cell) default: win.Parent.SetCell(col+win.Column, row+win.Row, cell) } } // SetStyle changes the style at a given location, leaving the text in place. func (win Window) SetStyle(col int, row int, style Style) { if row >= win.Height || col >= win.Width { return } if row < 0 || col < 0 { return } switch win.Parent { case nil: win.Vx.screenNext.setStyle(col+win.Column, row+win.Row, style) default: win.Parent.SetStyle(col+win.Column, row+win.Row, style) } } // ShowCursor shows the cursor at colxrow, relative to this Window's location func (win Window) ShowCursor(col int, row int, style CursorStyle) { col += win.Column row += win.Row if win.Parent == nil { win.Vx.ShowCursor(col, row, style) return } win.Parent.ShowCursor(col, row, style) } // Fill completely fills the Window with the provided cell func (win Window) Fill(cell Cell) { cols, rows := win.Size() for row := 0; row < rows; row += 1 { for col := 0; col < cols; col += 1 { win.SetCell(col, row, cell) } } } // returns the Origin of the window, column x row, 0-indexed func (win Window) Origin() (int, int) { w := win col := 0 row := 0 for { col += w.Column row += w.Row if w.Parent == nil { return col, row } w = *w.Parent } } // Clear fills the Window with spaces with the default colors and removes all // graphics placements func (win Window) Clear() { // We fill with a \x00 cell to differentiate between eg a text input // space and a cleared cell. \x00 is rendered as a space, but the // internal model will differentiate win.Fill(Cell{Character: Character{" ", 1}, Style: Style{}}) win.Vx.graphicsNext = []*placement{} } // Print prints [Segment]s, with each block having a given style. Text will be // wrapped, line breaks will begin a new line at the first column of the surface. // If the text overflows the height of the surface then only the top portion // will be shown func (win Window) Print(segs ...Segment) (col int, row int) { cols, rows := win.Size() for _, seg := range segs { for _, char := range Characters(seg.Text) { if strings.ContainsRune(char.Grapheme, '\n') { col = 0 row += 1 continue } if row > rows { return col, row } if !win.Vx.caps.unicodeCore || !win.Vx.caps.explicitWidth { // characterWidth will cache the result char.Width = win.Vx.characterWidth(char.Grapheme) } cell := Cell{ Character: char, Style: seg.Style, } win.SetCell(col, row, cell) col += char.Width if col >= cols { row += 1 col = 0 } } } return col, row } // PrintTruncate prints a single line of text to the specified row. If the text is // wider than the width of the window, the line will be truncated with "…": // // "This line has mo…" // // If the row is outside the bounds of the window, nothing will be printed func (win Window) PrintTruncate(row int, segs ...Segment) { cols, rows := win.Size() if row >= rows { return } col := 0 truncator := Character{ Grapheme: "…", Width: 1, } for _, seg := range segs { for _, char := range Characters(seg.Text) { if !win.Vx.caps.unicodeCore || !win.Vx.caps.explicitWidth { // characterWidth will cache the result char.Width = win.Vx.characterWidth(char.Grapheme) } w := char.Width cell := Cell{ Character: char, Style: seg.Style, } if col+truncator.Width+w > cols { cell.Character = truncator win.SetCell(col, row, cell) return } win.SetCell(col, row, cell) col += w } } } // Println prints a single line of text to the specified row. If the text is // wider than the width of the window, the line will be truncated with "…": // // "This line has mo…" // // If the row is outside the bounds of the window, nothing will be printed func (win Window) Println(row int, segs ...Segment) { cols, rows := win.Size() if row >= rows { return } col := 0 for _, seg := range segs { for _, char := range Characters(seg.Text) { if !win.Vx.caps.unicodeCore || !win.Vx.caps.explicitWidth { // characterWidth will cache the result char.Width = win.Vx.characterWidth(char.Grapheme) } w := char.Width if col+w > cols { return } cell := Cell{ Character: char, Style: seg.Style, } win.SetCell(col, row, cell) col += w } } } // Wrap uses unicode line break logic to wrap text. this is expensive, but // has good results func (win Window) Wrap(segs ...Segment) (col int, row int) { cols, rows := win.Size() var ( state = -1 segment string ) for _, seg := range segs { rest := seg.Text for len(rest) > 0 { if row >= rows { break } segment, rest, _, state = uniseg.FirstLineSegmentInString(rest, state) chars := Characters(segment) total := 0 for _, char := range chars { if !win.Vx.caps.unicodeCore || !win.Vx.caps.explicitWidth { // characterWidth will cache the result char.Width = win.Vx.characterWidth(char.Grapheme) } total += char.Width } // Figure out how to break the line switch { case total > cols: // the line is greater than our entire width, so we'll // break at a grapheme case total+col > cols: // there isn't space left, go to a new line col = 0 row += 1 default: // it fits on our line. Print it } for _, char := range chars { if uniseg.HasTrailingLineBreakInString(char.Grapheme) { row += 1 col = 0 continue } cell := Cell{ Character: char, Style: seg.Style, } win.SetCell(col, row, cell) col += char.Width if col >= cols { row += 1 col = 0 } } } } return col, row } golang-sourcehut-rockorager-vaxis-0.13.0/writer.go000066400000000000000000000060301476577054500222330ustar00rootroot00000000000000package vaxis import ( "bytes" "fmt" "io" "sync" ) // writer is a buffered writer for a terminal. If the terminal supports // synchronized output, all writes will be wrapped with synchronized mode // set/reset. The internal buffer will be reset upon flushing type writer struct { buf *bytes.Buffer w io.Writer vx *Vaxis mut sync.Mutex } func newWriter(vx *Vaxis) *writer { return &writer{ buf: bytes.NewBuffer(make([]byte, 8192)), w: vx.console, vx: vx, } } func (w *writer) Write(p []byte) (n int, err error) { if len(p) == 0 { return 0, nil } if w.buf.Len() == 0 { if w.vx.caps.synchronizedUpdate { w.buf.WriteString(decset(synchronizedUpdate)) } if w.vx.cursorLast.visible && w.vx.cursorNext.visible { // Hide cursor if it's visible, and only write this if // the next cursor is visible also. we'll explicitly // turn the cursor off in the render loop if there is a // change to the state of cursor visibility w.buf.WriteString(decrst(cursorVisibility)) } } return w.buf.Write(p) } func (w *writer) WriteString(s string) (n int, err error) { if s == "" { return 0, nil } if w.buf.Len() == 0 { if w.vx.cursorLast.visible { // Hide cursor if it's visible w.buf.WriteString(decrst(cursorVisibility)) } if w.vx.caps.synchronizedUpdate { w.buf.WriteString(decset(synchronizedUpdate)) } } return w.buf.WriteString(s) } func (w *writer) Printf(s string, args ...any) (n int, err error) { return fmt.Fprintf(w, s, args...) } func (w *writer) Len() int { return w.buf.Len() } // WriteStringLocked writes to the underlying terminal while the mutex is held. // This does not handle any mouse nor synchronization state and is intended to // be used for one-off synchronized sequence writes to the terminal func (w *writer) WriteStringLocked(s string) (n int, err error) { w.mut.Lock() defer w.mut.Unlock() return w.w.Write([]byte(s)) } func (w *writer) Flush() (n int, err error) { if w.buf.Len() == 0 { // If we didn't write any visual changes, make sure we make any // cursor changes here. Write directly to tty for these as // they are short and don't require synchronization switch { case !w.vx.cursorNext.visible && w.vx.cursorLast.visible: return w.w.Write([]byte(decrst(cursorVisibility))) case w.vx.cursorNext.row != w.vx.cursorLast.row: return w.w.Write([]byte(w.vx.showCursor())) case w.vx.cursorNext.col != w.vx.cursorLast.col: return w.w.Write([]byte(w.vx.showCursor())) case w.vx.cursorNext.style != w.vx.cursorLast.style: return w.w.Write([]byte(w.vx.showCursor())) default: return 0, nil } } defer w.buf.Reset() w.buf.WriteString(sgrReset) // We check against both. If the state changed, this was written in the // render loop. this portion only restores where teh cursor was prior to // the render if w.vx.cursorNext.visible && w.vx.cursorLast.visible { w.buf.WriteString(w.vx.showCursor()) } if w.vx.caps.synchronizedUpdate { w.buf.WriteString(decrst(synchronizedUpdate)) } w.mut.Lock() defer w.mut.Unlock() return w.w.Write(w.buf.Bytes()) }