├── .github └── workflows │ └── go.yml ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── application.go ├── borders.go ├── box.go ├── button.go ├── center.go ├── eventhandler.go ├── flex.go ├── form.go ├── go.mod ├── go.sum ├── grid.go ├── group.go ├── inputarea.go ├── inputfield.go ├── main ├── mauview-test ├── debug │ └── debug.go └── main.go ├── progress.go ├── screen.go ├── semigraphics.go ├── styles.go ├── textfield.go ├── textview.go └── util.go /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: [push, pull_request] 4 | 5 | env: 6 | GOTOOLCHAIN: local 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | go-version: ["1.22", "1.23"] 15 | name: Lint (${{ matrix.go-version == '1.23' && 'latest' || 'old' }}) 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Go ${{ matrix.go-version }} 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: ${{ matrix.go-version }} 24 | 25 | - name: Install dependencies 26 | run: | 27 | go install golang.org/x/tools/cmd/goimports@latest 28 | go install honnef.co/go/tools/cmd/staticcheck@latest 29 | export PATH="$HOME/go/bin:$PATH" 30 | 31 | - name: Run pre-commit 32 | uses: pre-commit/action@v3.0.1 33 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | exclude_types: [markdown] 7 | - id: end-of-file-fixer 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | 11 | - repo: https://github.com/tekwizely/pre-commit-golang 12 | rev: v1.0.0-rc.1 13 | hooks: 14 | - id: go-imports-repo 15 | args: 16 | - "-local" 17 | - "maunium.net/go/mauview" 18 | - "-w" 19 | - id: go-mod-tidy 20 | - id: go-vet-repo-mod 21 | # TODO fix and enable this 22 | #- id: go-staticcheck-repo-mod 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mauview 2 | Work in progress 3 | -------------------------------------------------------------------------------- /application.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2022 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "errors" 12 | "fmt" 13 | "os" 14 | "strings" 15 | "sync" 16 | "time" 17 | 18 | "github.com/gdamore/tcell/v2" 19 | ) 20 | 21 | type Component interface { 22 | Draw(screen Screen) 23 | OnKeyEvent(event KeyEvent) bool 24 | OnPasteEvent(event PasteEvent) bool 25 | OnMouseEvent(event MouseEvent) bool 26 | } 27 | 28 | type Focusable interface { 29 | Focus() 30 | Blur() 31 | } 32 | 33 | type FocusableComponent interface { 34 | Component 35 | Focusable 36 | } 37 | 38 | type Application struct { 39 | screenLock sync.RWMutex 40 | screen tcell.Screen 41 | prevMouseEvt *tcell.EventMouse 42 | root Component 43 | updates chan interface{} 44 | redrawTicker *time.Ticker 45 | stop chan struct{} 46 | waitForStop chan struct{} 47 | alwaysClear bool 48 | } 49 | 50 | const queueSize = 255 51 | 52 | func NewApplication() *Application { 53 | return &Application{ 54 | prevMouseEvt: &tcell.EventMouse{}, 55 | updates: make(chan interface{}, queueSize), 56 | redrawTicker: time.NewTicker(1 * time.Minute), 57 | stop: make(chan struct{}, 1), 58 | alwaysClear: true, 59 | } 60 | } 61 | 62 | func newScreen(events chan tcell.Event) (tcell.Screen, error) { 63 | if screen, err := tcell.NewScreen(); err != nil { 64 | return nil, fmt.Errorf("failed to create screen: %w", err) 65 | } else if err = screen.Init(); err != nil { 66 | return nil, fmt.Errorf("failed to initialize screen: %w", err) 67 | } else { 68 | screen.EnableMouse() 69 | screen.EnablePaste() 70 | go screen.ChannelEvents(events, nil) 71 | return screen, nil 72 | } 73 | } 74 | 75 | func (app *Application) SetRedrawTicker(tick time.Duration) { 76 | app.redrawTicker.Stop() 77 | app.redrawTicker = time.NewTicker(tick) 78 | } 79 | 80 | func (app *Application) Start() error { 81 | if app.root == nil { 82 | return errors.New("root component not set") 83 | } 84 | 85 | events := make(chan tcell.Event, queueSize) 86 | screen, err := newScreen(events) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | app.screenLock.Lock() 92 | app.screen = screen 93 | app.screenLock.Unlock() 94 | app.waitForStop = make(chan struct{}) 95 | 96 | defer func() { 97 | app.screenLock.Lock() 98 | app.screen = nil 99 | app.screenLock.Unlock() 100 | if screen != nil { 101 | screen.Fini() 102 | } 103 | close(app.waitForStop) 104 | }() 105 | 106 | var pasteBuffer strings.Builder 107 | var isPasting bool 108 | 109 | for { 110 | var redraw bool 111 | var clear bool 112 | select { 113 | case eventInterface := <-events: 114 | switch event := eventInterface.(type) { 115 | case *tcell.EventKey: 116 | if isPasting { 117 | switch event.Key() { 118 | case tcell.KeyRune: 119 | pasteBuffer.WriteRune(event.Rune()) 120 | case tcell.KeyEnter: 121 | pasteBuffer.WriteByte('\n') 122 | } 123 | } else { 124 | redraw = app.root.OnKeyEvent(event) 125 | } 126 | case *tcell.EventPaste: 127 | if event.Start() { 128 | isPasting = true 129 | pasteBuffer.Reset() 130 | } else { 131 | customEvt := customPasteEvent{event, pasteBuffer.String()} 132 | isPasting = false 133 | pasteBuffer.Reset() 134 | redraw = app.root.OnPasteEvent(customEvt) 135 | } 136 | case *tcell.EventMouse: 137 | onlyButtons := event.Buttons() < tcell.WheelUp 138 | hasMotion := onlyButtons && app.prevMouseEvt.Buttons() == event.Buttons() 139 | customEvt := customMouseEvent{event, hasMotion} 140 | app.prevMouseEvt = event 141 | redraw = app.root.OnMouseEvent(customEvt) 142 | case *tcell.EventResize: 143 | clear = true 144 | redraw = true 145 | } 146 | case <-app.redrawTicker.C: 147 | redraw = true 148 | case updaterInterface := <-app.updates: 149 | switch updater := updaterInterface.(type) { 150 | case redrawUpdate: 151 | redraw = true 152 | case setRootUpdate: 153 | app.root = updater.newRoot 154 | focusable, ok := app.root.(Focusable) 155 | if ok { 156 | focusable.Focus() 157 | } 158 | redraw = true 159 | clear = true 160 | case suspendUpdate: 161 | err = screen.Suspend() 162 | if err != nil { 163 | // This shouldn't fail 164 | panic(err) 165 | } 166 | updater.wait() 167 | err = screen.Resume() 168 | if err != nil { 169 | screen.Fini() 170 | fmt.Println("Failed to resume screen:", err) 171 | os.Exit(40) 172 | } 173 | redraw = true 174 | clear = true 175 | } 176 | case <-app.stop: 177 | return nil 178 | } 179 | select { 180 | case <-app.stop: 181 | return nil 182 | default: 183 | } 184 | if redraw { 185 | if clear || app.alwaysClear { 186 | screen.Clear() 187 | } 188 | screen.HideCursor() 189 | app.root.Draw(screen) 190 | screen.Show() 191 | } 192 | } 193 | } 194 | 195 | func (app *Application) Stop() { 196 | select { 197 | case app.stop <- struct{}{}: 198 | default: 199 | } 200 | <-app.waitForStop 201 | } 202 | 203 | func (app *Application) ForceStop() { 204 | if screen := app.screen; screen != nil { 205 | screen.Fini() 206 | } 207 | select { 208 | case app.stop <- struct{}{}: 209 | default: 210 | } 211 | } 212 | 213 | type suspendUpdate struct { 214 | wait func() 215 | } 216 | 217 | type redrawUpdate struct{} 218 | 219 | type setRootUpdate struct { 220 | newRoot Component 221 | } 222 | 223 | func (app *Application) Suspend(wait func()) { 224 | app.updates <- suspendUpdate{wait} 225 | } 226 | 227 | func (app *Application) Redraw() { 228 | app.updates <- redrawUpdate{} 229 | } 230 | 231 | func (app *Application) SetRoot(view Component) { 232 | app.screenLock.RLock() 233 | defer app.screenLock.RUnlock() 234 | if app.screen != nil { 235 | app.updates <- setRootUpdate{view} 236 | } else { 237 | app.root = view 238 | focusable, ok := app.root.(Focusable) 239 | if ok { 240 | focusable.Focus() 241 | } 242 | } 243 | } 244 | 245 | // Screen returns the main tcell screen currently used in the app. 246 | func (app *Application) Screen() tcell.Screen { 247 | app.screenLock.RLock() 248 | screen := app.screen 249 | app.screenLock.RUnlock() 250 | return screen 251 | } 252 | 253 | func (app *Application) SetAlwaysClear(always bool) { 254 | app.alwaysClear = always 255 | } 256 | -------------------------------------------------------------------------------- /borders.go: -------------------------------------------------------------------------------- 1 | // From https://github.com/rivo/tview/blob/master/borders.go 2 | // Copyright (c) 2018 Oliver Kuederle 3 | // MIT license 4 | 5 | package mauview 6 | 7 | // Borders defines various borders used when primitives are drawn. 8 | // These may be changed to accommodate a different look and feel. 9 | var Borders = struct { 10 | Horizontal rune 11 | Vertical rune 12 | TopLeft rune 13 | TopRight rune 14 | BottomLeft rune 15 | BottomRight rune 16 | 17 | LeftT rune 18 | RightT rune 19 | TopT rune 20 | BottomT rune 21 | Cross rune 22 | 23 | HorizontalFocus rune 24 | VerticalFocus rune 25 | TopLeftFocus rune 26 | TopRightFocus rune 27 | BottomLeftFocus rune 28 | BottomRightFocus rune 29 | }{ 30 | Horizontal: BoxDrawingsLightHorizontal, 31 | Vertical: BoxDrawingsLightVertical, 32 | TopLeft: BoxDrawingsLightDownAndRight, 33 | TopRight: BoxDrawingsLightDownAndLeft, 34 | BottomLeft: BoxDrawingsLightUpAndRight, 35 | BottomRight: BoxDrawingsLightUpAndLeft, 36 | 37 | LeftT: BoxDrawingsLightVerticalAndRight, 38 | RightT: BoxDrawingsLightVerticalAndLeft, 39 | TopT: BoxDrawingsLightDownAndHorizontal, 40 | BottomT: BoxDrawingsLightUpAndHorizontal, 41 | Cross: BoxDrawingsLightVerticalAndHorizontal, 42 | 43 | HorizontalFocus: BoxDrawingsDoubleHorizontal, 44 | VerticalFocus: BoxDrawingsDoubleVertical, 45 | TopLeftFocus: BoxDrawingsDoubleDownAndRight, 46 | TopRightFocus: BoxDrawingsDoubleDownAndLeft, 47 | BottomLeftFocus: BoxDrawingsDoubleUpAndRight, 48 | BottomRightFocus: BoxDrawingsDoubleUpAndLeft, 49 | } 50 | -------------------------------------------------------------------------------- /box.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "github.com/gdamore/tcell/v2" 12 | ) 13 | 14 | type KeyCaptureFunc func(event KeyEvent) KeyEvent 15 | type MouseCaptureFunc func(event MouseEvent) MouseEvent 16 | type PasteCaptureFunc func(event PasteEvent) PasteEvent 17 | 18 | type Box struct { 19 | border bool 20 | borderStyle tcell.Style 21 | backgroundColor *tcell.Color 22 | keyCapture KeyCaptureFunc 23 | mouseCapture MouseCaptureFunc 24 | pasteCapture PasteCaptureFunc 25 | focusCapture func() bool 26 | blurCapture func() bool 27 | title string 28 | inner Component 29 | innerScreen *ProxyScreen 30 | focused bool 31 | } 32 | 33 | func NewBox(inner Component) *Box { 34 | return &Box{ 35 | border: true, 36 | borderStyle: tcell.StyleDefault, 37 | backgroundColor: &Styles.PrimitiveBackgroundColor, 38 | inner: inner, 39 | innerScreen: &ProxyScreen{OffsetX: 1, OffsetY: 1}, 40 | } 41 | } 42 | 43 | func (box *Box) Focus() { 44 | box.focused = true 45 | if box.focusCapture != nil { 46 | if box.focusCapture() { 47 | return 48 | } 49 | } 50 | focusable, ok := box.inner.(Focusable) 51 | if ok { 52 | focusable.Focus() 53 | } 54 | } 55 | 56 | func (box *Box) Blur() { 57 | box.focused = false 58 | if box.blurCapture != nil { 59 | if box.blurCapture() { 60 | return 61 | } 62 | } 63 | focusable, ok := box.inner.(Focusable) 64 | if ok { 65 | focusable.Blur() 66 | } 67 | } 68 | 69 | func (box *Box) SetBorder(border bool) *Box { 70 | box.border = border 71 | if border { 72 | box.innerScreen.OffsetY = 1 73 | box.innerScreen.OffsetX = 1 74 | } else { 75 | box.innerScreen.OffsetY = 0 76 | box.innerScreen.OffsetX = 0 77 | } 78 | return box 79 | } 80 | 81 | func (box *Box) SetBorderStyle(borderStyle tcell.Style) *Box { 82 | box.borderStyle = borderStyle 83 | return box 84 | } 85 | 86 | func (box *Box) SetTitle(title string) *Box { 87 | box.title = title 88 | return box 89 | } 90 | 91 | func (box *Box) SetInnerComponent(component Component) *Box { 92 | box.inner = component 93 | return box 94 | } 95 | 96 | func (box *Box) SetMouseCaptureFunc(mouseCapture MouseCaptureFunc) *Box { 97 | box.mouseCapture = mouseCapture 98 | return box 99 | } 100 | 101 | func (box *Box) SetKeyCaptureFunc(keyCapture KeyCaptureFunc) *Box { 102 | box.keyCapture = keyCapture 103 | return box 104 | } 105 | 106 | func (box *Box) SetPasteCaptureFunc(pasteCapture PasteCaptureFunc) *Box { 107 | box.pasteCapture = pasteCapture 108 | return box 109 | } 110 | 111 | func (box *Box) SetFocusCaptureFunc(focusCapture func() bool) *Box { 112 | box.focusCapture = focusCapture 113 | return box 114 | } 115 | 116 | func (box *Box) SetBlurCaptureFunc(blurCapture func() bool) *Box { 117 | box.blurCapture = blurCapture 118 | return box 119 | } 120 | 121 | func (box *Box) SetBackgroundColor(color tcell.Color) *Box { 122 | box.backgroundColor = &color 123 | return box 124 | } 125 | 126 | func (box *Box) drawBorder(screen Screen) { 127 | width, height := screen.Size() 128 | var vertical, horizontal, topLeft, topRight, bottomLeft, bottomRight rune 129 | if box.focused { 130 | horizontal = Borders.HorizontalFocus 131 | vertical = Borders.VerticalFocus 132 | topLeft = Borders.TopLeftFocus 133 | topRight = Borders.TopRightFocus 134 | bottomLeft = Borders.BottomLeftFocus 135 | bottomRight = Borders.BottomRightFocus 136 | } else { 137 | horizontal = Borders.Horizontal 138 | vertical = Borders.Vertical 139 | topLeft = Borders.TopLeft 140 | topRight = Borders.TopRight 141 | bottomLeft = Borders.BottomLeft 142 | bottomRight = Borders.BottomRight 143 | } 144 | borderStyle := box.borderStyle 145 | if box.backgroundColor != nil { 146 | borderStyle = borderStyle.Background(*box.backgroundColor) 147 | } 148 | for x := 0; x < width; x++ { 149 | screen.SetContent(x, 0, horizontal, nil, borderStyle) 150 | screen.SetContent(x, height-1, horizontal, nil, borderStyle) 151 | } 152 | Print(screen, box.title, 1, 0, width-2, AlignCenter, Styles.BorderColor) 153 | for y := 0; y < height; y++ { 154 | screen.SetContent(0, y, vertical, nil, borderStyle) 155 | screen.SetContent(width-1, y, vertical, nil, borderStyle) 156 | } 157 | screen.SetContent(0, 0, topLeft, nil, borderStyle) 158 | screen.SetContent(width-1, 0, topRight, nil, borderStyle) 159 | screen.SetContent(0, height-1, bottomLeft, nil, borderStyle) 160 | screen.SetContent(width-1, height-1, bottomRight, nil, borderStyle) 161 | } 162 | 163 | func (box *Box) Draw(screen Screen) { 164 | width, height := screen.Size() 165 | border := false 166 | if box.backgroundColor != nil { 167 | screen.SetStyle(tcell.StyleDefault.Background(*box.backgroundColor)) 168 | screen.Clear() 169 | } 170 | if box.border && width >= 2 && height >= 2 { 171 | border = true 172 | box.drawBorder(screen) 173 | } 174 | 175 | if box.inner != nil { 176 | if border { 177 | box.innerScreen.Width = width - 2 178 | box.innerScreen.Height = height - 2 179 | } else { 180 | box.innerScreen.Width = width 181 | box.innerScreen.Height = height 182 | } 183 | box.innerScreen.Parent = screen 184 | box.inner.Draw(box.innerScreen) 185 | } 186 | } 187 | 188 | func (box *Box) OnKeyEvent(event KeyEvent) bool { 189 | if box.keyCapture != nil { 190 | event = box.keyCapture(event) 191 | if event == nil { 192 | return true 193 | } 194 | } 195 | if box.inner != nil { 196 | return box.inner.OnKeyEvent(event) 197 | } 198 | return false 199 | } 200 | 201 | func (box *Box) OnPasteEvent(event PasteEvent) bool { 202 | if box.pasteCapture != nil { 203 | event = box.pasteCapture(event) 204 | if event == nil { 205 | return true 206 | } 207 | } 208 | if box.inner != nil { 209 | return box.inner.OnPasteEvent(event) 210 | } 211 | return false 212 | } 213 | 214 | func (box *Box) OnMouseEvent(event MouseEvent) bool { 215 | if box.border { 216 | event = OffsetMouseEvent(event, -1, -1) 217 | } 218 | x, y := event.Position() 219 | if x < 0 || y < 0 || x > box.innerScreen.Width || y > box.innerScreen.Height { 220 | return false 221 | } 222 | if box.mouseCapture != nil { 223 | event = box.mouseCapture(event) 224 | if event == nil { 225 | return true 226 | } 227 | } 228 | if box.inner != nil { 229 | return box.inner.OnMouseEvent(event) 230 | } 231 | return false 232 | } 233 | -------------------------------------------------------------------------------- /button.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "github.com/gdamore/tcell/v2" 12 | ) 13 | 14 | type Button struct { 15 | text string 16 | style tcell.Style 17 | focusedStyle tcell.Style 18 | focused bool 19 | onClick func() 20 | } 21 | 22 | func NewButton(text string) *Button { 23 | return &Button{ 24 | text: text, 25 | style: tcell.StyleDefault.Background(Styles.ContrastBackgroundColor).Foreground(Styles.PrimaryTextColor), 26 | focusedStyle: tcell.StyleDefault.Background(Styles.MoreContrastBackgroundColor).Foreground(Styles.PrimaryTextColor), 27 | } 28 | } 29 | 30 | func (b *Button) SetText(text string) *Button { 31 | b.text = text 32 | return b 33 | } 34 | 35 | func (b *Button) SetForegroundColor(color tcell.Color) *Button { 36 | b.style = b.style.Foreground(color) 37 | return b 38 | } 39 | 40 | func (b *Button) SetBackgroundColor(color tcell.Color) *Button { 41 | b.style = b.style.Background(color) 42 | return b 43 | } 44 | 45 | func (b *Button) SetFocusedForegroundColor(color tcell.Color) *Button { 46 | b.focusedStyle = b.focusedStyle.Foreground(color) 47 | return b 48 | } 49 | 50 | func (b *Button) SetFocusedBackgroundColor(color tcell.Color) *Button { 51 | b.focusedStyle = b.focusedStyle.Background(color) 52 | return b 53 | } 54 | 55 | func (b *Button) SetStyle(style tcell.Style) *Button { 56 | b.style = style 57 | return b 58 | } 59 | 60 | func (b *Button) SetFocusedStyle(style tcell.Style) *Button { 61 | b.focusedStyle = style 62 | return b 63 | } 64 | 65 | func (b *Button) SetOnClick(fn func()) *Button { 66 | b.onClick = fn 67 | return b 68 | } 69 | 70 | func (b *Button) Focus() { 71 | b.focused = true 72 | } 73 | 74 | func (b *Button) Blur() { 75 | b.focused = false 76 | } 77 | 78 | func (b *Button) Draw(screen Screen) { 79 | width, _ := screen.Size() 80 | style := b.style 81 | if b.focused { 82 | style = b.focusedStyle 83 | } 84 | screen.SetStyle(style) 85 | screen.Clear() 86 | PrintWithStyle(screen, b.text, 0, 0, width, AlignCenter, style) 87 | } 88 | 89 | func (b *Button) Submit(event KeyEvent) bool { 90 | if b.onClick != nil { 91 | b.onClick() 92 | } 93 | return true 94 | } 95 | 96 | func (b *Button) OnKeyEvent(event KeyEvent) bool { 97 | if event.Key() == tcell.KeyEnter { 98 | if b.onClick != nil { 99 | b.onClick() 100 | } 101 | return true 102 | } 103 | return false 104 | } 105 | 106 | func (b *Button) OnMouseEvent(event MouseEvent) bool { 107 | if event.Buttons() == tcell.Button1 && !event.HasMotion() { 108 | if b.onClick != nil { 109 | b.onClick() 110 | } 111 | return true 112 | } 113 | return false 114 | } 115 | 116 | func (b *Button) OnPasteEvent(event PasteEvent) bool { 117 | return false 118 | } 119 | -------------------------------------------------------------------------------- /center.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "github.com/gdamore/tcell/v2" 12 | ) 13 | 14 | type FractionalCenterer struct { 15 | center *Centerer 16 | minWidth int 17 | minHeight int 18 | fractionWidth float64 19 | fractionHeight float64 20 | } 21 | 22 | func FractionalCenter(target Component, minWidth, minHeight int, fractionalWidth, fractionalHeight float64) *FractionalCenterer { 23 | return &FractionalCenterer{ 24 | center: Center(target, 0, 0), 25 | minWidth: minWidth, 26 | minHeight: minHeight, 27 | fractionWidth: fractionalWidth, 28 | fractionHeight: fractionalHeight, 29 | } 30 | } 31 | 32 | func (fc *FractionalCenterer) SetAlwaysFocusChild(always bool) *FractionalCenterer { 33 | fc.center.alwaysFocusChild = always 34 | return fc 35 | } 36 | 37 | func (fc *FractionalCenterer) Blur() { fc.center.Blur() } 38 | func (fc *FractionalCenterer) Focus() { fc.center.Focus() } 39 | 40 | func (fc *FractionalCenterer) OnMouseEvent(evt MouseEvent) bool { return fc.center.OnMouseEvent(evt) } 41 | func (fc *FractionalCenterer) OnKeyEvent(evt KeyEvent) bool { return fc.center.OnKeyEvent(evt) } 42 | func (fc *FractionalCenterer) OnPasteEvent(evt PasteEvent) bool { return fc.center.OnPasteEvent(evt) } 43 | 44 | func (fc *FractionalCenterer) Draw(screen Screen) { 45 | width, height := screen.Size() 46 | width = int(float64(width) * fc.fractionWidth) 47 | height = int(float64(height) * fc.fractionHeight) 48 | if width < fc.minWidth { 49 | width = fc.minWidth 50 | } 51 | if height < fc.minHeight { 52 | height = fc.minHeight 53 | } 54 | fc.center.SetSize(width, height) 55 | fc.center.Draw(screen) 56 | } 57 | 58 | type Centerer struct { 59 | target Component 60 | screen *ProxyScreen 61 | childFocused bool 62 | alwaysFocusChild bool 63 | } 64 | 65 | func Center(target Component, width, height int) *Centerer { 66 | return &Centerer{ 67 | target: target, 68 | screen: &ProxyScreen{Style: tcell.StyleDefault, Width: width, Height: height}, 69 | childFocused: false, 70 | alwaysFocusChild: false, 71 | } 72 | } 73 | 74 | func (center *Centerer) SetHeight(height int) { 75 | center.screen.Height = height 76 | } 77 | 78 | func (center *Centerer) SetWidth(width int) { 79 | center.screen.Width = width 80 | } 81 | 82 | func (center *Centerer) SetSize(width, height int) { 83 | center.screen.Width = width 84 | center.screen.Height = height 85 | } 86 | 87 | func (center *Centerer) SetAlwaysFocusChild(always bool) *Centerer { 88 | center.alwaysFocusChild = always 89 | return center 90 | } 91 | 92 | func (center *Centerer) Draw(screen Screen) { 93 | totalWidth, totalHeight := screen.Size() 94 | paddingX := (totalWidth - center.screen.Width) / 2 95 | paddingY := (totalHeight - center.screen.Height) / 2 96 | if paddingX >= 0 { 97 | center.screen.OffsetX = paddingX 98 | } 99 | if paddingY >= 0 { 100 | center.screen.OffsetY = paddingY 101 | } 102 | center.screen.Parent = screen 103 | center.target.Draw(center.screen) 104 | } 105 | 106 | func (center *Centerer) OnKeyEvent(evt KeyEvent) bool { 107 | return center.target.OnKeyEvent(evt) 108 | } 109 | 110 | func (center *Centerer) Focus() { 111 | if center.alwaysFocusChild { 112 | center.childFocused = true 113 | focusable, ok := center.target.(Focusable) 114 | if ok { 115 | focusable.Focus() 116 | } 117 | } 118 | } 119 | 120 | func (center *Centerer) Blur() { 121 | center.childFocused = false 122 | focusable, ok := center.target.(Focusable) 123 | if ok { 124 | focusable.Blur() 125 | } 126 | } 127 | 128 | func (center *Centerer) OnMouseEvent(evt MouseEvent) bool { 129 | x, y := evt.Position() 130 | x -= center.screen.OffsetX 131 | y -= center.screen.OffsetY 132 | focusable, ok := center.target.(Focusable) 133 | if x < 0 || y < 0 || x > center.screen.Width || y > center.screen.Height { 134 | if ok && evt.Buttons() == tcell.Button1 && !evt.HasMotion() { 135 | if center.alwaysFocusChild && !center.childFocused { 136 | focusable.Focus() 137 | center.childFocused = true 138 | } else if !center.alwaysFocusChild && center.childFocused { 139 | center.Blur() 140 | } 141 | return true 142 | } 143 | return false 144 | } 145 | focusChanged := false 146 | if ok && !center.childFocused && evt.Buttons() == tcell.Button1 && !evt.HasMotion() { 147 | focusable.Focus() 148 | center.childFocused = true 149 | focusChanged = true 150 | } 151 | return center.target.OnMouseEvent(OffsetMouseEvent(evt, -center.screen.OffsetX, -center.screen.OffsetY)) || focusChanged 152 | } 153 | 154 | func (center *Centerer) OnPasteEvent(evt PasteEvent) bool { 155 | return center.target.OnPasteEvent(evt) 156 | } 157 | -------------------------------------------------------------------------------- /eventhandler.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "github.com/gdamore/tcell/v2" 12 | ) 13 | 14 | // KeyEvent is an interface of the *tcell.EventKey type. 15 | type KeyEvent interface { 16 | tcell.Event 17 | // The rune corresponding to the key that was pressed. 18 | Rune() rune 19 | // The keyboard key that was pressed. 20 | Key() tcell.Key 21 | // The keyboard modifiers that were pressed during the event. 22 | Modifiers() tcell.ModMask 23 | } 24 | 25 | type customPasteEvent struct { 26 | *tcell.EventPaste 27 | text string 28 | } 29 | 30 | func (cpe customPasteEvent) Text() string { 31 | return cpe.text 32 | } 33 | 34 | // PasteEvent is an interface of the customPasteEvent type. 35 | type PasteEvent interface { 36 | tcell.Event 37 | // The text pasted. 38 | Text() string 39 | } 40 | 41 | type customMouseEvent struct { 42 | *tcell.EventMouse 43 | motion bool 44 | } 45 | 46 | func (cme customMouseEvent) HasMotion() bool { 47 | return cme.motion 48 | } 49 | 50 | // MouseEvent is an interface of the *tcell.EventMouse type. 51 | type MouseEvent interface { 52 | tcell.Event 53 | // The mouse buttons that were pressed. 54 | Buttons() tcell.ButtonMask 55 | // The keyboard modifiers that were pressed during the event. 56 | Modifiers() tcell.ModMask 57 | // The current position of the mouse. 58 | Position() (int, int) 59 | // Whether or not the event is a mouse move event. 60 | HasMotion() bool 61 | } 62 | 63 | // SimpleEventHandler is a simple implementation of the event handling methods required for components. 64 | type SimpleEventHandler struct { 65 | OnKey func(event KeyEvent) bool 66 | OnPaste func(event PasteEvent) bool 67 | OnMouse func(event MouseEvent) bool 68 | } 69 | 70 | func (seh *SimpleEventHandler) OnKeyEvent(event KeyEvent) bool { 71 | if seh.OnKey != nil { 72 | return seh.OnKey(event) 73 | } 74 | return false 75 | } 76 | 77 | func (seh *SimpleEventHandler) OnPasteEvent(event PasteEvent) bool { 78 | if seh.OnPaste != nil { 79 | return seh.OnPaste(event) 80 | } 81 | return false 82 | } 83 | 84 | func (seh *SimpleEventHandler) OnMouseEvent(event MouseEvent) bool { 85 | if seh.OnMouse != nil { 86 | return seh.OnMouse(event) 87 | } 88 | return false 89 | } 90 | 91 | type NoopEventHandler struct{} 92 | 93 | func (neh NoopEventHandler) OnKeyEvent(event KeyEvent) bool { 94 | return false 95 | } 96 | 97 | func (neh NoopEventHandler) OnPasteEvent(event PasteEvent) bool { 98 | return false 99 | } 100 | 101 | func (neh NoopEventHandler) OnMouseEvent(event MouseEvent) bool { 102 | return false 103 | } 104 | 105 | type proxyEventMouse struct { 106 | MouseEvent 107 | x int 108 | y int 109 | } 110 | 111 | func (evt *proxyEventMouse) Position() (int, int) { 112 | return evt.x, evt.y 113 | } 114 | 115 | // OffsetMouseEvent creates a new MouseEvent with the given offset. 116 | func OffsetMouseEvent(evt MouseEvent, offsetX, offsetY int) *proxyEventMouse { 117 | x, y := evt.Position() 118 | proxy, ok := evt.(*proxyEventMouse) 119 | if ok { 120 | evt = proxy.MouseEvent 121 | } 122 | return &proxyEventMouse{evt, x + offsetX, y + offsetY} 123 | } 124 | -------------------------------------------------------------------------------- /flex.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "github.com/gdamore/tcell/v2" 12 | ) 13 | 14 | type FlexDirection int 15 | 16 | const ( 17 | FlexRow FlexDirection = iota 18 | FlexColumn 19 | ) 20 | 21 | type flexChild struct { 22 | genericChild 23 | size int 24 | } 25 | 26 | type Flex struct { 27 | direction FlexDirection 28 | children []flexChild 29 | focused *flexChild 30 | } 31 | 32 | func NewFlex() *Flex { 33 | return &Flex{ 34 | children: []flexChild{}, 35 | focused: nil, 36 | direction: FlexColumn, 37 | } 38 | } 39 | 40 | func (flex *Flex) SetDirection(direction FlexDirection) *Flex { 41 | flex.direction = direction 42 | return flex 43 | } 44 | 45 | func (flex *Flex) AddFixedComponent(comp Component, size int) *Flex { 46 | flex.AddProportionalComponent(comp, -size) 47 | return flex 48 | } 49 | 50 | func (flex *Flex) AddProportionalComponent(comp Component, size int) *Flex { 51 | flex.children = append(flex.children, flexChild{ 52 | genericChild: genericChild{ 53 | target: comp, 54 | screen: &ProxyScreen{Style: tcell.StyleDefault}, 55 | }, 56 | size: -size, 57 | }) 58 | return flex 59 | } 60 | 61 | func (flex *Flex) RemoveComponent(comp Component) *Flex { 62 | for index := len(flex.children) - 1; index >= 0; index-- { 63 | if flex.children[index].target == comp { 64 | flex.children = append(flex.children[:index], flex.children[index+1:]...) 65 | } 66 | } 67 | return flex 68 | } 69 | 70 | func (flex *Flex) Draw(screen Screen) { 71 | width, height := screen.Size() 72 | screen.Clear() 73 | relTotalSize := width 74 | if flex.direction == FlexRow { 75 | relTotalSize = height 76 | } 77 | relParts := 0 78 | for _, child := range flex.children { 79 | if child.size > 0 { 80 | relTotalSize -= child.size 81 | } else { 82 | relParts -= child.size 83 | } 84 | 85 | } 86 | offset := 0 87 | for _, child := range flex.children { 88 | child.screen.Parent = screen 89 | size := child.size 90 | if size < 0 { 91 | size = relTotalSize * (-size) / relParts 92 | } 93 | if flex.direction == FlexRow { 94 | child.screen.Height = size 95 | child.screen.Width = width 96 | child.screen.OffsetY = offset 97 | child.screen.OffsetX = 0 98 | } else { 99 | child.screen.Height = height 100 | child.screen.Width = size 101 | child.screen.OffsetY = 0 102 | child.screen.OffsetX = offset 103 | } 104 | offset += size 105 | if flex.focused == nil || child != *flex.focused { 106 | child.target.Draw(child.screen) 107 | } 108 | } 109 | if flex.focused != nil { 110 | flex.focused.target.Draw(flex.focused.screen) 111 | } 112 | } 113 | 114 | func (flex *Flex) OnKeyEvent(event KeyEvent) bool { 115 | if flex.focused != nil { 116 | return flex.focused.target.OnKeyEvent(event) 117 | } 118 | return false 119 | } 120 | 121 | func (flex *Flex) OnPasteEvent(event PasteEvent) bool { 122 | if flex.focused != nil { 123 | return flex.focused.target.OnPasteEvent(event) 124 | } 125 | return false 126 | } 127 | 128 | func (flex *Flex) SetFocused(comp Component) { 129 | for i := range flex.children { 130 | childp := &flex.children[i] 131 | if childp.target == comp { 132 | flex.focused = childp 133 | flex.focused.Focus() 134 | break 135 | } 136 | } 137 | } 138 | 139 | func (flex *Flex) OnMouseEvent(event MouseEvent) bool { 140 | if flex.focused != nil && flex.focused.screen.IsInArea(event.Position()) { 141 | screen := flex.focused.screen 142 | return flex.focused.target.OnMouseEvent(OffsetMouseEvent(event, -screen.OffsetX, -screen.OffsetY)) 143 | } 144 | for _, child := range flex.children { 145 | if child.screen.IsInArea(event.Position()) { 146 | focusChanged := false 147 | if event.Buttons() == tcell.Button1 && !event.HasMotion() { 148 | if flex.focused != nil { 149 | flex.focused.Blur() 150 | } 151 | flex.focused = &child 152 | flex.focused.Focus() 153 | focusChanged = true 154 | } 155 | return child.target.OnMouseEvent(OffsetMouseEvent(event, -child.screen.OffsetX, -child.screen.OffsetY)) || 156 | focusChanged 157 | 158 | } 159 | } 160 | if event.Buttons() == tcell.Button1 && flex.focused != nil && !event.HasMotion() { 161 | flex.focused.Blur() 162 | flex.focused = nil 163 | return true 164 | } 165 | return false 166 | } 167 | 168 | func (flex *Flex) Focus() {} 169 | 170 | func (flex *Flex) Blur() { 171 | if flex.focused != nil { 172 | flex.focused.Blur() 173 | flex.focused = nil 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /form.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "github.com/gdamore/tcell/v2" 12 | ) 13 | 14 | type Form struct { 15 | *Grid 16 | items []*gridChild 17 | } 18 | 19 | type FormItem interface { 20 | Component 21 | Submit(event KeyEvent) bool 22 | } 23 | 24 | func NewForm() *Form { 25 | return &Form{ 26 | Grid: NewGrid(), 27 | } 28 | } 29 | 30 | func (form *Form) Draw(screen Screen) { 31 | form.Grid.Draw(screen) 32 | } 33 | 34 | func (form *Form) FocusNextItem() { 35 | for i := 0; i < len(form.items)-1; i++ { 36 | if form.focused == form.items[i] { 37 | form.setFocused(form.items[i+1]) 38 | return 39 | } 40 | } 41 | form.setFocused(form.items[0]) 42 | } 43 | 44 | func (form *Form) FocusPreviousItem() { 45 | for i := len(form.items) - 1; i > 0; i-- { 46 | if form.focused == form.items[i] { 47 | form.setFocused(form.items[i-1]) 48 | return 49 | } 50 | } 51 | form.setFocused(form.items[len(form.items)-1]) 52 | } 53 | 54 | func (form *Form) AddFormItem(comp Component, x, y, width, height int) *Form { 55 | child := form.Grid.createChild(comp, x, y, width, height) 56 | form.items = append(form.items, child) 57 | form.Grid.addChild(child) 58 | return form 59 | } 60 | 61 | func (form *Form) RemoveFormItem(comp Component) *Form { 62 | for index := len(form.items) - 1; index >= 0; index-- { 63 | if form.items[index].target == comp { 64 | form.items = append(form.items[:index], form.items[index+1:]...) 65 | } 66 | } 67 | form.Grid.RemoveComponent(comp) 68 | return form 69 | } 70 | 71 | func (form *Form) OnKeyEvent(event KeyEvent) bool { 72 | switch event.Key() { 73 | case tcell.KeyTab: 74 | form.FocusNextItem() 75 | return true 76 | case tcell.KeyBacktab: 77 | form.FocusPreviousItem() 78 | return true 79 | case tcell.KeyEnter: 80 | if form.focused != nil { 81 | if fi, ok := form.focused.target.(FormItem); ok { 82 | if fi.Submit(event) { 83 | form.FocusNextItem() 84 | return true 85 | } else { 86 | return false 87 | } 88 | } 89 | } 90 | } 91 | return form.Grid.OnKeyEvent(event) 92 | } 93 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module go.mau.fi/mauview 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.23.3 6 | 7 | require ( 8 | github.com/gdamore/tcell/v2 v2.7.4 9 | github.com/mattn/go-runewidth v0.0.16 10 | github.com/rivo/uniseg v0.4.7 11 | github.com/zyedidia/clipboard v1.0.4 12 | ) 13 | 14 | require ( 15 | github.com/gdamore/encoding v1.0.0 // indirect 16 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 17 | golang.org/x/sys v0.17.0 // indirect 18 | golang.org/x/term v0.17.0 // indirect 19 | golang.org/x/text v0.14.0 // indirect 20 | ) 21 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= 2 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 3 | github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU= 4 | github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg= 5 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 6 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 7 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 8 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 9 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 10 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 11 | github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 12 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 13 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 14 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 15 | github.com/zyedidia/clipboard v1.0.4 h1:r6GUQOyPtIaApRLeD56/U+2uJbXis6ANGbKWCljULEo= 16 | github.com/zyedidia/clipboard v1.0.4/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA= 17 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 18 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 19 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 20 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 21 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 22 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 23 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 24 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 25 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 26 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 27 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 28 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 29 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 30 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 32 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 33 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= 35 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 36 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 37 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 38 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 39 | golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= 40 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 41 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 42 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 43 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 44 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 45 | golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= 46 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 47 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 48 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 49 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 50 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 51 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 52 | -------------------------------------------------------------------------------- /grid.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "github.com/gdamore/tcell/v2" 12 | ) 13 | 14 | type gridChild struct { 15 | genericChild 16 | relWidth int 17 | relHeight int 18 | relX int 19 | relY int 20 | } 21 | 22 | type Grid struct { 23 | screen Screen 24 | children []*gridChild 25 | focused *gridChild 26 | 27 | focusReceived bool 28 | 29 | prevWidth int 30 | prevHeight int 31 | forceResize bool 32 | 33 | columnWidths []int 34 | rowHeights []int 35 | 36 | onFocusChanged func(from, to Component) 37 | } 38 | 39 | func NewGrid() *Grid { 40 | return &Grid{ 41 | children: []*gridChild{}, 42 | focused: nil, 43 | prevWidth: -1, 44 | prevHeight: -1, 45 | forceResize: false, 46 | columnWidths: []int{-1}, 47 | rowHeights: []int{-1}, 48 | } 49 | } 50 | 51 | func extend(arr []int, newSize int) []int { 52 | newArr := make([]int, newSize) 53 | copy(newArr, arr) 54 | for i := len(arr); i < len(newArr); i++ { 55 | newArr[i] = -1 56 | } 57 | return newArr 58 | } 59 | 60 | func (form *Form) SetOnFocusChanged(fn func(from, to Component)) *Form { 61 | form.onFocusChanged = fn 62 | return form 63 | } 64 | 65 | func (grid *Grid) createChild(comp Component, x, y, width, height int) *gridChild { 66 | if x+width >= len(grid.columnWidths) { 67 | grid.columnWidths = extend(grid.columnWidths, x+width) 68 | } 69 | if y+height >= len(grid.rowHeights) { 70 | grid.rowHeights = extend(grid.rowHeights, y+height) 71 | } 72 | return &gridChild{ 73 | genericChild: genericChild{ 74 | screen: &ProxyScreen{Parent: grid.screen, Style: tcell.StyleDefault}, 75 | target: comp, 76 | }, 77 | relWidth: width, 78 | relHeight: height, 79 | relX: x, 80 | relY: y, 81 | } 82 | } 83 | 84 | func (grid *Grid) addChild(child *gridChild) { 85 | if child.relX+child.relWidth >= len(grid.columnWidths) { 86 | grid.columnWidths = extend(grid.columnWidths, child.relX+child.relWidth) 87 | } 88 | if child.relY+child.relHeight >= len(grid.rowHeights) { 89 | grid.rowHeights = extend(grid.rowHeights, child.relY+child.relHeight) 90 | } 91 | grid.children = append(grid.children, child) 92 | grid.forceResize = true 93 | } 94 | 95 | func (grid *Grid) AddComponent(comp Component, x, y, width, height int) *Grid { 96 | grid.addChild(grid.createChild(comp, x, y, width, height)) 97 | return grid 98 | } 99 | 100 | func (grid *Grid) RemoveComponent(comp Component) *Grid { 101 | for index := len(grid.children) - 1; index >= 0; index-- { 102 | if grid.children[index].target == comp { 103 | grid.children = append(grid.children[:index], grid.children[index+1:]...) 104 | } 105 | } 106 | return grid 107 | } 108 | 109 | func (grid *Grid) SetColumn(col, width int) *Grid { 110 | if col >= len(grid.columnWidths) { 111 | grid.columnWidths = extend(grid.columnWidths, col+1) 112 | } 113 | grid.columnWidths[col] = width 114 | return grid 115 | } 116 | 117 | func (grid *Grid) SetRow(row, height int) *Grid { 118 | if row >= len(grid.rowHeights) { 119 | grid.rowHeights = extend(grid.rowHeights, row+1) 120 | } 121 | grid.rowHeights[row] = height 122 | return grid 123 | } 124 | 125 | func (grid *Grid) SetColumns(columns []int) *Grid { 126 | grid.columnWidths = columns 127 | return grid 128 | } 129 | 130 | func (grid *Grid) SetRows(rows []int) *Grid { 131 | grid.rowHeights = rows 132 | return grid 133 | } 134 | 135 | func pnSum(arr []int) (int, int) { 136 | positive := 0 137 | negative := 0 138 | for _, i := range arr { 139 | if i < 0 { 140 | negative -= i 141 | } else { 142 | positive += i 143 | } 144 | } 145 | return positive, negative 146 | } 147 | 148 | func fillDynamic(arr []int, size, dynamicItems int) []int { 149 | if dynamicItems == 0 { 150 | return arr 151 | } 152 | part := size / dynamicItems 153 | remainder := size % dynamicItems 154 | newArr := make([]int, len(arr)) 155 | for i, val := range arr { 156 | if val < 0 { 157 | newArr[i] = part * -val 158 | if remainder > 0 { 159 | remainder-- 160 | newArr[i]++ 161 | } 162 | } else { 163 | newArr[i] = val 164 | } 165 | } 166 | return newArr 167 | } 168 | 169 | func (grid *Grid) OnResize(width, height int) { 170 | absColWidth, dynamicColumns := pnSum(grid.columnWidths) 171 | columnWidths := fillDynamic(grid.columnWidths, width-absColWidth, dynamicColumns) 172 | absRowHeight, dynamicRows := pnSum(grid.rowHeights) 173 | rowHeights := fillDynamic(grid.rowHeights, height-absRowHeight, dynamicRows) 174 | for _, child := range grid.children { 175 | child.screen.OffsetX, _ = pnSum(columnWidths[:child.relX]) 176 | child.screen.OffsetY, _ = pnSum(rowHeights[:child.relY]) 177 | child.screen.Width, _ = pnSum(columnWidths[child.relX : child.relX+child.relWidth]) 178 | child.screen.Height, _ = pnSum(rowHeights[child.relY : child.relY+child.relHeight]) 179 | } 180 | grid.prevWidth, grid.prevHeight = width, height 181 | } 182 | 183 | func (grid *Grid) Draw(screen Screen) { 184 | width, height := screen.Size() 185 | if grid.forceResize || grid.prevWidth != width || grid.prevHeight != height { 186 | grid.OnResize(screen.Size()) 187 | } 188 | grid.forceResize = false 189 | screen.Clear() 190 | screenChanged := false 191 | if screen != grid.screen { 192 | grid.screen = screen 193 | screenChanged = true 194 | } 195 | for _, child := range grid.children { 196 | if screenChanged { 197 | child.screen.Parent = screen 198 | } 199 | if grid.focused == nil || child != grid.focused { 200 | child.target.Draw(child.screen) 201 | } 202 | } 203 | if grid.focused != nil { 204 | grid.focused.target.Draw(grid.focused.screen) 205 | } 206 | } 207 | 208 | func (grid *Grid) OnKeyEvent(event KeyEvent) bool { 209 | if grid.focused != nil { 210 | return grid.focused.target.OnKeyEvent(event) 211 | } 212 | return false 213 | } 214 | 215 | func (grid *Grid) OnPasteEvent(event PasteEvent) bool { 216 | if grid.focused != nil { 217 | return grid.focused.target.OnPasteEvent(event) 218 | } 219 | return false 220 | } 221 | 222 | func (grid *Grid) setFocused(item *gridChild) { 223 | if grid.focused != nil { 224 | grid.focused.Blur() 225 | } 226 | var prevFocus, newFocus Component 227 | if grid.focused != nil { 228 | prevFocus = grid.focused.target 229 | } 230 | if item != nil { 231 | newFocus = item.target 232 | } 233 | grid.focused = item 234 | if grid.focusReceived && grid.focused != nil { 235 | grid.focused.Focus() 236 | } 237 | if grid.onFocusChanged != nil { 238 | grid.onFocusChanged(prevFocus, newFocus) 239 | } 240 | } 241 | func (grid *Grid) OnMouseEvent(event MouseEvent) bool { 242 | if grid.focused != nil && grid.focused.screen.IsInArea(event.Position()) { 243 | screen := grid.focused.screen 244 | return grid.focused.target.OnMouseEvent(OffsetMouseEvent(event, -screen.OffsetX, -screen.OffsetY)) 245 | } 246 | for _, child := range grid.children { 247 | if child.screen.IsInArea(event.Position()) { 248 | focusChanged := false 249 | if event.Buttons() == tcell.Button1 && !event.HasMotion() { 250 | grid.setFocused(child) 251 | focusChanged = true 252 | } 253 | return child.target.OnMouseEvent(OffsetMouseEvent(event, -child.screen.OffsetX, -child.screen.OffsetY)) || 254 | focusChanged 255 | 256 | } 257 | } 258 | if event.Buttons() == tcell.Button1 && !event.HasMotion() && grid.focused != nil { 259 | grid.setFocused(nil) 260 | return true 261 | } 262 | return false 263 | } 264 | 265 | func (grid *Grid) Focus() { 266 | grid.focusReceived = true 267 | if grid.focused != nil { 268 | grid.focused.Focus() 269 | } 270 | } 271 | 272 | func (grid *Grid) Blur() { 273 | if grid.focused != nil { 274 | grid.setFocused(nil) 275 | } 276 | grid.focusReceived = false 277 | } 278 | -------------------------------------------------------------------------------- /group.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | type genericChild struct { 11 | screen *ProxyScreen 12 | target Component 13 | } 14 | 15 | func (child genericChild) Focus() { 16 | focusable, ok := child.target.(Focusable) 17 | if ok { 18 | focusable.Focus() 19 | } 20 | } 21 | 22 | func (child genericChild) Blur() { 23 | focusable, ok := child.target.(Focusable) 24 | if ok { 25 | focusable.Blur() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /inputarea.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "strings" 12 | "time" 13 | 14 | "github.com/mattn/go-runewidth" 15 | "github.com/zyedidia/clipboard" 16 | 17 | "github.com/gdamore/tcell/v2" 18 | ) 19 | 20 | // InputArea is a multi-line user-editable text area. 21 | type InputArea struct { 22 | // Cursor position as the runewidth from the start of the input area text. 23 | cursorOffsetW int 24 | // Cursor offset from the left of the input area. 25 | cursorOffsetX int 26 | // Cursor offset from the top of the text. 27 | cursorOffsetY int 28 | // Number of lines (from top) to offset rendering. 29 | viewOffsetY int 30 | 31 | // The start of the selection as the runewidth from the start of the input area text. 32 | selectionStartW int 33 | // The end of the selection. 34 | selectionEndW int 35 | 36 | // The text that was entered. 37 | text string 38 | 39 | // The text split into lines. Updated each during each render. 40 | lines []string 41 | 42 | // The text to be displayed in the input area when it is empty. 43 | placeholder string 44 | 45 | // The background color of the input area. 46 | fieldBackgroundColor tcell.Color 47 | // The text color of the input area. 48 | fieldTextColor tcell.Color 49 | // The text color of the placeholder. 50 | placeholderTextColor tcell.Color 51 | // The text color of selected text. 52 | selectionTextColor tcell.Color 53 | // The background color of selected text. 54 | selectionBackgroundColor tcell.Color 55 | 56 | // Whether or not to enable vim-style keybindings. 57 | vimBindings bool 58 | // Whether or not text should be automatically copied to the primary clipboard when selected. 59 | // Most apps on Linux work this way. 60 | copySelection bool 61 | 62 | // Whether or not the input area is focused. 63 | focused bool 64 | 65 | drawPrepared bool 66 | 67 | // An optional function which is called when the input has changed. 68 | changed func(text string) 69 | 70 | // An optional function which is called when the user presses tab. 71 | tabComplete func(text string, pos int) 72 | // An optional function which is called when the user presses the down arrow at the end of the input area. 73 | pressKeyDownAtEnd func() 74 | // An optional function which is called when the user presses the up arrow at the beginning of the input area. 75 | pressKeyUpAtStart func() 76 | 77 | // Change history for undo/redo functionality. 78 | history []*inputAreaSnapshot 79 | // Current position in the history array for redo functionality. 80 | historyPtr int 81 | // Maximum number of history snapshots to keep. 82 | historyMaxSize int 83 | // Maximum delay (ms) between changes to edit the previous snapshot instead of creating a new one. 84 | historyMaxEditDelay int64 85 | // Maximum age (ms) of the previous snapshot to edit the previous snapshot instead of craeting a new one. 86 | historyMaxSnapshotAge int64 87 | 88 | // Timestamp of the last click used for detecting double clicks. 89 | lastClick int64 90 | // Position of the last click used for detecting double clicks. 91 | lastClickX int 92 | lastClickY int 93 | // Number of clicks done within doubleClickTimeout of eachother. 94 | clickStreak int 95 | // Maximum delay (ms) between clicks to count as a double click. 96 | doubleClickTimeout int64 97 | 98 | // The previous word start and end X position that the mouse was dragged over when selecting words at a time. 99 | // Used to detect if the mouse is still over the same word. 100 | lastWordSelectionExtendXStart int 101 | lastWordSelectionExtendXEnd int 102 | // The position where the current selection streak started. 103 | // Used to properly handle the user selecting text backwards. 104 | selectionStreakStartWStart int 105 | selectionStreakStartWEnd int 106 | selectionStreakStartXStart int 107 | selectionStreakStartY int 108 | } 109 | 110 | // NewInputArea returns a new input field. 111 | func NewInputArea() *InputArea { 112 | return &InputArea{ 113 | fieldBackgroundColor: Styles.PrimitiveBackgroundColor, 114 | fieldTextColor: Styles.PrimaryTextColor, 115 | placeholderTextColor: Styles.SecondaryTextColor, 116 | selectionTextColor: Styles.PrimaryTextColor, 117 | selectionBackgroundColor: Styles.ContrastBackgroundColor, 118 | 119 | vimBindings: false, 120 | copySelection: true, 121 | focused: false, 122 | 123 | selectionEndW: -1, 124 | selectionStartW: -1, 125 | 126 | history: []*inputAreaSnapshot{{"", 0, 0, 0, true}}, 127 | historyPtr: 0, 128 | 129 | historyMaxSize: 256, 130 | historyMaxEditDelay: 1 * 1000, 131 | historyMaxSnapshotAge: 3 * 1000, 132 | 133 | lastClick: 0, 134 | doubleClickTimeout: 1 * 500, 135 | } 136 | } 137 | 138 | // SetText sets the current text of the input field. 139 | func (field *InputArea) SetText(text string) *InputArea { 140 | field.text = text 141 | if field.changed != nil { 142 | field.changed(text) 143 | } 144 | field.snapshot(true) 145 | return field 146 | } 147 | 148 | // SetTextAndMoveCursor sets the current text of the input field and moves the cursor with the width difference. 149 | func (field *InputArea) SetTextAndMoveCursor(text string) *InputArea { 150 | oldWidth := iaStringWidth(field.text) 151 | field.text = text 152 | newWidth := iaStringWidth(field.text) 153 | if oldWidth != newWidth { 154 | field.cursorOffsetW += newWidth - oldWidth 155 | } 156 | if field.changed != nil { 157 | field.changed(field.text) 158 | } 159 | field.snapshot(true) 160 | return field 161 | } 162 | 163 | // GetText returns the current text of the input field. 164 | func (field *InputArea) GetText() string { 165 | return field.text 166 | } 167 | 168 | // SetPlaceholder sets the text to be displayed when the input text is empty. 169 | func (field *InputArea) SetPlaceholder(text string) *InputArea { 170 | field.placeholder = text 171 | return field 172 | } 173 | 174 | // SetBackgroundColor sets the background color of the input area. 175 | func (field *InputArea) SetBackgroundColor(color tcell.Color) *InputArea { 176 | field.fieldBackgroundColor = color 177 | return field 178 | } 179 | 180 | // SetTextColor sets the text color of the input area. 181 | func (field *InputArea) SetTextColor(color tcell.Color) *InputArea { 182 | field.fieldTextColor = color 183 | return field 184 | } 185 | 186 | // SetPlaceholderTextColor sets the text color of placeholder text. 187 | func (field *InputArea) SetPlaceholderTextColor(color tcell.Color) *InputArea { 188 | field.placeholderTextColor = color 189 | return field 190 | } 191 | 192 | // SetChangedFunc sets a handler which is called whenever the text of the input 193 | // field has changed. It receives the current text (after the change). 194 | func (field *InputArea) SetChangedFunc(handler func(text string)) *InputArea { 195 | field.changed = handler 196 | return field 197 | } 198 | 199 | func (field *InputArea) SetTabCompleteFunc(handler func(text string, cursorOffset int)) *InputArea { 200 | field.tabComplete = handler 201 | return field 202 | } 203 | 204 | func (field *InputArea) SetPressKeyUpAtStartFunc(handler func()) *InputArea { 205 | field.pressKeyUpAtStart = handler 206 | return field 207 | } 208 | 209 | func (field *InputArea) SetPressKeyDownAtEndFunc(handler func()) *InputArea { 210 | field.pressKeyDownAtEnd = handler 211 | return field 212 | } 213 | 214 | // GetTextHeight returns the number of lines in the text during the previous render. 215 | func (field *InputArea) GetTextHeight() int { 216 | return len(field.lines) 217 | } 218 | 219 | // inputAreaSnapshot is a single history snapshot of the input area state. 220 | type inputAreaSnapshot struct { 221 | text string 222 | cursorOffsetW int 223 | origTimestamp int64 224 | editTimestamp int64 225 | locked bool 226 | } 227 | 228 | func millis() int64 { 229 | return time.Now().UnixNano() / 1e6 230 | } 231 | 232 | // Snapshot saves the current editor state into undo history. 233 | func (field *InputArea) snapshot(forceNew bool) { 234 | cur := field.history[field.historyPtr] 235 | now := millis() 236 | if cur.locked || forceNew || now > cur.editTimestamp+field.historyMaxEditDelay || now > cur.origTimestamp+field.historyMaxSnapshotAge { 237 | newSnapshot := &inputAreaSnapshot{ 238 | text: field.text, 239 | cursorOffsetW: field.cursorOffsetW, 240 | origTimestamp: now, 241 | editTimestamp: now, 242 | } 243 | if len(field.history) >= field.historyMaxSize { 244 | field.history = append(field.history[1:field.historyPtr+1], newSnapshot) 245 | } else { 246 | field.history = append(field.history[0:field.historyPtr+1], newSnapshot) 247 | field.historyPtr++ 248 | } 249 | } else { 250 | cur.text = field.text 251 | cur.cursorOffsetW = field.cursorOffsetW 252 | cur.editTimestamp = now 253 | } 254 | } 255 | 256 | // Redo reverses an undo. 257 | func (field *InputArea) Redo() { 258 | if field.historyPtr >= len(field.history)-1 { 259 | return 260 | } 261 | field.historyPtr++ 262 | newCur := field.history[field.historyPtr] 263 | newCur.locked = true 264 | field.text = newCur.text 265 | field.cursorOffsetW = newCur.cursorOffsetW 266 | } 267 | 268 | // Undo reverses the input area to the previous history snapshot. 269 | func (field *InputArea) Undo() { 270 | if field.historyPtr == 0 { 271 | return 272 | } 273 | field.historyPtr-- 274 | newCur := field.history[field.historyPtr] 275 | newCur.locked = true 276 | field.text = newCur.text 277 | field.cursorOffsetW = newCur.cursorOffsetW 278 | } 279 | 280 | // recalculateCursorOffset recalculates the runewidth cursor offset based on the X and Y cursor offsets. 281 | func (field *InputArea) recalculateCursorOffset() { 282 | cursorOffsetW := 0 283 | for i, str := range field.lines { 284 | ln := iaStringWidth(str) 285 | if i < field.cursorOffsetY { 286 | cursorOffsetW += ln 287 | } else { 288 | if ln == 0 { 289 | break 290 | } else if str[len(str)-1] == '\n' { 291 | ln-- 292 | } 293 | if field.cursorOffsetX < ln { 294 | cursorOffsetW += field.cursorOffsetX 295 | } else { 296 | cursorOffsetW += ln 297 | } 298 | break 299 | } 300 | } 301 | field.cursorOffsetW = cursorOffsetW 302 | textWidth := iaStringWidth(field.text) 303 | if field.cursorOffsetW > textWidth { 304 | field.cursorOffsetW = textWidth 305 | field.recalculateCursorPos() 306 | } 307 | } 308 | 309 | // recalculateCursorPos recalculates the X and Y cursor offsets based on the runewidth cursor offset. 310 | func (field *InputArea) recalculateCursorPos() { 311 | cursorOffsetY := 0 312 | cursorOffsetX := field.cursorOffsetW 313 | for i, str := range field.lines { 314 | if cursorOffsetX >= iaStringWidth(str) { 315 | cursorOffsetX -= iaStringWidth(str) 316 | } else { 317 | cursorOffsetY = i 318 | break 319 | } 320 | } 321 | field.cursorOffsetX = cursorOffsetX 322 | field.cursorOffsetY = cursorOffsetY 323 | } 324 | 325 | func matchBoundaryPattern(extract string) string { 326 | matches := boundaryPattern.FindAllStringIndex(extract, -1) 327 | if len(matches) > 0 { 328 | if match := matches[len(matches)-1]; len(match) >= 2 { 329 | if until := match[1]; until < len(extract) { 330 | extract = extract[:until] 331 | } 332 | } 333 | } 334 | return extract 335 | } 336 | 337 | // prepareText splits the text into lines that fit the input area. 338 | func (field *InputArea) prepareText(width int) { 339 | var lines []string 340 | if len(field.text) == 0 { 341 | field.lines = lines 342 | return 343 | } 344 | forcedLinebreaks := strings.Split(field.text, "\n") 345 | for _, str := range forcedLinebreaks { 346 | str = str + "\n" 347 | // Adapted from tview/textview.go#reindexBuffer() 348 | for len(str) > 0 { 349 | extract := iaSubstringBefore(str, width-1) 350 | if len(extract) < len(str) { 351 | if spaces := spacePattern.FindStringIndex(str[len(extract):]); spaces != nil && spaces[0] == 0 { 352 | extract = str[:len(extract)+spaces[1]] 353 | } 354 | extract = matchBoundaryPattern(extract) 355 | } 356 | lines = append(lines, extract) 357 | str = str[len(extract):] 358 | } 359 | } 360 | field.lines = lines 361 | } 362 | 363 | // updateViewOffset updates the view offset so that: 364 | // - it is not negative 365 | // - it is not unnecessarily high 366 | // - the cursor is within the rendered area 367 | func (field *InputArea) updateViewOffset(height int) { 368 | if field.viewOffsetY < 0 { 369 | field.viewOffsetY = 0 370 | } else if len(field.lines) > height && field.viewOffsetY+height > len(field.lines) { 371 | field.viewOffsetY = len(field.lines) - height 372 | } 373 | if field.cursorOffsetY-field.viewOffsetY < 0 { 374 | field.viewOffsetY = field.cursorOffsetY 375 | } else if field.cursorOffsetY >= field.viewOffsetY+height { 376 | field.viewOffsetY = field.cursorOffsetY - height + 1 377 | } 378 | } 379 | 380 | // drawText draws the text and the cursor. 381 | func (field *InputArea) drawText(screen Screen) { 382 | width, height := screen.Size() 383 | if len(field.lines) == 0 { 384 | if len(field.placeholder) > 0 { 385 | Print(screen, field.placeholder, 0, 0, width, AlignLeft, field.placeholderTextColor) 386 | } 387 | return 388 | } 389 | defaultStyle := tcell.StyleDefault.Foreground(field.fieldTextColor).Background(field.fieldBackgroundColor) 390 | highlightStyle := defaultStyle.Foreground(field.selectionTextColor).Background(field.selectionBackgroundColor) 391 | rwOffset := 0 392 | for y := 0; y <= field.viewOffsetY+height && y < len(field.lines); y++ { 393 | if y < field.viewOffsetY { 394 | rwOffset += iaStringWidth(field.lines[y]) 395 | continue 396 | } 397 | x := 0 398 | for _, ch := range []rune(field.lines[y]) { 399 | w := iaRuneWidth(ch) 400 | var style tcell.Style 401 | if rwOffset >= field.selectionStartW && rwOffset < field.selectionEndW { 402 | style = highlightStyle 403 | } else { 404 | style = defaultStyle 405 | } 406 | rwOffset += w 407 | for w > 0 { 408 | screen.SetContent(x, y-field.viewOffsetY, ch, nil, style) 409 | x++ 410 | w-- 411 | } 412 | } 413 | } 414 | } 415 | 416 | func (field *InputArea) PrepareDraw(width int) { 417 | field.prepareText(width) 418 | field.recalculateCursorPos() 419 | } 420 | 421 | // Draw draws this primitive onto the screen. 422 | func (field *InputArea) Draw(screen Screen) { 423 | width, height := screen.Size() 424 | if height < 1 || width < 1 { 425 | return 426 | } 427 | 428 | if !field.drawPrepared { 429 | field.PrepareDraw(width) 430 | } 431 | field.updateViewOffset(height) 432 | screen.SetStyle(tcell.StyleDefault.Background(field.fieldBackgroundColor)) 433 | screen.Clear() 434 | field.drawText(screen) 435 | if field.focused && field.selectionEndW == -1 { 436 | screen.ShowCursor(field.cursorOffsetX, field.cursorOffsetY-field.viewOffsetY) 437 | } 438 | field.drawPrepared = false 439 | } 440 | 441 | func iaRuneWidth(ch rune) int { 442 | if ch == '\n' { 443 | return 1 444 | } 445 | return runewidth.RuneWidth(ch) 446 | } 447 | 448 | func iaStringWidth(s string) (width int) { 449 | w := runewidth.StringWidth(s) 450 | for _, ch := range s { 451 | if ch == '\n' { 452 | w++ 453 | } 454 | } 455 | return w 456 | } 457 | 458 | func iaSubstringBefore(s string, w int) string { 459 | if iaStringWidth(s) <= w { 460 | return s 461 | } 462 | r := []rune(s) 463 | //tw := iaStringWidth(tail) 464 | //w -= tw 465 | width := 0 466 | i := 0 467 | for ; i < len(r); i++ { 468 | cw := iaRuneWidth(r[i]) 469 | if width+cw > w { 470 | break 471 | } 472 | width += cw 473 | } 474 | return string(r[0:i]) // + tail 475 | } 476 | 477 | // TypeRune inserts the given rune at the current cursor position. 478 | func (field *InputArea) TypeRune(ch rune) { 479 | var left, right string 480 | if field.selectionEndW != -1 { 481 | left = iaSubstringBefore(field.text, field.selectionStartW) 482 | rightLeft := iaSubstringBefore(field.text, field.selectionEndW) 483 | right = field.text[len(rightLeft):] 484 | field.cursorOffsetW = field.selectionStartW 485 | } else { 486 | left = iaSubstringBefore(field.text, field.cursorOffsetW) 487 | right = field.text[len(left):] 488 | } 489 | field.text = left + string(ch) + right 490 | field.cursorOffsetW += iaRuneWidth(ch) 491 | field.selectionEndW = -1 492 | field.selectionStartW = -1 493 | } 494 | 495 | // MoveCursorLeft moves the cursor left. 496 | // 497 | // If moveWord is true, the cursor moves a whole word to the left. 498 | // 499 | // If extendSelection is true, the selection is either extended to the left if the cursor is on the left side of the 500 | // selection or retracted from the right if the cursor is on the right side. If there is no existing selection, the 501 | // selection will be created towards the left of the cursor. 502 | func (field *InputArea) MoveCursorLeft(moveWord, extendSelection bool) { 503 | before := iaSubstringBefore(field.text, field.cursorOffsetW) 504 | var diff int 505 | if moveWord { 506 | diff = -iaStringWidth(lastWord.FindString(before)) 507 | } else if len(before) > 0 { 508 | beforeRunes := []rune(before) 509 | char := beforeRunes[len(beforeRunes)-1] 510 | diff = -iaRuneWidth(char) 511 | } 512 | if extendSelection { 513 | field.extendSelection(diff) 514 | } else { 515 | field.moveCursor(diff) 516 | } 517 | } 518 | 519 | // MoveCursorRight moves the cursor right. 520 | // 521 | // If moveWord is true, the cursor moves a whole word to the right. 522 | // 523 | // If extendSelection is true, the selection is either extended to the right if the cursor is on the right side of the 524 | // selection or retracted from the left if the cursor is on the left side. If there is no existing selection, the 525 | // selection will be created towards the right of the cursor. 526 | func (field *InputArea) MoveCursorRight(moveWord, extendSelection bool) { 527 | before := iaSubstringBefore(field.text, field.cursorOffsetW) 528 | after := field.text[len(before):] 529 | var diff int 530 | if moveWord { 531 | diff = +iaStringWidth(firstWord.FindString(after)) 532 | } else if len(after) > 0 { 533 | char := []rune(after)[0] 534 | diff = +iaRuneWidth(char) 535 | } 536 | if extendSelection { 537 | field.extendSelection(diff) 538 | } else { 539 | field.moveCursor(diff) 540 | } 541 | } 542 | 543 | func (field *InputArea) MoveCursorHome(extendSelection bool) { 544 | if extendSelection { 545 | field.extendSelection(-iaStringWidth(iaSubstringBefore(field.text, field.cursorOffsetW))) 546 | } else { 547 | field.selectionEndW = -1 548 | field.selectionStartW = -1 549 | field.cursorOffsetW = 0 550 | } 551 | } 552 | 553 | func (field *InputArea) MoveCursorEnd(extendSelection bool) { 554 | if extendSelection { 555 | after := field.text[len(iaSubstringBefore(field.text, field.cursorOffsetW)):] 556 | field.extendSelection(iaStringWidth(after)) 557 | } else { 558 | field.selectionEndW = -1 559 | field.selectionStartW = -1 560 | field.cursorOffsetW = iaStringWidth(field.text) 561 | } 562 | } 563 | 564 | // moveCursor resets the selection and adjusts the runewidth cursor offset. 565 | func (field *InputArea) moveCursor(diff int) { 566 | field.selectionEndW = -1 567 | field.selectionStartW = -1 568 | field.cursorOffsetW += diff 569 | } 570 | 571 | // extendSelection adjusts the selection or creates a selection. Negative values make the selection go left and 572 | // positive values make the selection go right. 573 | // "Go" in context of a selection means retracting or extending depending on which side the cursor is on. 574 | func (field *InputArea) extendSelection(diff int) { 575 | if field.selectionEndW == -1 { 576 | field.selectionStartW = field.cursorOffsetW 577 | field.selectionEndW = field.selectionStartW + diff 578 | } else if field.cursorOffsetW == field.selectionEndW { 579 | field.selectionEndW += diff 580 | } else if field.cursorOffsetW == field.selectionStartW { 581 | field.selectionStartW += diff 582 | } 583 | field.cursorOffsetW += diff 584 | if field.selectionStartW > field.selectionEndW { 585 | field.selectionStartW, field.selectionEndW = field.selectionEndW, field.selectionStartW 586 | } 587 | field.copy("primary", false) 588 | } 589 | 590 | // MoveCursorUp moves the cursor up one line. 591 | // 592 | // If extendSelection is true, the selection is either extended up if the cursor is at the beginning of the selection or 593 | // retracted from the bottom if the cursor is at the end of the selection. 594 | func (field *InputArea) MoveCursorUp(extendSelection bool) { 595 | pX, pY := field.cursorOffsetX, field.cursorOffsetY 596 | if field.cursorOffsetY > 0 { 597 | field.cursorOffsetY-- 598 | lineWidth := iaStringWidth(field.lines[field.cursorOffsetY]) 599 | if lineWidth < field.cursorOffsetX { 600 | field.cursorOffsetX = lineWidth 601 | } 602 | } else { 603 | field.cursorOffsetX = 0 604 | } 605 | if extendSelection && len(field.lines) > 0 { 606 | prevLineBefore := iaSubstringBefore(field.lines[pY], pX) 607 | curLineBefore := iaSubstringBefore(field.lines[field.cursorOffsetY], field.cursorOffsetX) 608 | curLineAfter := field.lines[field.cursorOffsetY][len(curLineBefore):] 609 | field.extendSelection(-iaStringWidth(curLineAfter + prevLineBefore)) 610 | } else { 611 | field.selectionStartW = -1 612 | field.selectionEndW = -1 613 | } 614 | prevOffsetW := field.cursorOffsetW 615 | field.recalculateCursorOffset() 616 | if field.cursorOffsetW == prevOffsetW && field.pressKeyUpAtStart != nil { 617 | field.pressKeyUpAtStart() 618 | } 619 | } 620 | 621 | // MoveCursorDown moves the cursor down one line. 622 | // 623 | // If extendSelection is true, the selection is either extended down if the cursor is at the end of the selection or 624 | // retracted from the top if the cursor is at the beginning of the selection. 625 | func (field *InputArea) MoveCursorDown(extendSelection bool) { 626 | pX, pY := field.cursorOffsetX, field.cursorOffsetY 627 | if field.cursorOffsetY < len(field.lines)-1 { 628 | field.cursorOffsetY++ 629 | lineWidth := iaStringWidth(field.lines[field.cursorOffsetY]) 630 | if lineWidth < field.cursorOffsetX { 631 | field.cursorOffsetX = lineWidth 632 | } 633 | } else if field.cursorOffsetY == len(field.lines)-1 { 634 | lineWidth := iaStringWidth(field.lines[field.cursorOffsetY]) 635 | field.cursorOffsetX = lineWidth 636 | } 637 | if extendSelection && len(field.lines) > 0 { 638 | prevLineBefore := iaSubstringBefore(field.lines[pY], pX) 639 | prevLineAfter := field.lines[pY][len(prevLineBefore):] 640 | curLineBefore := iaSubstringBefore(field.lines[field.cursorOffsetY], field.cursorOffsetX) 641 | field.extendSelection(iaStringWidth(prevLineAfter + curLineBefore)) 642 | } else { 643 | field.selectionStartW = -1 644 | field.selectionEndW = -1 645 | } 646 | prevOffsetW := field.cursorOffsetW 647 | field.recalculateCursorOffset() 648 | if field.cursorOffsetW == prevOffsetW && field.pressKeyDownAtEnd != nil { 649 | field.pressKeyDownAtEnd() 650 | } 651 | } 652 | 653 | // SetCursorPos sets the X and Y cursor offsets. 654 | func (field *InputArea) SetCursorPos(x, y int) { 655 | field.cursorOffsetX = x 656 | field.cursorOffsetY = y 657 | field.selectionStartW = -1 658 | field.selectionEndW = -1 659 | if field.cursorOffsetY > len(field.lines) { 660 | field.cursorOffsetY = len(field.lines) - 1 661 | } 662 | field.recalculateCursorOffset() 663 | } 664 | 665 | func (field *InputArea) GetCursorPos() (int, int) { 666 | return field.cursorOffsetX, field.cursorOffsetY 667 | } 668 | 669 | // SetCursorOffset sets the runewidth cursor offset. 670 | func (field *InputArea) SetCursorOffset(offset int) { 671 | if offset < 0 { 672 | offset = iaStringWidth(field.text) - (offset + 1) 673 | } 674 | field.cursorOffsetW = offset 675 | field.selectionStartW = -1 676 | field.selectionEndW = -1 677 | } 678 | 679 | func (field *InputArea) GetCursorOffset() int { 680 | return field.cursorOffsetW 681 | } 682 | 683 | func (field *InputArea) SetSelection(start, end int) { 684 | field.selectionStartW = start 685 | field.selectionEndW = end 686 | } 687 | 688 | func (field *InputArea) GetSelectedText() string { 689 | leftLeft := iaSubstringBefore(field.text, field.selectionStartW) 690 | rightLeft := iaSubstringBefore(field.text, field.selectionEndW) 691 | return rightLeft[len(leftLeft):] 692 | } 693 | 694 | func (field *InputArea) GetSelection() (int, int) { 695 | return field.selectionStartW, field.selectionEndW 696 | } 697 | 698 | func (field *InputArea) ClearSelection() { 699 | field.selectionStartW = -1 700 | field.selectionEndW = -1 701 | } 702 | 703 | // findWordAt finds the word around the given runewidth offset in the given string. 704 | // 705 | // Returns the start and end index of the word. 706 | func findWordAt(line string, x int) (beforePos, afterPos int) { 707 | before := iaSubstringBefore(line, x) 708 | after := line[len(before):] 709 | afterBound := boundaryPattern.FindStringIndex(after) 710 | if afterBound != nil { 711 | afterPos = afterBound[0] 712 | } else { 713 | afterPos = len(after) 714 | } 715 | afterPos += len(before) 716 | beforeBounds := boundaryPattern.FindAllStringIndex(before, -1) 717 | if len(beforeBounds) > 0 { 718 | beforeBound := beforeBounds[len(beforeBounds)-1] 719 | beforePos = beforeBound[1] 720 | } else { 721 | beforePos = 0 722 | } 723 | return 724 | } 725 | 726 | // startSelectionStreak selects the current word or line for double and triple clicks (respectively). 727 | func (field *InputArea) startSelectionStreak(x, y int) { 728 | field.cursorOffsetY = y 729 | if field.cursorOffsetY > len(field.lines) { 730 | field.cursorOffsetY = len(field.lines) - 1 731 | } else if len(field.lines) == 0 { 732 | return 733 | } 734 | line := field.lines[field.cursorOffsetY] 735 | fullLine := (field.clickStreak-2)%2 == 1 736 | if fullLine { 737 | field.cursorOffsetX = iaStringWidth(line) 738 | 739 | field.recalculateCursorOffset() 740 | 741 | field.selectionStartW = field.cursorOffsetW - field.cursorOffsetX 742 | field.selectionEndW = field.cursorOffsetW 743 | } else { 744 | beforePos, afterPos := findWordAt(line, x) 745 | field.cursorOffsetX = iaStringWidth(line[:afterPos]) 746 | 747 | field.recalculateCursorOffset() 748 | 749 | wordWidth := iaStringWidth(line[beforePos:afterPos]) 750 | field.selectionStartW = field.cursorOffsetW - wordWidth 751 | field.selectionEndW = field.cursorOffsetW 752 | 753 | field.selectionStreakStartWStart = field.selectionStartW 754 | field.selectionStreakStartWEnd = field.selectionEndW 755 | field.selectionStreakStartXStart = field.cursorOffsetX - wordWidth 756 | } 757 | 758 | field.selectionStreakStartY = field.cursorOffsetY 759 | field.copy("primary", false) 760 | } 761 | 762 | // ExtendSelection extends the selection as if the user dragged their mouse to the given coordinates. 763 | func (field *InputArea) ExtendSelection(x, y int) { 764 | field.cursorOffsetY = y 765 | if field.cursorOffsetY > len(field.lines) { 766 | field.cursorOffsetY = len(field.lines) - 1 767 | } 768 | if field.clickStreak <= 1 { 769 | field.cursorOffsetX = x 770 | } else if (field.clickStreak-2)%2 == 0 { 771 | if field.lastClickY == y && x >= field.lastWordSelectionExtendXStart && x <= field.lastWordSelectionExtendXEnd { 772 | return 773 | } 774 | line := field.lines[field.cursorOffsetY] 775 | beforePos, afterPos := findWordAt(line, x) 776 | field.lastWordSelectionExtendXStart = beforePos 777 | field.lastWordSelectionExtendXEnd = afterPos 778 | if y < field.selectionStreakStartY || (y == field.selectionStreakStartY && x < field.selectionStreakStartXStart) { 779 | field.cursorOffsetW = field.selectionStartW 780 | field.selectionEndW = field.selectionStreakStartWEnd 781 | field.cursorOffsetX = iaStringWidth(line[:beforePos]) 782 | } else { 783 | field.cursorOffsetW = field.selectionEndW 784 | field.selectionStartW = field.selectionStreakStartWStart 785 | field.cursorOffsetX = iaStringWidth(line[:afterPos]) 786 | } 787 | } else { 788 | if field.lastClickY == y { 789 | return 790 | } 791 | if field.cursorOffsetY == field.selectionStreakStartY { 792 | // Special case to not mess up stuff when dragging mouse over selection streak start. 793 | line := field.lines[field.cursorOffsetY] 794 | field.cursorOffsetX = iaStringWidth(line) 795 | field.recalculateCursorOffset() 796 | field.selectionStartW = field.cursorOffsetW - field.cursorOffsetX 797 | field.selectionEndW = field.cursorOffsetW 798 | return 799 | } else if field.cursorOffsetY < field.selectionStreakStartY { 800 | field.cursorOffsetW = field.selectionStartW 801 | field.cursorOffsetX = 0 802 | } else { 803 | field.cursorOffsetW = field.selectionEndW 804 | line := field.lines[field.cursorOffsetY] 805 | field.cursorOffsetX = iaStringWidth(line) 806 | } 807 | } 808 | prevOffset := field.cursorOffsetW 809 | field.recalculateCursorOffset() 810 | if field.selectionEndW == -1 { 811 | field.selectionStartW = prevOffset 812 | field.selectionEndW = field.cursorOffsetW 813 | } else if prevOffset == field.selectionEndW { 814 | field.selectionEndW = field.cursorOffsetW 815 | } else { 816 | field.selectionStartW = field.cursorOffsetW 817 | } 818 | if field.selectionStartW > field.selectionEndW { 819 | field.selectionStartW, field.selectionEndW = field.selectionEndW, field.selectionStartW 820 | } 821 | field.copy("primary", false) 822 | } 823 | 824 | // RemoveNextCharacter removes the character after the cursor. 825 | func (field *InputArea) RemoveNextCharacter() { 826 | if field.selectionEndW > 0 { 827 | field.RemoveSelection() 828 | return 829 | } else if field.cursorOffsetW >= iaStringWidth(field.text) { 830 | return 831 | } 832 | left := iaSubstringBefore(field.text, field.cursorOffsetW) 833 | // Take everything after the left part minus the first character. 834 | right := string([]rune(field.text[len(left):])[1:]) 835 | 836 | field.text = left + right 837 | } 838 | 839 | // RemovePreviousWord removes the word before the cursor. 840 | func (field *InputArea) RemovePreviousWord() { 841 | if field.selectionEndW > 0 { 842 | field.RemoveSelection() 843 | return 844 | } 845 | left := iaSubstringBefore(field.text, field.cursorOffsetW) 846 | replacement := lastWord.ReplaceAllString(left, "") 847 | field.text = replacement + field.text[len(left):] 848 | field.cursorOffsetW = iaStringWidth(replacement) 849 | } 850 | 851 | // RemoveSelection removes the selected content. 852 | func (field *InputArea) RemoveSelection() { 853 | leftLeft := iaSubstringBefore(field.text, field.selectionStartW) 854 | rightLeft := iaSubstringBefore(field.text, field.selectionEndW) 855 | rightRight := field.text[len(rightLeft):] 856 | field.text = leftLeft + rightRight 857 | if field.cursorOffsetW == field.selectionEndW { 858 | field.cursorOffsetW -= iaStringWidth(rightLeft[len(leftLeft):]) 859 | } 860 | field.selectionEndW = -1 861 | field.selectionStartW = -1 862 | } 863 | 864 | // RemovePreviousCharacter removes the character before the cursor. 865 | func (field *InputArea) RemovePreviousCharacter() { 866 | if field.selectionEndW > 0 { 867 | field.RemoveSelection() 868 | return 869 | } else if field.cursorOffsetW == 0 { 870 | return 871 | } 872 | left := iaSubstringBefore(field.text, field.cursorOffsetW) 873 | right := field.text[len(left):] 874 | 875 | // Take everything before the right part minus the last character. 876 | leftRunes := []rune(left) 877 | leftRunes = leftRunes[0 : len(leftRunes)-1] 878 | left = string(leftRunes) 879 | 880 | // Figure out what character was removed to correctly decrease cursorOffset. 881 | removedChar := field.text[len(left) : len(field.text)-len(right)] 882 | field.text = left + right 883 | field.cursorOffsetW -= iaStringWidth(removedChar) 884 | } 885 | 886 | // Clear clears the input area. 887 | func (field *InputArea) Clear() { 888 | field.text = "" 889 | field.cursorOffsetW = 0 890 | field.cursorOffsetX = 0 891 | field.cursorOffsetY = 0 892 | field.selectionEndW = -1 893 | field.selectionStartW = -1 894 | field.viewOffsetY = 0 895 | } 896 | 897 | // SelectAll extends the selection to cover all text in the input area. 898 | func (field *InputArea) SelectAll() { 899 | field.selectionStartW = 0 900 | field.selectionEndW = iaStringWidth(field.text) 901 | field.cursorOffsetW = field.selectionEndW 902 | field.copy("primary", false) 903 | } 904 | 905 | // handleInputChanges calls the text change handler and makes sure 906 | // offsets are valid after a change in the text of the input area. 907 | func (field *InputArea) handleInputChanges(originalText string) { 908 | // Trigger changed events. 909 | if field.text != originalText && field.changed != nil { 910 | field.changed(field.text) 911 | } 912 | 913 | // Make sure cursor offset is valid 914 | if field.cursorOffsetW < 0 { 915 | field.cursorOffsetW = 0 916 | } 917 | textWidth := iaStringWidth(field.text) 918 | if field.cursorOffsetW > textWidth { 919 | field.cursorOffsetW = textWidth 920 | } 921 | if field.selectionEndW > textWidth { 922 | field.selectionEndW = textWidth 923 | } 924 | if field.selectionEndW <= field.selectionStartW { 925 | field.selectionStartW = -1 926 | field.selectionEndW = -1 927 | } 928 | } 929 | 930 | // OnPasteEvent handles a terminal bracketed paste event. 931 | func (field *InputArea) OnPasteEvent(event PasteEvent) bool { 932 | var left, right string 933 | if field.selectionEndW != -1 { 934 | left = iaSubstringBefore(field.text, field.selectionStartW) 935 | rightLeft := iaSubstringBefore(field.text, field.selectionEndW) 936 | right = field.text[len(rightLeft):] 937 | field.cursorOffsetW = field.selectionStartW 938 | } else { 939 | left = iaSubstringBefore(field.text, field.cursorOffsetW) 940 | right = field.text[len(left):] 941 | } 942 | oldText := field.text 943 | field.text = left + event.Text() + right 944 | field.cursorOffsetW += iaStringWidth(event.Text()) 945 | field.handleInputChanges(oldText) 946 | field.selectionEndW = -1 947 | field.selectionStartW = -1 948 | field.snapshot(true) 949 | return true 950 | } 951 | 952 | // Paste reads the clipboard and inserts the content at the cursor position. 953 | func (field *InputArea) Paste() { 954 | text, _ := clipboard.ReadAll("clipboard") 955 | field.OnPasteEvent(customPasteEvent{nil, text}) 956 | } 957 | 958 | // Copy copies the currently selected content onto the clipboard. 959 | func (field *InputArea) Copy() { 960 | field.copy("clipboard", false) 961 | } 962 | 963 | func (field *InputArea) Cut() { 964 | field.copy("clipboard", true) 965 | } 966 | 967 | func (field *InputArea) copy(selection string, cut bool) { 968 | if !field.copySelection && selection == "primary" { 969 | return 970 | } else if field.selectionEndW == -1 { 971 | return 972 | } 973 | left := iaSubstringBefore(field.text, field.selectionStartW) 974 | rightLeft := iaSubstringBefore(field.text, field.selectionEndW) 975 | text := rightLeft[len(left):] 976 | _ = clipboard.WriteAll(text, selection) 977 | if cut { 978 | field.text = left + field.text[len(rightLeft):] 979 | field.cursorOffsetW = field.selectionStartW 980 | field.selectionStartW = -1 981 | field.selectionEndW = -1 982 | } 983 | } 984 | 985 | // OnKeyEvent handles a terminal key press event. 986 | func (field *InputArea) OnKeyEvent(event KeyEvent) bool { 987 | hasMod := func(mod tcell.ModMask) bool { 988 | return event.Modifiers()&mod != 0 989 | } 990 | oldText := field.text 991 | 992 | doSnapshot := false 993 | forceNewSnapshot := false 994 | // Process key event. 995 | switch event.Key() { 996 | case tcell.KeyRune: 997 | field.TypeRune(event.Rune()) 998 | doSnapshot = true 999 | forceNewSnapshot = event.Rune() == ' ' 1000 | case tcell.KeyEnter: 1001 | field.TypeRune('\n') 1002 | doSnapshot = true 1003 | forceNewSnapshot = true 1004 | case tcell.KeyLeft: 1005 | field.MoveCursorLeft(hasMod(tcell.ModCtrl), hasMod(tcell.ModShift)) 1006 | case tcell.KeyRight: 1007 | field.MoveCursorRight(hasMod(tcell.ModCtrl), hasMod(tcell.ModShift)) 1008 | case tcell.KeyUp: 1009 | field.MoveCursorUp(hasMod(tcell.ModShift)) 1010 | case tcell.KeyDown: 1011 | field.MoveCursorDown(hasMod(tcell.ModShift)) 1012 | case tcell.KeyHome: 1013 | field.MoveCursorHome(hasMod(tcell.ModShift)) 1014 | case tcell.KeyEnd: 1015 | field.MoveCursorEnd(hasMod(tcell.ModShift)) 1016 | case tcell.KeyDelete: 1017 | field.RemoveNextCharacter() 1018 | doSnapshot = true 1019 | case tcell.KeyBackspace: 1020 | if Backspace1RemovesWord { 1021 | field.RemovePreviousWord() 1022 | } else { 1023 | field.RemovePreviousCharacter() 1024 | } 1025 | doSnapshot = true 1026 | forceNewSnapshot = true 1027 | case tcell.KeyBackspace2: 1028 | forceNewSnapshot = field.selectionEndW > 0 1029 | if Backspace2RemovesWord { 1030 | field.RemovePreviousWord() 1031 | } else { 1032 | field.RemovePreviousCharacter() 1033 | } 1034 | doSnapshot = true 1035 | case tcell.KeyTab: 1036 | if field.tabComplete != nil { 1037 | field.tabComplete(field.text, field.cursorOffsetW) 1038 | } 1039 | default: 1040 | if field.vimBindings { 1041 | switch event.Key() { 1042 | case tcell.KeyCtrlU: 1043 | field.Clear() 1044 | doSnapshot = true 1045 | forceNewSnapshot = true 1046 | case tcell.KeyCtrlW: 1047 | field.RemovePreviousWord() 1048 | doSnapshot = true 1049 | forceNewSnapshot = true 1050 | default: 1051 | return false 1052 | } 1053 | } else { 1054 | switch event.Key() { 1055 | case tcell.KeyCtrlA: 1056 | field.SelectAll() 1057 | case tcell.KeyCtrlZ: 1058 | field.Undo() 1059 | case tcell.KeyCtrlY: 1060 | field.Redo() 1061 | case tcell.KeyCtrlC: 1062 | field.Copy() 1063 | case tcell.KeyCtrlV: 1064 | field.Paste() 1065 | return true 1066 | case tcell.KeyCtrlX: 1067 | field.Cut() 1068 | default: 1069 | return false 1070 | } 1071 | } 1072 | } 1073 | field.handleInputChanges(oldText) 1074 | if doSnapshot { 1075 | field.snapshot(forceNewSnapshot) 1076 | } 1077 | return true 1078 | } 1079 | 1080 | // Focus marks the input area as focused. 1081 | func (field *InputArea) Focus() { 1082 | field.focused = true 1083 | } 1084 | 1085 | // Blur marks the input area as not focused. 1086 | func (field *InputArea) Blur() { 1087 | field.focused = false 1088 | } 1089 | 1090 | // OnMouseEvent handles a terminal mouse event. 1091 | func (field *InputArea) OnMouseEvent(event MouseEvent) bool { 1092 | switch event.Buttons() { 1093 | case tcell.Button1: 1094 | cursorX, cursorY := event.Position() 1095 | cursorY += field.viewOffsetY 1096 | now := millis() 1097 | sameCell := field.lastClickX == cursorX && field.lastClickY == cursorY 1098 | if !event.HasMotion() { 1099 | withinTimeout := now < field.lastClick+field.doubleClickTimeout 1100 | if field.clickStreak > 0 && sameCell && withinTimeout { 1101 | field.clickStreak++ 1102 | } else { 1103 | field.clickStreak = 1 1104 | } 1105 | if field.clickStreak <= 1 { 1106 | field.SetCursorPos(cursorX, cursorY) 1107 | } else { 1108 | field.startSelectionStreak(cursorX, cursorY) 1109 | } 1110 | field.lastClick = now 1111 | field.lastClickX = cursorX 1112 | field.lastClickY = cursorY 1113 | } else { 1114 | if sameCell { 1115 | return false 1116 | } 1117 | field.ExtendSelection(cursorX, cursorY) 1118 | } 1119 | case tcell.WheelDown: 1120 | field.viewOffsetY += 3 1121 | field.cursorOffsetY += 3 1122 | field.recalculateCursorOffset() 1123 | case tcell.WheelUp: 1124 | field.viewOffsetY -= 3 1125 | field.cursorOffsetY -= 3 1126 | field.recalculateCursorOffset() 1127 | default: 1128 | return false 1129 | } 1130 | return true 1131 | } 1132 | -------------------------------------------------------------------------------- /inputfield.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | // Based on https://github.com/rivo/tview/blob/master/inputfield.go 9 | 10 | package mauview 11 | 12 | import ( 13 | "regexp" 14 | "strings" 15 | "unicode/utf8" 16 | 17 | "github.com/mattn/go-runewidth" 18 | 19 | "github.com/gdamore/tcell/v2" 20 | ) 21 | 22 | // InputField is a single-line user-editable text field. 23 | // 24 | // Use SetMaskCharacter() to hide input from onlookers (e.g. for password 25 | // input). 26 | type InputField struct { 27 | // Cursor position 28 | cursorOffset int 29 | // Number of characters (from left) to offset rendering. 30 | viewOffset int 31 | 32 | // The text that was entered. 33 | text string 34 | 35 | // The text to be displayed in the input area when it is empty. 36 | placeholder string 37 | 38 | // The background color of the input area. 39 | fieldBackgroundColor tcell.Color 40 | // The text color of the input area. 41 | fieldTextColor tcell.Color 42 | // The text color of the placeholder. 43 | placeholderTextColor tcell.Color 44 | 45 | // A character to mask entered text (useful for password fields). A value of 0 46 | // disables masking. 47 | maskCharacter rune 48 | 49 | // Whether or not to enable vim-style keybindings. 50 | vimBindings bool 51 | 52 | // Whether or not the input field is focused. 53 | focused bool 54 | 55 | // An optional function which is called when the input has changed. 56 | changed func(text string) 57 | 58 | // An optional function which is called when the user presses tab. 59 | tabComplete func(text string, pos int) 60 | } 61 | 62 | // NewInputField returns a new input field. 63 | func NewInputField() *InputField { 64 | return &InputField{ 65 | fieldBackgroundColor: Styles.ContrastBackgroundColor, 66 | fieldTextColor: Styles.PrimaryTextColor, 67 | placeholderTextColor: Styles.ContrastSecondaryTextColor, 68 | } 69 | } 70 | 71 | // SetText sets the current text of the input field. 72 | func (field *InputField) SetText(text string) *InputField { 73 | field.text = text 74 | if field.changed != nil { 75 | field.changed(text) 76 | } 77 | return field 78 | } 79 | 80 | // SetTextAndMoveCursor sets the current text of the input field and moves the cursor with the width difference. 81 | func (field *InputField) SetTextAndMoveCursor(text string) *InputField { 82 | oldWidth := runewidth.StringWidth(field.text) 83 | field.text = text 84 | newWidth := runewidth.StringWidth(field.text) 85 | if oldWidth != newWidth { 86 | field.cursorOffset += newWidth - oldWidth 87 | } 88 | if field.changed != nil { 89 | field.changed(field.text) 90 | } 91 | return field 92 | } 93 | 94 | // GetText returns the current text of the input field. 95 | func (field *InputField) GetText() string { 96 | return field.text 97 | } 98 | 99 | // SetPlaceholder sets the text to be displayed when the input text is empty. 100 | func (field *InputField) SetPlaceholder(text string) *InputField { 101 | field.placeholder = text 102 | return field 103 | } 104 | 105 | // SetBackgroundColor sets the background color of the input area. 106 | func (field *InputField) SetBackgroundColor(color tcell.Color) *InputField { 107 | field.fieldBackgroundColor = color 108 | return field 109 | } 110 | 111 | // SetTextColor sets the text color of the input area. 112 | func (field *InputField) SetTextColor(color tcell.Color) *InputField { 113 | field.fieldTextColor = color 114 | return field 115 | } 116 | 117 | // SetPlaceholderTextColor sets the text color of placeholder text. 118 | func (field *InputField) SetPlaceholderTextColor(color tcell.Color) *InputField { 119 | field.placeholderTextColor = color 120 | return field 121 | } 122 | 123 | // SetMaskCharacter sets a character that masks user input on a screen. A value 124 | // of 0 disables masking. 125 | func (field *InputField) SetMaskCharacter(mask rune) *InputField { 126 | field.maskCharacter = mask 127 | return field 128 | } 129 | 130 | // SetChangedFunc sets a handler which is called whenever the text of the input 131 | // field has changed. It receives the current text (after the change). 132 | func (field *InputField) SetChangedFunc(handler func(text string)) *InputField { 133 | field.changed = handler 134 | return field 135 | } 136 | 137 | func (field *InputField) SetTabCompleteFunc(handler func(text string, cursorOffset int)) *InputField { 138 | field.tabComplete = handler 139 | return field 140 | } 141 | 142 | // prepareText prepares the text to be displayed and recalculates the view and cursor offsets. 143 | func (field *InputField) prepareText(screen Screen) (text string, placeholder bool) { 144 | width, _ := screen.Size() 145 | text = field.text 146 | if len(text) == 0 && len(field.placeholder) > 0 { 147 | text = field.placeholder 148 | placeholder = true 149 | } 150 | 151 | if !placeholder && field.maskCharacter > 0 { 152 | text = strings.Repeat(string(field.maskCharacter), utf8.RuneCountInString(text)) 153 | } 154 | textWidth := runewidth.StringWidth(text) 155 | if field.cursorOffset >= textWidth { 156 | width-- 157 | } 158 | 159 | if field.cursorOffset < field.viewOffset { 160 | field.viewOffset = field.cursorOffset 161 | } else if field.cursorOffset > field.viewOffset+width { 162 | field.viewOffset = field.cursorOffset - width 163 | } else if textWidth-field.viewOffset < width { 164 | field.viewOffset = textWidth - width 165 | } 166 | 167 | if field.viewOffset < 0 { 168 | field.viewOffset = 0 169 | } 170 | 171 | return 172 | } 173 | 174 | // drawText draws the text and the cursor. 175 | func (field *InputField) drawText(screen Screen, text string, placeholder bool) { 176 | width, _ := screen.Size() 177 | runes := []rune(text) 178 | x := 0 179 | style := tcell.StyleDefault.Foreground(field.fieldTextColor).Background(field.fieldBackgroundColor) 180 | if placeholder { 181 | style = style.Foreground(field.placeholderTextColor) 182 | } 183 | for pos := field.viewOffset; pos <= width+field.viewOffset && pos < len(runes); pos++ { 184 | ch := runes[pos] 185 | w := runewidth.RuneWidth(ch) 186 | for w > 0 { 187 | screen.SetContent(x, 0, ch, nil, style) 188 | x++ 189 | w-- 190 | } 191 | } 192 | for ; x <= width; x++ { 193 | screen.SetContent(x, 0, ' ', nil, style) 194 | } 195 | } 196 | 197 | // Draw draws this primitive onto the screen. 198 | func (field *InputField) Draw(screen Screen) { 199 | width, height := screen.Size() 200 | if height < 1 || width < 1 { 201 | return 202 | } 203 | 204 | text, placeholder := field.prepareText(screen) 205 | field.drawText(screen, text, placeholder) 206 | if field.focused { 207 | field.setCursor(screen) 208 | } 209 | } 210 | 211 | func (field *InputField) GetCursorOffset() int { 212 | return field.cursorOffset 213 | } 214 | 215 | func (field *InputField) SetCursorOffset(offset int) *InputField { 216 | if offset < 0 { 217 | offset = 0 218 | } else { 219 | width := runewidth.StringWidth(field.text) 220 | if offset >= width { 221 | offset = width 222 | } 223 | } 224 | field.cursorOffset = offset 225 | return field 226 | } 227 | 228 | // setCursor sets the cursor position. 229 | func (field *InputField) setCursor(screen Screen) { 230 | width, _ := screen.Size() 231 | x := field.cursorOffset - field.viewOffset 232 | if x >= width { 233 | x = width - 1 234 | } else if x < 0 { 235 | x = 0 236 | } 237 | screen.ShowCursor(x, 0) 238 | } 239 | 240 | var ( 241 | lastWord = regexp.MustCompile(`\S+\s*$`) 242 | firstWord = regexp.MustCompile(`^\s*\S+`) 243 | ) 244 | 245 | func SubstringBefore(s string, w int) string { 246 | return runewidth.Truncate(s, w, "") 247 | } 248 | 249 | func (field *InputField) TypeRune(ch rune) { 250 | leftPart := SubstringBefore(field.text, field.cursorOffset) 251 | field.text = leftPart + string(ch) + field.text[len(leftPart):] 252 | field.cursorOffset += runewidth.RuneWidth(ch) 253 | } 254 | 255 | func (field *InputField) MoveCursorLeft(moveWord bool) { 256 | before := SubstringBefore(field.text, field.cursorOffset) 257 | if moveWord { 258 | found := lastWord.FindString(before) 259 | field.cursorOffset -= runewidth.StringWidth(found) 260 | } else if len(before) > 0 { 261 | beforeRunes := []rune(before) 262 | char := beforeRunes[len(beforeRunes)-1] 263 | field.cursorOffset -= runewidth.RuneWidth(char) 264 | } 265 | } 266 | 267 | func (field *InputField) MoveCursorRight(moveWord bool) { 268 | before := SubstringBefore(field.text, field.cursorOffset) 269 | after := field.text[len(before):] 270 | if moveWord { 271 | found := firstWord.FindString(after) 272 | field.cursorOffset += runewidth.StringWidth(found) 273 | } else if len(after) > 0 { 274 | char := []rune(after)[0] 275 | field.cursorOffset += runewidth.RuneWidth(char) 276 | } 277 | } 278 | 279 | func (field *InputField) RemoveNextCharacter() { 280 | if field.cursorOffset >= runewidth.StringWidth(field.text) { 281 | return 282 | } 283 | leftPart := SubstringBefore(field.text, field.cursorOffset) 284 | // Take everything after the left part minus the first character. 285 | rightPart := string([]rune(field.text[len(leftPart):])[1:]) 286 | 287 | field.text = leftPart + rightPart 288 | } 289 | 290 | func (field *InputField) Clear() { 291 | field.text = "" 292 | field.cursorOffset = 0 293 | field.viewOffset = 0 294 | } 295 | 296 | func (field *InputField) RemovePreviousWord() { 297 | leftPart := SubstringBefore(field.text, field.cursorOffset) 298 | rightPart := field.text[len(leftPart):] 299 | replacement := lastWord.ReplaceAllString(leftPart, "") 300 | field.text = replacement + rightPart 301 | 302 | field.cursorOffset -= runewidth.StringWidth(leftPart) - runewidth.StringWidth(replacement) 303 | } 304 | 305 | func (field *InputField) RemovePreviousCharacter() { 306 | if field.cursorOffset == 0 { 307 | return 308 | } 309 | leftPart := SubstringBefore(field.text, field.cursorOffset) 310 | rightPart := field.text[len(leftPart):] 311 | 312 | // Take everything before the right part minus the last character. 313 | leftPartRunes := []rune(leftPart) 314 | leftPartRunes = leftPartRunes[0 : len(leftPartRunes)-1] 315 | leftPart = string(leftPartRunes) 316 | 317 | // Figure out what character was removed to correctly decrease cursorOffset. 318 | removedChar := field.text[len(leftPart) : len(field.text)-len(rightPart)] 319 | 320 | field.text = leftPart + rightPart 321 | 322 | field.cursorOffset -= runewidth.StringWidth(removedChar) 323 | } 324 | 325 | func (field *InputField) handleInputChanges(originalText string) { 326 | // Trigger changed events. 327 | if field.text != originalText && field.changed != nil { 328 | field.changed(field.text) 329 | } 330 | 331 | // Make sure cursor offset is valid 332 | if field.cursorOffset < 0 { 333 | field.cursorOffset = 0 334 | } 335 | width := runewidth.StringWidth(field.text) 336 | if field.cursorOffset > width { 337 | field.cursorOffset = width 338 | } 339 | } 340 | 341 | func (field *InputField) OnPasteEvent(event PasteEvent) bool { 342 | defer field.handleInputChanges(field.text) 343 | leftPart := SubstringBefore(field.text, field.cursorOffset) 344 | field.text = leftPart + event.Text() + field.text[len(leftPart):] 345 | field.cursorOffset += runewidth.StringWidth(event.Text()) 346 | return true 347 | } 348 | 349 | func (field *InputField) Submit(event KeyEvent) bool { 350 | return true 351 | } 352 | 353 | // Global options to specify which of the two backspace key codes should remove the whole previous word. 354 | // If false, only the previous character will be removed with that key code. 355 | var ( 356 | Backspace1RemovesWord = true 357 | Backspace2RemovesWord = false 358 | ) 359 | 360 | func (field *InputField) OnKeyEvent(event KeyEvent) bool { 361 | defer field.handleInputChanges(field.text) 362 | 363 | // Process key event. 364 | switch key := event.Key(); key { 365 | case tcell.KeyRune: 366 | field.TypeRune(event.Rune()) 367 | case tcell.KeyLeft: 368 | field.MoveCursorLeft(event.Modifiers() == tcell.ModCtrl) 369 | case tcell.KeyRight: 370 | field.MoveCursorRight(event.Modifiers() == tcell.ModCtrl) 371 | case tcell.KeyDelete: 372 | field.RemoveNextCharacter() 373 | case tcell.KeyCtrlU: 374 | if field.vimBindings { 375 | field.Clear() 376 | } 377 | case tcell.KeyCtrlW: 378 | if field.vimBindings { 379 | field.RemovePreviousWord() 380 | } 381 | case tcell.KeyBackspace: 382 | if Backspace1RemovesWord { 383 | field.RemovePreviousWord() 384 | } else { 385 | field.RemovePreviousCharacter() 386 | } 387 | case tcell.KeyBackspace2: 388 | if Backspace2RemovesWord { 389 | field.RemovePreviousWord() 390 | } else { 391 | field.RemovePreviousCharacter() 392 | } 393 | case tcell.KeyTab: 394 | if field.tabComplete != nil { 395 | field.tabComplete(field.text, field.cursorOffset) 396 | return true 397 | } 398 | return false 399 | default: 400 | return false 401 | } 402 | return true 403 | } 404 | 405 | func (field *InputField) Focus() { 406 | field.focused = true 407 | } 408 | 409 | func (field *InputField) Blur() { 410 | field.focused = false 411 | } 412 | 413 | func (field *InputField) OnMouseEvent(event MouseEvent) bool { 414 | if event.Buttons() == tcell.Button1 { 415 | x, _ := event.Position() 416 | field.SetCursorOffset(field.viewOffset + x) 417 | return true 418 | } 419 | return false 420 | } 421 | -------------------------------------------------------------------------------- /main: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tulir/mauview/292e0a6914c5e5a425cbd798644c31f440a1da8d/main -------------------------------------------------------------------------------- /mauview-test/debug/debug.go: -------------------------------------------------------------------------------- 1 | // gomuks - A terminal Matrix client written in Go. 2 | // Copyright (C) 2018 Tulir Asokan 3 | // 4 | // This program is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU General Public License as published by 6 | // the Free Software Foundation, either version 3 of the License, or 7 | // (at your option) any later version. 8 | // 9 | // This program is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU General Public License 15 | // along with this program. If not, see . 16 | 17 | package debug 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "os" 23 | "runtime/debug" 24 | "time" 25 | ) 26 | 27 | var writer io.Writer 28 | 29 | func init() { 30 | var err error 31 | writer, err = os.OpenFile("/tmp/mauview-debug.log", os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) 32 | if err != nil { 33 | panic(err) 34 | } 35 | } 36 | 37 | func Printf(text string, args ...interface{}) { 38 | if writer != nil { 39 | fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] ")) 40 | fmt.Fprintf(writer, text+"\n", args...) 41 | } 42 | } 43 | 44 | func Print(text ...interface{}) { 45 | if writer != nil { 46 | fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] ")) 47 | fmt.Fprintln(writer, text...) 48 | } 49 | } 50 | 51 | func PrintStack() { 52 | if writer != nil { 53 | data := debug.Stack() 54 | writer.Write(data) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /mauview-test/main.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package main 9 | 10 | import ( 11 | "github.com/gdamore/tcell/v2" 12 | 13 | "go.mau.fi/mauview" 14 | ) 15 | 16 | type Text struct { 17 | mauview.SimpleEventHandler 18 | Text string 19 | } 20 | 21 | func (text *Text) Draw(screen mauview.Screen) { 22 | for i, char := range text.Text { 23 | screen.SetCell(i, 0, tcell.StyleDefault, char) 24 | } 25 | } 26 | 27 | func main() { 28 | app := mauview.NewApplication() 29 | grid := mauview.NewGrid() 30 | textComp := &Text{mauview.SimpleEventHandler{}, "Hello, World!"} 31 | textComp.OnKey = func(event mauview.KeyEvent) bool { 32 | if event.Key() == tcell.KeyCtrlC { 33 | app.Stop() 34 | } 35 | return false 36 | } 37 | grid.SetColumn(0, 25) 38 | grid.SetRow(1, 15) 39 | grid.SetRow(3, 5) 40 | grid.SetRow(4, 3) 41 | grid.AddComponent(mauview.NewBox(textComp), 1, 0, 2, 2) 42 | grid.AddComponent(mauview.NewBox(mauview.NewFlex().SetDirection(mauview.FlexRow). 43 | AddFixedComponent(mauview.NewBox(nil), 10). 44 | AddProportionalComponent(mauview.NewBox(nil), 3). 45 | AddProportionalComponent(mauview.NewBox(nil), 1). 46 | AddFixedComponent(mauview.NewBox(nil), 10)), 0, 0, 1, 3) 47 | grid.AddComponent(mauview.NewBox( 48 | mauview.NewGrid(). 49 | AddComponent(&Text{mauview.SimpleEventHandler{}, "Hello, World! (again)"}, 0, 1, 1, 1). 50 | AddComponent(mauview.NewBox(mauview.NewInputArea().SetPlaceholder("I'm holding a place!")), 0, 0, 2, 1). 51 | AddComponent(mauview.NewBox(nil), 1, 1, 1, 1)), 52 | 1, 2, 1, 1) 53 | grid.AddComponent(mauview.NewBox(mauview.Center(mauview.NewBox(nil), 10, 5).SetAlwaysFocusChild(true)), 2, 2, 1, 1) 54 | grid.AddComponent(mauview.NewBox(nil), 0, 4, 2, 1) 55 | grid.AddComponent(mauview.NewBox(mauview.NewInputField()), 0, 3, 3, 1) 56 | app.SetRoot(mauview.NewBox(grid)) 57 | err := app.Start() 58 | if err != nil { 59 | panic(err) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /progress.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2020 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "math" 12 | "sync/atomic" 13 | "time" 14 | 15 | "github.com/gdamore/tcell/v2" 16 | ) 17 | 18 | type ProgressBar struct { 19 | *SimpleEventHandler 20 | style tcell.Style 21 | progress int32 22 | max int 23 | 24 | indeterminate bool 25 | indeterminateStart time.Time 26 | } 27 | 28 | var _ Component = &ProgressBar{} 29 | 30 | func NewProgressBar() *ProgressBar { 31 | return &ProgressBar{ 32 | SimpleEventHandler: &SimpleEventHandler{}, 33 | 34 | style: tcell.StyleDefault, 35 | progress: 0, 36 | max: 100, 37 | indeterminate: true, 38 | } 39 | } 40 | 41 | var Blocks = [9]rune{' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'} 42 | 43 | func min(a, b int) int { 44 | if a < b { 45 | return a 46 | } 47 | return b 48 | } 49 | 50 | func (pb *ProgressBar) SetProgress(progress int) *ProgressBar { 51 | pb.progress = int32(min(progress, pb.max)) 52 | return pb 53 | } 54 | 55 | func (pb *ProgressBar) Increment(increment int) *ProgressBar { 56 | atomic.AddInt32(&pb.progress, int32(increment)) 57 | return pb 58 | } 59 | 60 | func (pb *ProgressBar) SetIndeterminate(indeterminate bool) *ProgressBar { 61 | pb.indeterminate = indeterminate 62 | pb.indeterminateStart = time.Now() 63 | return pb 64 | } 65 | 66 | func (pb *ProgressBar) SetMax(max int) *ProgressBar { 67 | pb.max = max 68 | pb.progress = int32(min(pb.max, int(pb.progress))) 69 | return pb 70 | } 71 | 72 | // Draw draws this primitive onto the screen. 73 | func (pb *ProgressBar) Draw(screen Screen) { 74 | width, _ := screen.Size() 75 | if pb.indeterminate { 76 | barWidth := width / 6 77 | pos := int(time.Now().Sub(pb.indeterminateStart).Milliseconds()/200) % (width + barWidth) 78 | for x := pos - barWidth; x < pos; x++ { 79 | screen.SetCell(x, 0, pb.style, Blocks[8]) 80 | } 81 | } else { 82 | progress := math.Min(float64(pb.progress), float64(pb.max)) 83 | floatingBlocks := progress * (float64(width) / float64(pb.max)) 84 | parts := int(math.Floor(math.Mod(floatingBlocks, 1) * 8)) 85 | blocks := int(math.Floor(floatingBlocks)) 86 | for x := 0; x < blocks; x++ { 87 | screen.SetCell(x, 0, pb.style, Blocks[8]) 88 | } 89 | screen.SetCell(blocks, 0, pb.style, Blocks[parts]) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /screen.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "github.com/gdamore/tcell/v2" 12 | ) 13 | 14 | // Screen is a subset of the tcell Screen. 15 | // See https://godoc.org/maunium.net/go/tcell#Screen for documentation. 16 | type Screen interface { 17 | Clear() 18 | Fill(rune, tcell.Style) 19 | SetStyle(style tcell.Style) 20 | SetCell(x, y int, style tcell.Style, ch ...rune) 21 | GetContent(x, y int) (mainc rune, combc []rune, style tcell.Style, width int) 22 | SetContent(x int, y int, mainc rune, combc []rune, style tcell.Style) 23 | ShowCursor(x int, y int) 24 | HideCursor() 25 | Size() (int, int) 26 | Colors() int 27 | CharacterSet() string 28 | CanDisplay(r rune, checkFallbacks bool) bool 29 | HasKey(tcell.Key) bool 30 | } 31 | 32 | // ProxyScreen is a proxy to a tcell Screen with a specific allowed drawing area. 33 | type ProxyScreen struct { 34 | Parent Screen 35 | OffsetX, OffsetY int 36 | Width, Height int 37 | Style tcell.Style 38 | } 39 | 40 | func NewProxyScreen(parent Screen, offsetX, offsetY, width, height int) Screen { 41 | return &ProxyScreen{ 42 | Parent: parent, 43 | OffsetX: offsetX, 44 | OffsetY: offsetY, 45 | Width: width, 46 | Height: height, 47 | Style: tcell.StyleDefault, 48 | } 49 | } 50 | 51 | func (ss *ProxyScreen) IsInArea(x, y int) bool { 52 | return x >= ss.OffsetX && x <= ss.OffsetX+ss.Width && 53 | y >= ss.OffsetY && y <= ss.OffsetY+ss.Height 54 | } 55 | 56 | func (ss *ProxyScreen) YEnd() int { 57 | return ss.OffsetY + ss.Height 58 | } 59 | 60 | func (ss *ProxyScreen) XEnd() int { 61 | return ss.OffsetX + ss.Width 62 | } 63 | 64 | func (ss *ProxyScreen) OffsetMouseEvent(event MouseEvent) MouseEvent { 65 | return OffsetMouseEvent(event, -ss.OffsetX, -ss.OffsetY) 66 | } 67 | 68 | func (ss *ProxyScreen) Clear() { 69 | ss.Fill(' ', ss.Style) 70 | } 71 | 72 | func (ss *ProxyScreen) Fill(r rune, style tcell.Style) { 73 | for x := ss.OffsetX; x < ss.XEnd(); x++ { 74 | for y := ss.OffsetY; y < ss.YEnd(); y++ { 75 | ss.Parent.SetCell(x, y, style, r) 76 | } 77 | } 78 | } 79 | 80 | func (ss *ProxyScreen) SetStyle(style tcell.Style) { 81 | ss.Style = style 82 | } 83 | 84 | func (ss *ProxyScreen) adjustCoordinates(x, y int) (int, int, bool) { 85 | if x < 0 || y < 0 || (ss.Width >= 0 && x >= ss.Width) || (ss.Height >= 0 && y >= ss.Height) { 86 | return -1, -1, false 87 | } 88 | 89 | x += ss.OffsetX 90 | y += ss.OffsetY 91 | return x, y, true 92 | } 93 | 94 | func (ss *ProxyScreen) SetCell(x, y int, style tcell.Style, ch ...rune) { 95 | x, y, ok := ss.adjustCoordinates(x, y) 96 | if ok { 97 | ss.Parent.SetCell(x, y, style, ch...) 98 | } 99 | } 100 | 101 | func (ss *ProxyScreen) GetContent(x, y int) (mainc rune, combc []rune, style tcell.Style, width int) { 102 | x, y, ok := ss.adjustCoordinates(x, y) 103 | if ok { 104 | return ss.Parent.GetContent(x, y) 105 | } 106 | return 0, nil, tcell.StyleDefault, 0 107 | } 108 | 109 | func (ss *ProxyScreen) SetContent(x int, y int, mainc rune, combc []rune, style tcell.Style) { 110 | x, y, ok := ss.adjustCoordinates(x, y) 111 | if ok { 112 | ss.Parent.SetContent(x, y, mainc, combc, style) 113 | } 114 | } 115 | 116 | func (ss *ProxyScreen) ShowCursor(x, y int) { 117 | x, y, ok := ss.adjustCoordinates(x, y) 118 | if ok { 119 | ss.Parent.ShowCursor(x, y) 120 | } 121 | } 122 | 123 | func (ss *ProxyScreen) HideCursor() { 124 | ss.Parent.HideCursor() 125 | } 126 | 127 | // Size returns the size of this subscreen. 128 | // 129 | // If the subscreen doesn't fit in the parent with the set offset and size, 130 | // the returned size is whatever can actually be rendered. 131 | func (ss *ProxyScreen) Size() (width int, height int) { 132 | width, height = ss.Parent.Size() 133 | width -= ss.OffsetX 134 | height -= ss.OffsetY 135 | if width > ss.Width { 136 | width = ss.Width 137 | } 138 | if height > ss.Height { 139 | height = ss.Height 140 | } 141 | return 142 | } 143 | 144 | func (ss *ProxyScreen) Colors() int { 145 | return ss.Parent.Colors() 146 | } 147 | 148 | func (ss *ProxyScreen) CharacterSet() string { 149 | return ss.Parent.CharacterSet() 150 | } 151 | 152 | func (ss *ProxyScreen) CanDisplay(r rune, checkFallbacks bool) bool { 153 | return ss.Parent.CanDisplay(r, checkFallbacks) 154 | } 155 | 156 | func (ss *ProxyScreen) HasKey(key tcell.Key) bool { 157 | return ss.Parent.HasKey(key) 158 | } 159 | -------------------------------------------------------------------------------- /semigraphics.go: -------------------------------------------------------------------------------- 1 | // From https://github.com/rivo/tview/blob/master/semigraphics.go 2 | // Copyright (c) 2018 Oliver Kuederle 3 | // MIT license 4 | 5 | package mauview 6 | 7 | import "github.com/gdamore/tcell/v2" 8 | 9 | // Semigraphics provides an easy way to access unicode characters for drawing. 10 | // 11 | // Named like the unicode characters, 'Semigraphics'-prefix used if unicode block 12 | // isn't prefixed itself. 13 | const ( 14 | // Block: General Punctation U+2000-U+206F (http://unicode.org/charts/PDF/U2000.pdf) 15 | SemigraphicsHorizontalEllipsis rune = '\u2026' // … 16 | 17 | // Block: Box Drawing U+2500-U+257F (http://unicode.org/charts/PDF/U2500.pdf) 18 | BoxDrawingsLightHorizontal rune = '\u2500' // ─ 19 | BoxDrawingsHeavyHorizontal rune = '\u2501' // ━ 20 | BoxDrawingsLightVertical rune = '\u2502' // │ 21 | BoxDrawingsHeavyVertical rune = '\u2503' // ┃ 22 | BoxDrawingsLightTripleDashHorizontal rune = '\u2504' // ┄ 23 | BoxDrawingsHeavyTripleDashHorizontal rune = '\u2505' // ┅ 24 | BoxDrawingsLightTripleDashVertical rune = '\u2506' // ┆ 25 | BoxDrawingsHeavyTripleDashVertical rune = '\u2507' // ┇ 26 | BoxDrawingsLightQuadrupleDashHorizontal rune = '\u2508' // ┈ 27 | BoxDrawingsHeavyQuadrupleDashHorizontal rune = '\u2509' // ┉ 28 | BoxDrawingsLightQuadrupleDashVertical rune = '\u250a' // ┊ 29 | BoxDrawingsHeavyQuadrupleDashVertical rune = '\u250b' // ┋ 30 | BoxDrawingsLightDownAndRight rune = '\u250c' // ┌ 31 | BoxDrawingsDownLighAndRightHeavy rune = '\u250d' // ┍ 32 | BoxDrawingsDownHeavyAndRightLight rune = '\u250e' // ┎ 33 | BoxDrawingsHeavyDownAndRight rune = '\u250f' // ┏ 34 | BoxDrawingsLightDownAndLeft rune = '\u2510' // ┐ 35 | BoxDrawingsDownLighAndLeftHeavy rune = '\u2511' // ┑ 36 | BoxDrawingsDownHeavyAndLeftLight rune = '\u2512' // ┒ 37 | BoxDrawingsHeavyDownAndLeft rune = '\u2513' // ┓ 38 | BoxDrawingsLightUpAndRight rune = '\u2514' // └ 39 | BoxDrawingsUpLightAndRightHeavy rune = '\u2515' // ┕ 40 | BoxDrawingsUpHeavyAndRightLight rune = '\u2516' // ┖ 41 | BoxDrawingsHeavyUpAndRight rune = '\u2517' // ┗ 42 | BoxDrawingsLightUpAndLeft rune = '\u2518' // ┘ 43 | BoxDrawingsUpLightAndLeftHeavy rune = '\u2519' // ┙ 44 | BoxDrawingsUpHeavyAndLeftLight rune = '\u251a' // ┚ 45 | BoxDrawingsHeavyUpAndLeft rune = '\u251b' // ┛ 46 | BoxDrawingsLightVerticalAndRight rune = '\u251c' // ├ 47 | BoxDrawingsVerticalLightAndRightHeavy rune = '\u251d' // ┝ 48 | BoxDrawingsUpHeavyAndRightDownLight rune = '\u251e' // ┞ 49 | BoxDrawingsDownHeacyAndRightUpLight rune = '\u251f' // ┟ 50 | BoxDrawingsVerticalHeavyAndRightLight rune = '\u2520' // ┠ 51 | BoxDrawingsDownLightAnbdRightUpHeavy rune = '\u2521' // ┡ 52 | BoxDrawingsUpLightAndRightDownHeavy rune = '\u2522' // ┢ 53 | BoxDrawingsHeavyVerticalAndRight rune = '\u2523' // ┣ 54 | BoxDrawingsLightVerticalAndLeft rune = '\u2524' // ┤ 55 | BoxDrawingsVerticalLightAndLeftHeavy rune = '\u2525' // ┥ 56 | BoxDrawingsUpHeavyAndLeftDownLight rune = '\u2526' // ┦ 57 | BoxDrawingsDownHeavyAndLeftUpLight rune = '\u2527' // ┧ 58 | BoxDrawingsVerticalheavyAndLeftLight rune = '\u2528' // ┨ 59 | BoxDrawingsDownLightAndLeftUpHeavy rune = '\u2529' // ┨ 60 | BoxDrawingsUpLightAndLeftDownHeavy rune = '\u252a' // ┪ 61 | BoxDrawingsHeavyVerticalAndLeft rune = '\u252b' // ┫ 62 | BoxDrawingsLightDownAndHorizontal rune = '\u252c' // ┬ 63 | BoxDrawingsLeftHeavyAndRightDownLight rune = '\u252d' // ┭ 64 | BoxDrawingsRightHeavyAndLeftDownLight rune = '\u252e' // ┮ 65 | BoxDrawingsDownLightAndHorizontalHeavy rune = '\u252f' // ┯ 66 | BoxDrawingsDownHeavyAndHorizontalLight rune = '\u2530' // ┰ 67 | BoxDrawingsRightLightAndLeftDownHeavy rune = '\u2531' // ┱ 68 | BoxDrawingsLeftLightAndRightDownHeavy rune = '\u2532' // ┲ 69 | BoxDrawingsHeavyDownAndHorizontal rune = '\u2533' // ┳ 70 | BoxDrawingsLightUpAndHorizontal rune = '\u2534' // ┴ 71 | BoxDrawingsLeftHeavyAndRightUpLight rune = '\u2535' // ┵ 72 | BoxDrawingsRightHeavyAndLeftUpLight rune = '\u2536' // ┶ 73 | BoxDrawingsUpLightAndHorizontalHeavy rune = '\u2537' // ┷ 74 | BoxDrawingsUpHeavyAndHorizontalLight rune = '\u2538' // ┸ 75 | BoxDrawingsRightLightAndLeftUpHeavy rune = '\u2539' // ┹ 76 | BoxDrawingsLeftLightAndRightUpHeavy rune = '\u253a' // ┺ 77 | BoxDrawingsHeavyUpAndHorizontal rune = '\u253b' // ┻ 78 | BoxDrawingsLightVerticalAndHorizontal rune = '\u253c' // ┼ 79 | BoxDrawingsLeftHeavyAndRightVerticalLight rune = '\u253d' // ┽ 80 | BoxDrawingsRightHeavyAndLeftVerticalLight rune = '\u253e' // ┾ 81 | BoxDrawingsVerticalLightAndHorizontalHeavy rune = '\u253f' // ┿ 82 | BoxDrawingsUpHeavyAndDownHorizontalLight rune = '\u2540' // ╀ 83 | BoxDrawingsDownHeavyAndUpHorizontalLight rune = '\u2541' // ╁ 84 | BoxDrawingsVerticalHeavyAndHorizontalLight rune = '\u2542' // ╂ 85 | BoxDrawingsLeftUpHeavyAndRightDownLight rune = '\u2543' // ╃ 86 | BoxDrawingsRightUpHeavyAndLeftDownLight rune = '\u2544' // ╄ 87 | BoxDrawingsLeftDownHeavyAndRightUpLight rune = '\u2545' // ╅ 88 | BoxDrawingsRightDownHeavyAndLeftUpLight rune = '\u2546' // ╆ 89 | BoxDrawingsDownLightAndUpHorizontalHeavy rune = '\u2547' // ╇ 90 | BoxDrawingsUpLightAndDownHorizontalHeavy rune = '\u2548' // ╈ 91 | BoxDrawingsRightLightAndLeftVerticalHeavy rune = '\u2549' // ╉ 92 | BoxDrawingsLeftLightAndRightVerticalHeavy rune = '\u254a' // ╊ 93 | BoxDrawingsHeavyVerticalAndHorizontal rune = '\u254b' // ╋ 94 | BoxDrawingsLightDoubleDashHorizontal rune = '\u254c' // ╌ 95 | BoxDrawingsHeavyDoubleDashHorizontal rune = '\u254d' // ╍ 96 | BoxDrawingsLightDoubleDashVertical rune = '\u254e' // ╎ 97 | BoxDrawingsHeavyDoubleDashVertical rune = '\u254f' // ╏ 98 | BoxDrawingsDoubleHorizontal rune = '\u2550' // ═ 99 | BoxDrawingsDoubleVertical rune = '\u2551' // ║ 100 | BoxDrawingsDownSingleAndRightDouble rune = '\u2552' // ╒ 101 | BoxDrawingsDownDoubleAndRightSingle rune = '\u2553' // ╓ 102 | BoxDrawingsDoubleDownAndRight rune = '\u2554' // ╔ 103 | BoxDrawingsDownSingleAndLeftDouble rune = '\u2555' // ╕ 104 | BoxDrawingsDownDoubleAndLeftSingle rune = '\u2556' // ╖ 105 | BoxDrawingsDoubleDownAndLeft rune = '\u2557' // ╗ 106 | BoxDrawingsUpSingleAndRightDouble rune = '\u2558' // ╘ 107 | BoxDrawingsUpDoubleAndRightSingle rune = '\u2559' // ╙ 108 | BoxDrawingsDoubleUpAndRight rune = '\u255a' // ╚ 109 | BoxDrawingsUpSingleAndLeftDouble rune = '\u255b' // ╛ 110 | BoxDrawingsUpDobuleAndLeftSingle rune = '\u255c' // ╜ 111 | BoxDrawingsDoubleUpAndLeft rune = '\u255d' // ╝ 112 | BoxDrawingsVerticalSingleAndRightDouble rune = '\u255e' // ╞ 113 | BoxDrawingsVerticalDoubleAndRightSingle rune = '\u255f' // ╟ 114 | BoxDrawingsDoubleVerticalAndRight rune = '\u2560' // ╠ 115 | BoxDrawingsVerticalSingleAndLeftDouble rune = '\u2561' // ╡ 116 | BoxDrawingsVerticalDoubleAndLeftSingle rune = '\u2562' // ╢ 117 | BoxDrawingsDoubleVerticalAndLeft rune = '\u2563' // ╣ 118 | BoxDrawingsDownSingleAndHorizontalDouble rune = '\u2564' // ╤ 119 | BoxDrawingsDownDoubleAndHorizontalSingle rune = '\u2565' // ╥ 120 | BoxDrawingsDoubleDownAndHorizontal rune = '\u2566' // ╦ 121 | BoxDrawingsUpSingleAndHorizontalDouble rune = '\u2567' // ╧ 122 | BoxDrawingsUpDoubleAndHorizontalSingle rune = '\u2568' // ╨ 123 | BoxDrawingsDoubleUpAndHorizontal rune = '\u2569' // ╩ 124 | BoxDrawingsVerticalSingleAndHorizontalDouble rune = '\u256a' // ╪ 125 | BoxDrawingsVerticalDoubleAndHorizontalSingle rune = '\u256b' // ╫ 126 | BoxDrawingsDoubleVerticalAndHorizontal rune = '\u256c' // ╬ 127 | BoxDrawingsLightArcDownAndRight rune = '\u256d' // ╭ 128 | BoxDrawingsLightArcDownAndLeft rune = '\u256e' // ╮ 129 | BoxDrawingsLightArcUpAndLeft rune = '\u256f' // ╯ 130 | BoxDrawingsLightArcUpAndRight rune = '\u2570' // ╰ 131 | BoxDrawingsLightDiagonalUpperRightToLowerLeft rune = '\u2571' // ╱ 132 | BoxDrawingsLightDiagonalUpperLeftToLowerRight rune = '\u2572' // ╲ 133 | BoxDrawingsLightDiagonalCross rune = '\u2573' // ╳ 134 | BoxDrawingsLightLeft rune = '\u2574' // ╴ 135 | BoxDrawingsLightUp rune = '\u2575' // ╵ 136 | BoxDrawingsLightRight rune = '\u2576' // ╶ 137 | BoxDrawingsLightDown rune = '\u2577' // ╷ 138 | BoxDrawingsHeavyLeft rune = '\u2578' // ╸ 139 | BoxDrawingsHeavyUp rune = '\u2579' // ╹ 140 | BoxDrawingsHeavyRight rune = '\u257a' // ╺ 141 | BoxDrawingsHeavyDown rune = '\u257b' // ╻ 142 | BoxDrawingsLightLeftAndHeavyRight rune = '\u257c' // ╼ 143 | BoxDrawingsLightUpAndHeavyDown rune = '\u257d' // ╽ 144 | BoxDrawingsHeavyLeftAndLightRight rune = '\u257e' // ╾ 145 | BoxDrawingsHeavyUpAndLightDown rune = '\u257f' // ╿ 146 | ) 147 | 148 | // SemigraphicJoints is a map for joining semigraphic (or otherwise) runes. 149 | // So far only light lines are supported but if you want to change the border 150 | // styling you need to provide the joints, too. 151 | // The matching will be sorted ascending by rune value, so you don't need to 152 | // provide all rune combinations, 153 | // e.g. (─) + (│) = (┼) will also match (│) + (─) = (┼) 154 | var SemigraphicJoints = map[string]rune{ 155 | // (─) + (│) = (┼) 156 | string([]rune{BoxDrawingsLightHorizontal, BoxDrawingsLightVertical}): BoxDrawingsLightVerticalAndHorizontal, 157 | // (─) + (┌) = (┬) 158 | string([]rune{BoxDrawingsLightHorizontal, BoxDrawingsLightDownAndRight}): BoxDrawingsLightDownAndHorizontal, 159 | // (─) + (┐) = (┬) 160 | string([]rune{BoxDrawingsLightHorizontal, BoxDrawingsLightDownAndLeft}): BoxDrawingsLightDownAndHorizontal, 161 | // (─) + (└) = (┴) 162 | string([]rune{BoxDrawingsLightHorizontal, BoxDrawingsLightUpAndRight}): BoxDrawingsLightUpAndHorizontal, 163 | // (─) + (┘) = (┴) 164 | string([]rune{BoxDrawingsLightHorizontal, BoxDrawingsLightUpAndLeft}): BoxDrawingsLightUpAndHorizontal, 165 | // (─) + (├) = (┼) 166 | string([]rune{BoxDrawingsLightHorizontal, BoxDrawingsLightVerticalAndRight}): BoxDrawingsLightVerticalAndHorizontal, 167 | // (─) + (┤) = (┼) 168 | string([]rune{BoxDrawingsLightHorizontal, BoxDrawingsLightVerticalAndLeft}): BoxDrawingsLightVerticalAndHorizontal, 169 | // (─) + (┬) = (┬) 170 | string([]rune{BoxDrawingsLightHorizontal, BoxDrawingsLightDownAndHorizontal}): BoxDrawingsLightDownAndHorizontal, 171 | // (─) + (┴) = (┴) 172 | string([]rune{BoxDrawingsLightHorizontal, BoxDrawingsLightUpAndHorizontal}): BoxDrawingsLightUpAndHorizontal, 173 | // (─) + (┼) = (┼) 174 | string([]rune{BoxDrawingsLightHorizontal, BoxDrawingsLightVerticalAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 175 | 176 | // (│) + (┌) = (├) 177 | string([]rune{BoxDrawingsLightVertical, BoxDrawingsLightDownAndRight}): BoxDrawingsLightVerticalAndRight, 178 | // (│) + (┐) = (┤) 179 | string([]rune{BoxDrawingsLightVertical, BoxDrawingsLightDownAndLeft}): BoxDrawingsLightVerticalAndLeft, 180 | // (│) + (└) = (├) 181 | string([]rune{BoxDrawingsLightVertical, BoxDrawingsLightUpAndRight}): BoxDrawingsLightVerticalAndRight, 182 | // (│) + (┘) = (┤) 183 | string([]rune{BoxDrawingsLightVertical, BoxDrawingsLightUpAndLeft}): BoxDrawingsLightVerticalAndLeft, 184 | // (│) + (├) = (├) 185 | string([]rune{BoxDrawingsLightVertical, BoxDrawingsLightVerticalAndRight}): BoxDrawingsLightVerticalAndRight, 186 | // (│) + (┤) = (┤) 187 | string([]rune{BoxDrawingsLightVertical, BoxDrawingsLightVerticalAndLeft}): BoxDrawingsLightVerticalAndLeft, 188 | // (│) + (┬) = (┼) 189 | string([]rune{BoxDrawingsLightVertical, BoxDrawingsLightDownAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 190 | // (│) + (┴) = (┼) 191 | string([]rune{BoxDrawingsLightVertical, BoxDrawingsLightUpAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 192 | // (│) + (┼) = (┼) 193 | string([]rune{BoxDrawingsLightVertical, BoxDrawingsLightVerticalAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 194 | 195 | // (┌) + (┐) = (┬) 196 | string([]rune{BoxDrawingsLightDownAndRight, BoxDrawingsLightDownAndLeft}): BoxDrawingsLightDownAndHorizontal, 197 | // (┌) + (└) = (├) 198 | string([]rune{BoxDrawingsLightDownAndRight, BoxDrawingsLightUpAndRight}): BoxDrawingsLightVerticalAndRight, 199 | // (┌) + (┘) = (┼) 200 | string([]rune{BoxDrawingsLightDownAndRight, BoxDrawingsLightUpAndLeft}): BoxDrawingsLightVerticalAndHorizontal, 201 | // (┌) + (├) = (├) 202 | string([]rune{BoxDrawingsLightDownAndRight, BoxDrawingsLightVerticalAndRight}): BoxDrawingsLightVerticalAndRight, 203 | // (┌) + (┤) = (┼) 204 | string([]rune{BoxDrawingsLightDownAndRight, BoxDrawingsLightVerticalAndLeft}): BoxDrawingsLightVerticalAndHorizontal, 205 | // (┌) + (┬) = (┬) 206 | string([]rune{BoxDrawingsLightDownAndRight, BoxDrawingsLightDownAndHorizontal}): BoxDrawingsLightDownAndHorizontal, 207 | // (┌) + (┴) = (┼) 208 | string([]rune{BoxDrawingsLightDownAndRight, BoxDrawingsLightUpAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 209 | // (┌) + (┴) = (┼) 210 | string([]rune{BoxDrawingsLightDownAndRight, BoxDrawingsLightVerticalAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 211 | 212 | // (┐) + (└) = (┼) 213 | string([]rune{BoxDrawingsLightDownAndLeft, BoxDrawingsLightUpAndRight}): BoxDrawingsLightVerticalAndHorizontal, 214 | // (┐) + (┘) = (┤) 215 | string([]rune{BoxDrawingsLightDownAndLeft, BoxDrawingsLightUpAndLeft}): BoxDrawingsLightVerticalAndLeft, 216 | // (┐) + (├) = (┼) 217 | string([]rune{BoxDrawingsLightDownAndLeft, BoxDrawingsLightVerticalAndRight}): BoxDrawingsLightVerticalAndHorizontal, 218 | // (┐) + (┤) = (┤) 219 | string([]rune{BoxDrawingsLightDownAndLeft, BoxDrawingsLightVerticalAndLeft}): BoxDrawingsLightVerticalAndLeft, 220 | // (┐) + (┬) = (┬) 221 | string([]rune{BoxDrawingsLightDownAndLeft, BoxDrawingsLightDownAndHorizontal}): BoxDrawingsLightDownAndHorizontal, 222 | // (┐) + (┴) = (┼) 223 | string([]rune{BoxDrawingsLightDownAndLeft, BoxDrawingsLightUpAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 224 | // (┐) + (┼) = (┼) 225 | string([]rune{BoxDrawingsLightDownAndLeft, BoxDrawingsLightVerticalAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 226 | 227 | // (└) + (┘) = (┴) 228 | string([]rune{BoxDrawingsLightUpAndRight, BoxDrawingsLightUpAndLeft}): BoxDrawingsLightUpAndHorizontal, 229 | // (└) + (├) = (├) 230 | string([]rune{BoxDrawingsLightUpAndRight, BoxDrawingsLightVerticalAndRight}): BoxDrawingsLightVerticalAndRight, 231 | // (└) + (┤) = (┼) 232 | string([]rune{BoxDrawingsLightUpAndRight, BoxDrawingsLightVerticalAndLeft}): BoxDrawingsLightVerticalAndHorizontal, 233 | // (└) + (┬) = (┼) 234 | string([]rune{BoxDrawingsLightUpAndRight, BoxDrawingsLightDownAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 235 | // (└) + (┴) = (┴) 236 | string([]rune{BoxDrawingsLightUpAndRight, BoxDrawingsLightUpAndHorizontal}): BoxDrawingsLightUpAndHorizontal, 237 | // (└) + (┼) = (┼) 238 | string([]rune{BoxDrawingsLightUpAndRight, BoxDrawingsLightVerticalAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 239 | 240 | // (┘) + (├) = (┼) 241 | string([]rune{BoxDrawingsLightUpAndLeft, BoxDrawingsLightVerticalAndRight}): BoxDrawingsLightVerticalAndHorizontal, 242 | // (┘) + (┤) = (┤) 243 | string([]rune{BoxDrawingsLightUpAndLeft, BoxDrawingsLightVerticalAndLeft}): BoxDrawingsLightVerticalAndLeft, 244 | // (┘) + (┬) = (┼) 245 | string([]rune{BoxDrawingsLightUpAndLeft, BoxDrawingsLightDownAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 246 | // (┘) + (┴) = (┴) 247 | string([]rune{BoxDrawingsLightUpAndLeft, BoxDrawingsLightUpAndHorizontal}): BoxDrawingsLightUpAndHorizontal, 248 | // (┘) + (┼) = (┼) 249 | string([]rune{BoxDrawingsLightUpAndLeft, BoxDrawingsLightVerticalAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 250 | 251 | // (├) + (┤) = (┼) 252 | string([]rune{BoxDrawingsLightVerticalAndRight, BoxDrawingsLightVerticalAndLeft}): BoxDrawingsLightVerticalAndHorizontal, 253 | // (├) + (┬) = (┼) 254 | string([]rune{BoxDrawingsLightVerticalAndRight, BoxDrawingsLightDownAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 255 | // (├) + (┴) = (┼) 256 | string([]rune{BoxDrawingsLightVerticalAndRight, BoxDrawingsLightUpAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 257 | // (├) + (┼) = (┼) 258 | string([]rune{BoxDrawingsLightVerticalAndRight, BoxDrawingsLightVerticalAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 259 | 260 | // (┤) + (┬) = (┼) 261 | string([]rune{BoxDrawingsLightVerticalAndLeft, BoxDrawingsLightDownAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 262 | // (┤) + (┴) = (┼) 263 | string([]rune{BoxDrawingsLightVerticalAndLeft, BoxDrawingsLightUpAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 264 | // (┤) + (┼) = (┼) 265 | string([]rune{BoxDrawingsLightVerticalAndLeft, BoxDrawingsLightVerticalAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 266 | 267 | // (┬) + (┴) = (┼) 268 | string([]rune{BoxDrawingsLightDownAndHorizontal, BoxDrawingsLightUpAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 269 | // (┬) + (┼) = (┼) 270 | string([]rune{BoxDrawingsLightDownAndHorizontal, BoxDrawingsLightVerticalAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 271 | 272 | // (┴) + (┼) = (┼) 273 | string([]rune{BoxDrawingsLightUpAndHorizontal, BoxDrawingsLightVerticalAndHorizontal}): BoxDrawingsLightVerticalAndHorizontal, 274 | } 275 | 276 | // PrintJoinedSemigraphics prints a semigraphics rune into the screen at the given 277 | // position with the given color, joining it with any existing semigraphics 278 | // rune. Background colors are preserved. At this point, only regular single 279 | // line borders are supported. 280 | func PrintJoinedSemigraphics(screen tcell.Screen, x, y int, ch rune, color tcell.Color) { 281 | previous, _, style, _ := screen.GetContent(x, y) 282 | style = style.Foreground(color) 283 | 284 | // What's the resulting rune? 285 | var result rune 286 | if ch == previous { 287 | result = ch 288 | } else { 289 | if ch < previous { 290 | previous, ch = ch, previous 291 | } 292 | result = SemigraphicJoints[string([]rune{previous, ch})] 293 | } 294 | if result == 0 { 295 | result = ch 296 | } 297 | 298 | // We only print something if we have something. 299 | screen.SetContent(x, y, result, nil, style) 300 | } 301 | -------------------------------------------------------------------------------- /styles.go: -------------------------------------------------------------------------------- 1 | // From https://github.com/rivo/tview/blob/master/styles.go 2 | // Copyright (c) 2018 Oliver Kuederle 3 | // MIT license 4 | 5 | package mauview 6 | 7 | import "github.com/gdamore/tcell/v2" 8 | 9 | // Styles defines various colors used when primitives are initialized. These 10 | // may be changed to accommodate a different look and feel. 11 | // 12 | // The default is for applications with a black background and basic colors: 13 | // black, white, yellow, green, and blue. 14 | var Styles = struct { 15 | PrimitiveBackgroundColor tcell.Color // Main background color for primitives. 16 | ContrastBackgroundColor tcell.Color // Background color for contrasting elements. 17 | MoreContrastBackgroundColor tcell.Color // Background color for even more contrasting elements. 18 | BorderColor tcell.Color // Box borders. 19 | TitleColor tcell.Color // Box titles. 20 | GraphicsColor tcell.Color // Graphics. 21 | PrimaryTextColor tcell.Color // Primary text. 22 | SecondaryTextColor tcell.Color // Secondary text (e.g. labels). 23 | TertiaryTextColor tcell.Color // Tertiary text (e.g. subtitles, notes). 24 | InverseTextColor tcell.Color // Text on primary-colored backgrounds. 25 | ContrastSecondaryTextColor tcell.Color // Secondary text on ContrastBackgroundColor-colored backgrounds. 26 | }{ 27 | PrimitiveBackgroundColor: tcell.ColorBlack, 28 | ContrastBackgroundColor: tcell.ColorBlue, 29 | MoreContrastBackgroundColor: tcell.ColorGreen, 30 | BorderColor: tcell.ColorWhite, 31 | TitleColor: tcell.ColorWhite, 32 | GraphicsColor: tcell.ColorWhite, 33 | PrimaryTextColor: tcell.ColorWhite, 34 | SecondaryTextColor: tcell.ColorYellow, 35 | TertiaryTextColor: tcell.ColorGreen, 36 | InverseTextColor: tcell.ColorBlue, 37 | ContrastSecondaryTextColor: tcell.ColorDarkCyan, 38 | } 39 | -------------------------------------------------------------------------------- /textfield.go: -------------------------------------------------------------------------------- 1 | // mauview - A Go TUI library based on tcell. 2 | // Copyright © 2019 Tulir Asokan 3 | // 4 | // This Source Code Form is subject to the terms of the Mozilla Public 5 | // License, v. 2.0. If a copy of the MPL was not distributed with this 6 | // file, You can obtain one at http://mozilla.org/MPL/2.0/. 7 | 8 | package mauview 9 | 10 | import ( 11 | "sync" 12 | 13 | "github.com/gdamore/tcell/v2" 14 | ) 15 | 16 | type TextField struct { 17 | sync.Mutex 18 | *SimpleEventHandler 19 | text string 20 | style tcell.Style 21 | } 22 | 23 | func NewTextField() *TextField { 24 | return &TextField{ 25 | SimpleEventHandler: &SimpleEventHandler{}, 26 | 27 | text: "", 28 | style: tcell.StyleDefault.Foreground(Styles.PrimaryTextColor), 29 | } 30 | } 31 | 32 | func (tf *TextField) SetText(text string) *TextField { 33 | tf.Lock() 34 | tf.text = text 35 | tf.Unlock() 36 | return tf 37 | } 38 | 39 | func (tf *TextField) SetTextColor(color tcell.Color) *TextField { 40 | tf.Lock() 41 | tf.style = tf.style.Foreground(color) 42 | tf.Unlock() 43 | return tf 44 | } 45 | 46 | func (tf *TextField) SetBackgroundColor(color tcell.Color) *TextField { 47 | tf.Lock() 48 | tf.style = tf.style.Background(color) 49 | tf.Unlock() 50 | return tf 51 | } 52 | 53 | func (tf *TextField) SetStyle(style tcell.Style) *TextField { 54 | tf.Lock() 55 | tf.style = style 56 | tf.Unlock() 57 | return tf 58 | } 59 | 60 | func (tf *TextField) Draw(screen Screen) { 61 | tf.Lock() 62 | width, _ := screen.Size() 63 | screen.SetStyle(tf.style) 64 | screen.Clear() 65 | PrintWithStyle(screen, tf.text, 0, 0, width, AlignLeft, tf.style) 66 | tf.Unlock() 67 | } 68 | -------------------------------------------------------------------------------- /textview.go: -------------------------------------------------------------------------------- 1 | // From https://github.com/rivo/tview/blob/master/textview.go 2 | 3 | package mauview 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "regexp" 9 | "sync" 10 | "unicode/utf8" 11 | 12 | "github.com/mattn/go-runewidth" 13 | 14 | "github.com/gdamore/tcell/v2" 15 | ) 16 | 17 | // TabSize is the number of spaces with which a tab character will be replaced. 18 | var TabSize = 4 19 | 20 | // textViewIndex contains information about each line displayed in the text 21 | // view. 22 | type textViewIndex struct { 23 | Line int // The index into the "buffer" variable. 24 | Pos int // The index into the "buffer" string (byte position). 25 | NextPos int // The (byte) index of the next character in this buffer line. 26 | Width int // The screen width of this line. 27 | ForegroundColor string // The starting foreground color ("" = don't change, "-" = reset). 28 | BackgroundColor string // The starting background color ("" = don't change, "-" = reset). 29 | Attributes string // The starting attributes ("" = don't change, "-" = reset). 30 | Region string // The starting region ID. 31 | } 32 | 33 | // TextView is a box which displays text. It implements the io.Writer interface 34 | // so you can stream text to it. This does not trigger a redraw automatically 35 | // but if a handler is installed via SetChangedFunc(), you can cause it to be 36 | // redrawn. (See SetChangedFunc() for more details.) 37 | // 38 | // # Navigation 39 | // 40 | // If the text view is scrollable (the default), text is kept in a buffer which 41 | // may be larger than the screen and can be navigated similarly to Vim: 42 | // 43 | // - h, left arrow: Move left. 44 | // - l, right arrow: Move right. 45 | // - j, down arrow: Move down. 46 | // - k, up arrow: Move up. 47 | // - g, home: Move to the top. 48 | // - G, end: Move to the bottom. 49 | // - Ctrl-F, page down: Move down by one page. 50 | // - Ctrl-B, page up: Move up by one page. 51 | // 52 | // If the text is not scrollable, any text above the top visible line is 53 | // discarded. 54 | // 55 | // Use SetInputCapture() to override or modify keyboard input. 56 | // 57 | // # Colors 58 | // 59 | // If dynamic colors are enabled via SetDynamicColors(), text color can be 60 | // changed dynamically by embedding color strings in square brackets. This works 61 | // the same way as anywhere else. Please see the package documentation for more 62 | // information. 63 | // 64 | // # Regions and Highlights 65 | // 66 | // If regions are enabled via SetRegions(), you can define text regions within 67 | // the text and assign region IDs to them. Text regions start with region tags. 68 | // Region tags are square brackets that contain a region ID in double quotes, 69 | // for example: 70 | // 71 | // We define a ["rg"]region[""] here. 72 | // 73 | // A text region ends with the next region tag. Tags with no region ID ([""]) 74 | // don't start new regions. They can therefore be used to mark the end of a 75 | // region. Region IDs must satisfy the following regular expression: 76 | // 77 | // [a-zA-Z0-9_,;: \-\.]+ 78 | // 79 | // Regions can be highlighted by calling the Highlight() function with one or 80 | // more region IDs. This can be used to display search results, for example. 81 | // 82 | // The ScrollToHighlight() function can be used to jump to the currently 83 | // highlighted region once when the text view is drawn the next time. 84 | // 85 | // See https://github.com/rivo/tview/wiki/TextView for an example. 86 | type TextView struct { 87 | sync.Mutex 88 | 89 | // The text buffer. 90 | buffer []string 91 | 92 | // The last bytes that have been received but are not part of the buffer yet. 93 | recentBytes []byte 94 | 95 | // The processed line index. This is nil if the buffer has changed and needs 96 | // to be re-indexed. 97 | index []*textViewIndex 98 | 99 | // The text alignment, one of AlignLeft, AlignCenter, or AlignRight. 100 | align int 101 | 102 | // Indices into the "index" slice which correspond to the first line of the 103 | // first highlight and the last line of the last highlight. This is calculated 104 | // during re-indexing. Set to -1 if there is no current highlight. 105 | fromHighlight, toHighlight int 106 | 107 | // The screen space column of the highlight in its first line. Set to -1 if 108 | // there is no current highlight. 109 | posHighlight int 110 | 111 | // A set of region IDs that are currently highlighted. 112 | highlights map[string]struct{} 113 | 114 | // The last width for which the current table is drawn. 115 | lastWidth int 116 | 117 | // The screen width of the longest line in the index (not the buffer). 118 | longestLine int 119 | 120 | // The index of the first line shown in the text view. 121 | lineOffset int 122 | 123 | // If set to true, the text view will always remain at the end of the content. 124 | trackEnd bool 125 | 126 | // The number of characters to be skipped on each line (not in wrap mode). 127 | columnOffset int 128 | 129 | // The height of the content the last time the text view was drawn. 130 | pageSize int 131 | 132 | // If set to true, the text view will keep a buffer of text which can be 133 | // navigated when the text is longer than what fits into the box. 134 | scrollable bool 135 | 136 | // If set to true, lines that are longer than the available width are wrapped 137 | // onto the next line. If set to false, any characters beyond the available 138 | // width are discarded. 139 | wrap bool 140 | 141 | // If set to true and if wrap is also true, lines are split at spaces or 142 | // after punctuation characters. 143 | wordWrap bool 144 | 145 | baseStyle tcell.Style 146 | 147 | // If set to true, the text color can be changed dynamically by piping color 148 | // strings in square brackets to the text view. 149 | dynamicColors bool 150 | 151 | // If set to true, region tags can be used to define regions. 152 | regions bool 153 | 154 | // A temporary flag which, when true, will automatically bring the current 155 | // highlight(s) into the visible screen. 156 | scrollToHighlights bool 157 | 158 | // An optional function which is called when the content of the text view has 159 | // changed. 160 | changed func() 161 | 162 | // An optional function which is called when the user presses one of the 163 | // following keys: Escape, Enter, Tab, Backtab. 164 | done func(tcell.Key) 165 | } 166 | 167 | // NewTextView returns a new text view. 168 | func NewTextView() *TextView { 169 | return &TextView{ 170 | highlights: make(map[string]struct{}), 171 | lineOffset: -1, 172 | scrollable: true, 173 | align: AlignLeft, 174 | wrap: true, 175 | baseStyle: tcell.StyleDefault.Foreground(Styles.PrimaryTextColor), 176 | regions: false, 177 | dynamicColors: false, 178 | } 179 | } 180 | 181 | // SetScrollable sets the flag that decides whether or not the text view is 182 | // scrollable. If true, text is kept in a buffer and can be navigated. 183 | func (t *TextView) SetScrollable(scrollable bool) *TextView { 184 | t.scrollable = scrollable 185 | if !scrollable { 186 | t.trackEnd = true 187 | } 188 | return t 189 | } 190 | 191 | // SetWrap sets the flag that, if true, leads to lines that are longer than the 192 | // available width being wrapped onto the next line. If false, any characters 193 | // beyond the available width are not displayed. 194 | func (t *TextView) SetWrap(wrap bool) *TextView { 195 | if t.wrap != wrap { 196 | t.index = nil 197 | } 198 | t.wrap = wrap 199 | return t 200 | } 201 | 202 | // SetWordWrap sets the flag that, if true and if the "wrap" flag is also true 203 | // (see SetWrap()), wraps the line at spaces or after punctuation marks. Note 204 | // that trailing spaces will not be printed. 205 | // 206 | // This flag is ignored if the "wrap" flag is false. 207 | func (t *TextView) SetWordWrap(wrapOnWords bool) *TextView { 208 | if t.wordWrap != wrapOnWords { 209 | t.index = nil 210 | } 211 | t.wordWrap = wrapOnWords 212 | return t 213 | } 214 | 215 | // SetTextAlign sets the text alignment within the text view. This must be 216 | // either AlignLeft, AlignCenter, or AlignRight. 217 | func (t *TextView) SetTextAlign(align int) *TextView { 218 | if t.align != align { 219 | t.index = nil 220 | } 221 | t.align = align 222 | return t 223 | } 224 | 225 | // SetTextColor sets the initial color of the text (which can be changed 226 | // dynamically by sending color strings in square brackets to the text view if 227 | // dynamic colors are enabled). 228 | func (t *TextView) SetTextColor(color tcell.Color) *TextView { 229 | t.baseStyle = t.baseStyle.Foreground(color) 230 | return t 231 | } 232 | 233 | func (t *TextView) SetBackgroundColor(color tcell.Color) *TextView { 234 | t.baseStyle = t.baseStyle.Background(color) 235 | return t 236 | } 237 | 238 | // SetText sets the text of this text view to the provided string. Previously 239 | // contained text will be removed. 240 | func (t *TextView) SetText(text string) *TextView { 241 | t.Clear() 242 | fmt.Fprint(t, text) 243 | return t 244 | } 245 | 246 | // SetDynamicColors sets the flag that allows the text color to be changed 247 | // dynamically. See class description for details. 248 | func (t *TextView) SetDynamicColors(dynamic bool) *TextView { 249 | if t.dynamicColors != dynamic { 250 | t.index = nil 251 | } 252 | t.dynamicColors = dynamic 253 | return t 254 | } 255 | 256 | // SetRegions sets the flag that allows to define regions in the text. See class 257 | // description for details. 258 | func (t *TextView) SetRegions(regions bool) *TextView { 259 | if t.regions != regions { 260 | t.index = nil 261 | } 262 | t.regions = regions 263 | return t 264 | } 265 | 266 | // SetChangedFunc sets a handler function which is called when the text of the 267 | // text view has changed. This is useful when text is written to this io.Writer 268 | // in a separate goroutine. This does not automatically cause the screen to be 269 | // refreshed so you may want to use the "changed" handler to redraw the screen. 270 | // 271 | // Note that to avoid race conditions or deadlocks, there are a few rules you 272 | // should follow: 273 | // 274 | // - You can call Application.Draw() from this handler. 275 | // - You can call TextView.HasFocus() from this handler. 276 | // - During the execution of this handler, access to any other variables from 277 | // this primitive or any other primitive should be queued using 278 | // Application.QueueUpdate(). 279 | // 280 | // See package description for details on dealing with concurrency. 281 | func (t *TextView) SetChangedFunc(handler func()) *TextView { 282 | t.changed = handler 283 | return t 284 | } 285 | 286 | // SetDoneFunc sets a handler which is called when the user presses on the 287 | // following keys: Escape, Enter, Tab, Backtab. The key is passed to the 288 | // handler. 289 | func (t *TextView) SetDoneFunc(handler func(key tcell.Key)) *TextView { 290 | t.done = handler 291 | return t 292 | } 293 | 294 | // ScrollTo scrolls to the specified row and column (both starting with 0). 295 | func (t *TextView) ScrollTo(row, column int) *TextView { 296 | if !t.scrollable { 297 | return t 298 | } 299 | t.lineOffset = row 300 | t.columnOffset = column 301 | return t 302 | } 303 | 304 | // ScrollToBeginning scrolls to the top left corner of the text if the text view 305 | // is scrollable. 306 | func (t *TextView) ScrollToBeginning() *TextView { 307 | if !t.scrollable { 308 | return t 309 | } 310 | t.trackEnd = false 311 | t.lineOffset = 0 312 | t.columnOffset = 0 313 | return t 314 | } 315 | 316 | // ScrollToEnd scrolls to the bottom left corner of the text if the text view 317 | // is scrollable. Adding new rows to the end of the text view will cause it to 318 | // scroll with the new data. 319 | func (t *TextView) ScrollToEnd() *TextView { 320 | if !t.scrollable { 321 | return t 322 | } 323 | t.trackEnd = true 324 | t.columnOffset = 0 325 | return t 326 | } 327 | 328 | // GetScrollOffset returns the number of rows and columns that are skipped at 329 | // the top left corner when the text view has been scrolled. 330 | func (t *TextView) GetScrollOffset() (row, column int) { 331 | return t.lineOffset, t.columnOffset 332 | } 333 | 334 | // Clear removes all text from the buffer. 335 | func (t *TextView) Clear() *TextView { 336 | t.buffer = nil 337 | t.recentBytes = nil 338 | t.index = nil 339 | return t 340 | } 341 | 342 | // Highlight specifies which regions should be highlighted. See class 343 | // description for details on regions. Empty region strings are ignored. 344 | // 345 | // Text in highlighted regions will be drawn inverted, i.e. with their 346 | // background and foreground colors swapped. 347 | // 348 | // Calling this function will remove any previous highlights. To remove all 349 | // highlights, call this function without any arguments. 350 | func (t *TextView) Highlight(regionIDs ...string) *TextView { 351 | t.highlights = make(map[string]struct{}) 352 | for _, id := range regionIDs { 353 | if id == "" { 354 | continue 355 | } 356 | t.highlights[id] = struct{}{} 357 | } 358 | t.index = nil 359 | return t 360 | } 361 | 362 | // GetHighlights returns the IDs of all currently highlighted regions. 363 | func (t *TextView) GetHighlights() (regionIDs []string) { 364 | for id := range t.highlights { 365 | regionIDs = append(regionIDs, id) 366 | } 367 | return 368 | } 369 | 370 | // ScrollToHighlight will cause the visible area to be scrolled so that the 371 | // highlighted regions appear in the visible area of the text view. This 372 | // repositioning happens the next time the text view is drawn. It happens only 373 | // once so you will need to call this function repeatedly to always keep 374 | // highlighted regions in view. 375 | // 376 | // Nothing happens if there are no highlighted regions or if the text view is 377 | // not scrollable. 378 | func (t *TextView) ScrollToHighlight() *TextView { 379 | if len(t.highlights) == 0 || !t.scrollable || !t.regions { 380 | return t 381 | } 382 | t.index = nil 383 | t.scrollToHighlights = true 384 | t.trackEnd = false 385 | return t 386 | } 387 | 388 | // GetRegionText returns the text of the region with the given ID. If dynamic 389 | // colors are enabled, color tags are stripped from the text. Newlines are 390 | // always returned as '\n' runes. 391 | // 392 | // If the region does not exist or if regions are turned off, an empty string 393 | // is returned. 394 | func (t *TextView) GetRegionText(regionID string) string { 395 | if !t.regions || regionID == "" { 396 | return "" 397 | } 398 | 399 | var ( 400 | buffer bytes.Buffer 401 | currentRegionID string 402 | ) 403 | 404 | for _, str := range t.buffer { 405 | // Find all color tags in this line. 406 | var colorTagIndices [][]int 407 | if t.dynamicColors { 408 | colorTagIndices = colorPattern.FindAllStringIndex(str, -1) 409 | } 410 | 411 | // Find all regions in this line. 412 | var ( 413 | regionIndices [][]int 414 | regions [][]string 415 | ) 416 | if t.regions { 417 | regionIndices = regionPattern.FindAllStringIndex(str, -1) 418 | regions = regionPattern.FindAllStringSubmatch(str, -1) 419 | } 420 | 421 | // Analyze this line. 422 | var currentTag, currentRegion int 423 | for pos, ch := range str { 424 | // Skip any color tags. 425 | if currentTag < len(colorTagIndices) && pos >= colorTagIndices[currentTag][0] && pos < colorTagIndices[currentTag][1] { 426 | if pos == colorTagIndices[currentTag][1]-1 { 427 | currentTag++ 428 | } 429 | continue 430 | } 431 | 432 | // Skip any regions. 433 | if currentRegion < len(regionIndices) && pos >= regionIndices[currentRegion][0] && pos < regionIndices[currentRegion][1] { 434 | if pos == regionIndices[currentRegion][1]-1 { 435 | if currentRegionID == regionID { 436 | // This is the end of the requested region. We're done. 437 | return buffer.String() 438 | } 439 | currentRegionID = regions[currentRegion][1] 440 | currentRegion++ 441 | } 442 | continue 443 | } 444 | 445 | // Add this rune. 446 | if currentRegionID == regionID { 447 | buffer.WriteRune(ch) 448 | } 449 | } 450 | 451 | // Add newline. 452 | if currentRegionID == regionID { 453 | buffer.WriteRune('\n') 454 | } 455 | } 456 | 457 | return escapePattern.ReplaceAllString(buffer.String(), `[$1$2]`) 458 | } 459 | 460 | // Write lets us implement the io.Writer interface. Tab characters will be 461 | // replaced with TabSize space characters. A "\n" or "\r\n" will be interpreted 462 | // as a new line. 463 | func (t *TextView) Write(p []byte) (n int, err error) { 464 | // Notify at the end. 465 | t.Lock() 466 | changed := t.changed 467 | t.Unlock() 468 | if changed != nil { 469 | defer changed() // Deadlocks may occur if we lock here. 470 | } 471 | 472 | t.Lock() 473 | defer t.Unlock() 474 | 475 | // Copy data over. 476 | newBytes := append(t.recentBytes, p...) 477 | t.recentBytes = nil 478 | 479 | // If we have a trailing invalid UTF-8 byte, we'll wait. 480 | if r, _ := utf8.DecodeLastRune(p); r == utf8.RuneError { 481 | t.recentBytes = newBytes 482 | return len(p), nil 483 | } 484 | 485 | // If we have a trailing open dynamic color, exclude it. 486 | if t.dynamicColors { 487 | openColor := regexp.MustCompile(`\[([a-zA-Z]*|#[0-9a-zA-Z]*)$`) 488 | location := openColor.FindIndex(newBytes) 489 | if location != nil { 490 | t.recentBytes = newBytes[location[0]:] 491 | newBytes = newBytes[:location[0]] 492 | } 493 | } 494 | 495 | // If we have a trailing open region, exclude it. 496 | if t.regions { 497 | openRegion := regexp.MustCompile(`\["[a-zA-Z0-9_,;: \-\.]*"?$`) 498 | location := openRegion.FindIndex(newBytes) 499 | if location != nil { 500 | t.recentBytes = newBytes[location[0]:] 501 | newBytes = newBytes[:location[0]] 502 | } 503 | } 504 | 505 | // Transform the new bytes into strings. 506 | newLine := regexp.MustCompile(`\r?\n`) 507 | newBytes = bytes.Replace(newBytes, []byte{'\t'}, bytes.Repeat([]byte{' '}, TabSize), -1) 508 | for index, line := range newLine.Split(string(newBytes), -1) { 509 | if index == 0 { 510 | if len(t.buffer) == 0 { 511 | t.buffer = []string{line} 512 | } else { 513 | t.buffer[len(t.buffer)-1] += line 514 | } 515 | } else { 516 | t.buffer = append(t.buffer, line) 517 | } 518 | } 519 | 520 | // Reset the index. 521 | t.index = nil 522 | 523 | return len(p), nil 524 | } 525 | 526 | // reindexBuffer re-indexes the buffer such that we can use it to easily draw 527 | // the buffer onto the screen. Each line in the index will contain a pointer 528 | // into the buffer from which on we will print text. It will also contain the 529 | // color with which the line starts. 530 | func (t *TextView) reindexBuffer(width int) { 531 | if t.index != nil { 532 | return // Nothing has changed. We can still use the current index. 533 | } 534 | t.index = nil 535 | t.fromHighlight, t.toHighlight, t.posHighlight = -1, -1, -1 536 | 537 | // If there's no space, there's no index. 538 | if width < 1 { 539 | return 540 | } 541 | 542 | // Initial states. 543 | regionID := "" 544 | var highlighted bool 545 | 546 | // Go through each line in the buffer. 547 | for bufferIndex, str := range t.buffer { 548 | // Find all color tags in this line. Then remove them. 549 | var ( 550 | colorTagIndices [][]int 551 | colorTags [][]string 552 | escapeIndices [][]int 553 | ) 554 | strippedStr := str 555 | if t.dynamicColors { 556 | colorTagIndices, colorTags, _, _, escapeIndices, strippedStr, _ = decomposeString(str, true, false) 557 | } 558 | 559 | // Find all regions in this line. Then remove them. 560 | var ( 561 | regionIndices [][]int 562 | regions [][]string 563 | ) 564 | if t.regions { 565 | regionIndices = regionPattern.FindAllStringIndex(str, -1) 566 | regions = regionPattern.FindAllStringSubmatch(str, -1) 567 | strippedStr = regionPattern.ReplaceAllString(strippedStr, "") 568 | } 569 | 570 | // We don't need the original string anymore for now. 571 | str = strippedStr 572 | 573 | // Split the line if required. 574 | var splitLines []string 575 | if t.wrap && len(str) > 0 { 576 | for len(str) > 0 { 577 | extract := runewidth.Truncate(str, width, "") 578 | if t.wordWrap && len(extract) < len(str) { 579 | // Add any spaces from the next line. 580 | if spaces := spacePattern.FindStringIndex(str[len(extract):]); spaces != nil && spaces[0] == 0 { 581 | extract = str[:len(extract)+spaces[1]] 582 | } 583 | 584 | // Can we split before the mandatory end? 585 | matches := boundaryPattern.FindAllStringIndex(extract, -1) 586 | if len(matches) > 0 { 587 | // Yes. Let's split there. 588 | extract = extract[:matches[len(matches)-1][1]] 589 | } 590 | } 591 | splitLines = append(splitLines, extract) 592 | str = str[len(extract):] 593 | } 594 | } else { 595 | // No need to split the line. 596 | splitLines = []string{str} 597 | } 598 | 599 | // Create index from split lines. 600 | var ( 601 | originalPos, colorPos, regionPos, escapePos int 602 | foregroundColor, backgroundColor, attributes string 603 | ) 604 | for _, splitLine := range splitLines { 605 | line := &textViewIndex{ 606 | Line: bufferIndex, 607 | Pos: originalPos, 608 | ForegroundColor: foregroundColor, 609 | BackgroundColor: backgroundColor, 610 | Attributes: attributes, 611 | Region: regionID, 612 | } 613 | 614 | // Shift original position with tags. 615 | lineLength := len(splitLine) 616 | remainingLength := lineLength 617 | tagEnd := originalPos 618 | totalTagLength := 0 619 | for { 620 | // Which tag comes next? 621 | nextTag := make([][3]int, 0, 3) 622 | if colorPos < len(colorTagIndices) { 623 | nextTag = append(nextTag, [3]int{colorTagIndices[colorPos][0], colorTagIndices[colorPos][1], 0}) // 0 = color tag. 624 | } 625 | if regionPos < len(regionIndices) { 626 | nextTag = append(nextTag, [3]int{regionIndices[regionPos][0], regionIndices[regionPos][1], 1}) // 1 = region tag. 627 | } 628 | if escapePos < len(escapeIndices) { 629 | nextTag = append(nextTag, [3]int{escapeIndices[escapePos][0], escapeIndices[escapePos][1], 2}) // 2 = escape tag. 630 | } 631 | minPos := -1 632 | tagIndex := -1 633 | for index, pair := range nextTag { 634 | if minPos < 0 || pair[0] < minPos { 635 | minPos = pair[0] 636 | tagIndex = index 637 | } 638 | } 639 | 640 | // Is the next tag in range? 641 | if tagIndex < 0 || minPos >= tagEnd+remainingLength { 642 | break // No. We're done with this line. 643 | } 644 | 645 | // Advance. 646 | strippedTagStart := nextTag[tagIndex][0] - originalPos - totalTagLength 647 | tagEnd = nextTag[tagIndex][1] 648 | tagLength := tagEnd - nextTag[tagIndex][0] 649 | if nextTag[tagIndex][2] == 2 { 650 | tagLength = 1 651 | } 652 | totalTagLength += tagLength 653 | remainingLength = lineLength - (tagEnd - originalPos - totalTagLength) 654 | 655 | // Process the tag. 656 | switch nextTag[tagIndex][2] { 657 | case 0: 658 | // Process color tags. 659 | foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colorTags[colorPos]) 660 | colorPos++ 661 | case 1: 662 | // Process region tags. 663 | regionID = regions[regionPos][1] 664 | _, highlighted = t.highlights[regionID] 665 | 666 | // Update highlight range. 667 | if highlighted { 668 | line := len(t.index) 669 | if t.fromHighlight < 0 { 670 | t.fromHighlight, t.toHighlight = line, line 671 | t.posHighlight = runewidth.StringWidth(splitLine[:strippedTagStart]) 672 | } else if line > t.toHighlight { 673 | t.toHighlight = line 674 | } 675 | } 676 | 677 | regionPos++ 678 | case 2: 679 | // Process escape tags. 680 | escapePos++ 681 | } 682 | } 683 | 684 | // Advance to next line. 685 | originalPos += lineLength + totalTagLength 686 | 687 | // Append this line. 688 | line.NextPos = originalPos 689 | line.Width = runewidth.StringWidth(splitLine) 690 | t.index = append(t.index, line) 691 | } 692 | 693 | // Word-wrapped lines may have trailing whitespace. Remove it. 694 | if t.wrap && t.wordWrap { 695 | for _, line := range t.index { 696 | str := t.buffer[line.Line][line.Pos:line.NextPos] 697 | spaces := spacePattern.FindAllStringIndex(str, -1) 698 | if spaces != nil && spaces[len(spaces)-1][1] == len(str) { 699 | oldNextPos := line.NextPos 700 | line.NextPos -= spaces[len(spaces)-1][1] - spaces[len(spaces)-1][0] 701 | line.Width -= runewidth.StringWidth(t.buffer[line.Line][line.NextPos:oldNextPos]) 702 | } 703 | } 704 | } 705 | } 706 | 707 | // Calculate longest line. 708 | t.longestLine = 0 709 | for _, line := range t.index { 710 | if line.Width > t.longestLine { 711 | t.longestLine = line.Width 712 | } 713 | } 714 | } 715 | 716 | // Draw draws this primitive onto the screen. 717 | func (t *TextView) Draw(screen Screen) { 718 | t.Lock() 719 | defer t.Unlock() 720 | 721 | screen.SetStyle(t.baseStyle) 722 | screen.Clear() 723 | 724 | // Get the available size. 725 | width, height := screen.Size() 726 | t.pageSize = height 727 | 728 | // If the width has changed, we need to reindex. 729 | if width != t.lastWidth && t.wrap { 730 | t.index = nil 731 | } 732 | t.lastWidth = width 733 | 734 | // Re-index. 735 | t.reindexBuffer(width) 736 | 737 | // If we don't have an index, there's nothing to draw. 738 | if t.index == nil { 739 | return 740 | } 741 | 742 | // Move to highlighted regions. 743 | if t.regions && t.scrollToHighlights && t.fromHighlight >= 0 { 744 | // Do we fit the entire height? 745 | if t.toHighlight-t.fromHighlight+1 < height { 746 | // Yes, let's center the highlights. 747 | t.lineOffset = (t.fromHighlight + t.toHighlight - height) / 2 748 | } else { 749 | // No, let's move to the start of the highlights. 750 | t.lineOffset = t.fromHighlight 751 | } 752 | 753 | // If the highlight is too far to the right, move it to the middle. 754 | if t.posHighlight-t.columnOffset > 3*width/4 { 755 | t.columnOffset = t.posHighlight - width/2 756 | } 757 | 758 | // If the highlight is off-screen on the left, move it on-screen. 759 | if t.posHighlight-t.columnOffset < 0 { 760 | t.columnOffset = t.posHighlight - width/4 761 | } 762 | } 763 | t.scrollToHighlights = false 764 | 765 | // Adjust line offset. 766 | if t.lineOffset+height > len(t.index) { 767 | t.trackEnd = true 768 | } 769 | if t.trackEnd { 770 | t.lineOffset = len(t.index) - height 771 | } 772 | if t.lineOffset < 0 { 773 | t.lineOffset = 0 774 | } 775 | 776 | // Adjust column offset. 777 | if t.align == AlignLeft { 778 | if t.columnOffset+width > t.longestLine { 779 | t.columnOffset = t.longestLine - width 780 | } 781 | if t.columnOffset < 0 { 782 | t.columnOffset = 0 783 | } 784 | } else if t.align == AlignRight { 785 | if t.columnOffset-width < -t.longestLine { 786 | t.columnOffset = width - t.longestLine 787 | } 788 | if t.columnOffset > 0 { 789 | t.columnOffset = 0 790 | } 791 | } else { // AlignCenter. 792 | half := (t.longestLine - width) / 2 793 | if half > 0 { 794 | if t.columnOffset > half { 795 | t.columnOffset = half 796 | } 797 | if t.columnOffset < -half { 798 | t.columnOffset = -half 799 | } 800 | } else { 801 | t.columnOffset = 0 802 | } 803 | } 804 | 805 | // Draw the buffer. 806 | defaultStyle := t.baseStyle 807 | for line := t.lineOffset; line < len(t.index); line++ { 808 | // Are we done? 809 | if line-t.lineOffset >= height { 810 | break 811 | } 812 | 813 | // Get the text for this line. 814 | index := t.index[line] 815 | text := t.buffer[index.Line][index.Pos:index.NextPos] 816 | foregroundColor := index.ForegroundColor 817 | backgroundColor := index.BackgroundColor 818 | attributes := index.Attributes 819 | regionID := index.Region 820 | 821 | // Get color tags. 822 | var ( 823 | colorTagIndices [][]int 824 | colorTags [][]string 825 | escapeIndices [][]int 826 | ) 827 | strippedText := text 828 | if t.dynamicColors { 829 | colorTagIndices, colorTags, _, _, escapeIndices, strippedText, _ = decomposeString(text, true, false) 830 | } 831 | 832 | // Get regions. 833 | var ( 834 | regionIndices [][]int 835 | regions [][]string 836 | ) 837 | if t.regions { 838 | regionIndices = regionPattern.FindAllStringIndex(text, -1) 839 | regions = regionPattern.FindAllStringSubmatch(text, -1) 840 | strippedText = regionPattern.ReplaceAllString(strippedText, "") 841 | if !t.dynamicColors { 842 | escapeIndices = escapePattern.FindAllStringIndex(text, -1) 843 | strippedText = escapePattern.ReplaceAllString(strippedText, "[$1$2]") 844 | } 845 | } 846 | 847 | // Calculate the position of the line. 848 | var skip, posX int 849 | if t.align == AlignLeft { 850 | posX = -t.columnOffset 851 | } else if t.align == AlignRight { 852 | posX = width - index.Width - t.columnOffset 853 | } else { // AlignCenter. 854 | posX = (width-index.Width)/2 - t.columnOffset 855 | } 856 | if posX < 0 { 857 | skip = -posX 858 | posX = 0 859 | } 860 | 861 | // Print the line. 862 | var colorPos, regionPos, escapePos, tagOffset, skipped int 863 | iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { 864 | // Process tags. 865 | for { 866 | if colorPos < len(colorTags) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] { 867 | // Get the color. 868 | foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colorTags[colorPos]) 869 | tagOffset += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0] 870 | colorPos++ 871 | } else if regionPos < len(regionIndices) && textPos+tagOffset >= regionIndices[regionPos][0] && textPos+tagOffset < regionIndices[regionPos][1] { 872 | // Get the region. 873 | regionID = regions[regionPos][1] 874 | tagOffset += regionIndices[regionPos][1] - regionIndices[regionPos][0] 875 | regionPos++ 876 | } else { 877 | break 878 | } 879 | } 880 | 881 | // Skip the second-to-last character of an escape tag. 882 | if escapePos < len(escapeIndices) && textPos+tagOffset == escapeIndices[escapePos][1]-2 { 883 | tagOffset++ 884 | escapePos++ 885 | } 886 | 887 | // Mix the existing style with the new style. 888 | _, _, existingStyle, _ := screen.GetContent(posX, line-t.lineOffset) 889 | _, background, _ := existingStyle.Decompose() 890 | style := overlayStyle(defaultStyle.Background(background), foregroundColor, backgroundColor, attributes) 891 | 892 | // Do we highlight this character? 893 | var highlighted bool 894 | if len(regionID) > 0 { 895 | if _, ok := t.highlights[regionID]; ok { 896 | highlighted = true 897 | } 898 | } 899 | if highlighted { 900 | _, _, attrs := style.Decompose() 901 | reversed := attrs&tcell.AttrReverse != 0 902 | style = style.Reverse(!reversed) 903 | } 904 | 905 | // Skip to the right. 906 | if !t.wrap && skipped < skip { 907 | skipped += screenWidth 908 | return false 909 | } 910 | 911 | // Stop at the right border. 912 | if posX+screenWidth > width { 913 | return true 914 | } 915 | 916 | // Draw the character. 917 | for offset := screenWidth - 1; offset >= 0; offset-- { 918 | if offset == 0 { 919 | screen.SetContent(posX+offset, line-t.lineOffset, main, comb, style) 920 | } else { 921 | screen.SetContent(posX+offset, line-t.lineOffset, ' ', nil, style) 922 | } 923 | } 924 | 925 | // Advance. 926 | posX += screenWidth 927 | return false 928 | }) 929 | } 930 | 931 | // If this view is not scrollable, we'll purge the buffer of lines that have 932 | // scrolled out of view. 933 | if !t.scrollable && t.lineOffset > 0 { 934 | t.buffer = t.buffer[t.index[t.lineOffset].Line:] 935 | t.index = nil 936 | } 937 | } 938 | 939 | func (t *TextView) Submit(event KeyEvent) bool { 940 | return true 941 | } 942 | 943 | func (t *TextView) OnKeyEvent(event KeyEvent) bool { 944 | key := event.Key() 945 | 946 | if !t.scrollable { 947 | return false 948 | } 949 | 950 | switch key { 951 | case tcell.KeyRune: 952 | switch event.Rune() { 953 | case 'g': // Home. 954 | t.trackEnd = false 955 | t.lineOffset = 0 956 | t.columnOffset = 0 957 | case 'G': // End. 958 | t.trackEnd = true 959 | t.columnOffset = 0 960 | case 'j': // Down. 961 | t.lineOffset++ 962 | case 'k': // Up. 963 | t.trackEnd = false 964 | t.lineOffset-- 965 | case 'h': // Left. 966 | t.columnOffset-- 967 | case 'l': // Right. 968 | t.columnOffset++ 969 | } 970 | case tcell.KeyHome: 971 | t.trackEnd = false 972 | t.lineOffset = 0 973 | t.columnOffset = 0 974 | case tcell.KeyEnd: 975 | t.trackEnd = true 976 | t.columnOffset = 0 977 | case tcell.KeyUp: 978 | t.trackEnd = false 979 | t.lineOffset-- 980 | case tcell.KeyDown: 981 | t.lineOffset++ 982 | case tcell.KeyLeft: 983 | t.columnOffset-- 984 | case tcell.KeyRight: 985 | t.columnOffset++ 986 | case tcell.KeyPgDn, tcell.KeyCtrlF: 987 | t.lineOffset += t.pageSize 988 | case tcell.KeyPgUp, tcell.KeyCtrlB: 989 | t.trackEnd = false 990 | t.lineOffset -= t.pageSize 991 | default: 992 | return false 993 | } 994 | return true 995 | } 996 | 997 | func (t *TextView) OnMouseEvent(event MouseEvent) bool { 998 | if !t.scrollable { 999 | return false 1000 | } 1001 | 1002 | switch event.Buttons() { 1003 | case tcell.WheelDown: 1004 | t.lineOffset += 3 1005 | case tcell.WheelUp: 1006 | t.lineOffset -= 3 1007 | default: 1008 | return false 1009 | } 1010 | return true 1011 | } 1012 | 1013 | func (t *TextView) OnPasteEvent(event PasteEvent) bool { 1014 | return false 1015 | } 1016 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | // From https://github.com/rivo/tview/blob/master/util.go 2 | // Copyright (c) 2018 Oliver Kuederle 3 | // MIT license 4 | 5 | package mauview 6 | 7 | import ( 8 | "math" 9 | "regexp" 10 | "sort" 11 | "strconv" 12 | 13 | "github.com/mattn/go-runewidth" 14 | "github.com/rivo/uniseg" 15 | 16 | "github.com/gdamore/tcell/v2" 17 | ) 18 | 19 | // Text alignment within a box. 20 | const ( 21 | AlignLeft = iota 22 | AlignCenter 23 | AlignRight 24 | ) 25 | 26 | // Common regular expressions. 27 | var ( 28 | colorPattern = regexp.MustCompile(`\[([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([a-zA-Z]+|#[0-9a-zA-Z]{6}|\-)?(:([lbidrus]+|\-)?)?)?\]`) 29 | regionPattern = regexp.MustCompile(`\["([a-zA-Z0-9_,;: \-\.]*)"\]`) 30 | escapePattern = regexp.MustCompile(`\[([a-zA-Z0-9_,;: \-\."#]+)\[(\[*)\]`) 31 | nonEscapePattern = regexp.MustCompile(`(\[[a-zA-Z0-9_,;: \-\."#]+\[*)\]`) 32 | boundaryPattern = regexp.MustCompile(`(([,\.\-:;!\?&#+]|\n)[ \t\f\r]*|([ \t\f\r]+))`) 33 | spacePattern = regexp.MustCompile(`\s+`) 34 | ) 35 | 36 | // Positions of substrings in regular expressions. 37 | const ( 38 | colorForegroundPos = 1 39 | colorBackgroundPos = 3 40 | colorFlagPos = 5 41 | ) 42 | 43 | // Predefined InputField acceptance functions. 44 | var ( 45 | // InputFieldInteger accepts integers. 46 | InputFieldInteger func(text string, ch rune) bool 47 | 48 | // InputFieldFloat accepts floating-point numbers. 49 | InputFieldFloat func(text string, ch rune) bool 50 | 51 | // InputFieldMaxLength returns an input field accept handler which accepts 52 | // input strings up to a given length. Use it like this: 53 | // 54 | // inputField.SetAcceptanceFunc(InputFieldMaxLength(10)) // Accept up to 10 characters. 55 | InputFieldMaxLength func(maxLength int) func(text string, ch rune) bool 56 | ) 57 | 58 | // Package initialization. 59 | func init() { 60 | // Initialize the predefined input field handlers. 61 | InputFieldInteger = func(text string, ch rune) bool { 62 | if text == "-" { 63 | return true 64 | } 65 | _, err := strconv.Atoi(text) 66 | return err == nil 67 | } 68 | InputFieldFloat = func(text string, ch rune) bool { 69 | if text == "-" || text == "." || text == "-." { 70 | return true 71 | } 72 | _, err := strconv.ParseFloat(text, 64) 73 | return err == nil 74 | } 75 | InputFieldMaxLength = func(maxLength int) func(text string, ch rune) bool { 76 | return func(text string, ch rune) bool { 77 | return len([]rune(text)) <= maxLength 78 | } 79 | } 80 | } 81 | 82 | // styleFromTag takes the given style, defined by a foreground color (fgColor), 83 | // a background color (bgColor), and style attributes, and modifies it based on 84 | // the substrings (tagSubstrings) extracted by the regular expression for color 85 | // tags. The new colors and attributes are returned where empty strings mean 86 | // "don't modify" and a dash ("-") means "reset to default". 87 | func styleFromTag(fgColor, bgColor, attributes string, tagSubstrings []string) (newFgColor, newBgColor, newAttributes string) { 88 | if tagSubstrings[colorForegroundPos] != "" { 89 | color := tagSubstrings[colorForegroundPos] 90 | if color == "-" { 91 | fgColor = "-" 92 | } else if color != "" { 93 | fgColor = color 94 | } 95 | } 96 | 97 | if tagSubstrings[colorBackgroundPos-1] != "" { 98 | color := tagSubstrings[colorBackgroundPos] 99 | if color == "-" { 100 | bgColor = "-" 101 | } else if color != "" { 102 | bgColor = color 103 | } 104 | } 105 | 106 | if tagSubstrings[colorFlagPos-1] != "" { 107 | flags := tagSubstrings[colorFlagPos] 108 | if flags == "-" { 109 | attributes = "-" 110 | } else if flags != "" { 111 | attributes = flags 112 | } 113 | } 114 | 115 | return fgColor, bgColor, attributes 116 | } 117 | 118 | // overlayStyle calculates a new style based on "style" and applying tag-based 119 | // colors/attributes to it (see also styleFromTag()). 120 | func overlayStyle(style tcell.Style, fgColor, bgColor, attributes string) tcell.Style { 121 | _, _, defAttr := style.Decompose() 122 | 123 | if fgColor != "" && fgColor != "-" { 124 | style = style.Foreground(tcell.GetColor(fgColor)) 125 | } 126 | 127 | if bgColor != "" && bgColor != "-" { 128 | style = style.Background(tcell.GetColor(bgColor)) 129 | } 130 | 131 | if attributes == "-" { 132 | style = style.Bold(defAttr&tcell.AttrBold > 0). 133 | Italic(defAttr&tcell.AttrItalic > 0). 134 | Blink(defAttr&tcell.AttrBlink > 0). 135 | Reverse(defAttr&tcell.AttrReverse > 0). 136 | Underline(defAttr&tcell.AttrUnderline > 0). 137 | Dim(defAttr&tcell.AttrDim > 0) 138 | } else if attributes != "" { 139 | style = style.Normal() 140 | for _, flag := range attributes { 141 | switch flag { 142 | case 'l': 143 | style = style.Blink(true) 144 | case 'b': 145 | style = style.Bold(true) 146 | case 'i': 147 | style = style.Italic(true) 148 | case 'd': 149 | style = style.Dim(true) 150 | case 'r': 151 | style = style.Reverse(true) 152 | case 'u': 153 | style = style.Underline(true) 154 | case 's': 155 | style = style.StrikeThrough(true) 156 | } 157 | } 158 | } 159 | 160 | return style 161 | } 162 | 163 | // decomposeString returns information about a string which may contain color 164 | // tags or region tags, depending on which ones are requested to be found. It 165 | // returns the indices of the color tags (as returned by 166 | // re.FindAllStringIndex()), the color tags themselves (as returned by 167 | // re.FindAllStringSubmatch()), the indices of region tags and the region tags 168 | // themselves, the indices of an escaped tags (only if at least color tags or 169 | // region tags are requested), the string stripped by any tags and escaped, and 170 | // the screen width of the stripped string. 171 | func decomposeString(text string, findColors, findRegions bool) (colorIndices [][]int, colors [][]string, regionIndices [][]int, regions [][]string, escapeIndices [][]int, stripped string, width int) { 172 | // Shortcut for the trivial case. 173 | if !findColors && !findRegions { 174 | return nil, nil, nil, nil, nil, text, stringWidth(text) 175 | } 176 | 177 | // Get positions of any tags. 178 | if findColors { 179 | colorIndices = colorPattern.FindAllStringIndex(text, -1) 180 | colors = colorPattern.FindAllStringSubmatch(text, -1) 181 | } 182 | if findRegions { 183 | regionIndices = regionPattern.FindAllStringIndex(text, -1) 184 | regions = regionPattern.FindAllStringSubmatch(text, -1) 185 | } 186 | escapeIndices = escapePattern.FindAllStringIndex(text, -1) 187 | 188 | // Because the color pattern detects empty tags, we need to filter them out. 189 | for i := len(colorIndices) - 1; i >= 0; i-- { 190 | if colorIndices[i][1]-colorIndices[i][0] == 2 { 191 | colorIndices = append(colorIndices[:i], colorIndices[i+1:]...) 192 | colors = append(colors[:i], colors[i+1:]...) 193 | } 194 | } 195 | 196 | // Make a (sorted) list of all tags. 197 | allIndices := make([][3]int, 0, len(colorIndices)+len(regionIndices)+len(escapeIndices)) 198 | for indexType, index := range [][][]int{colorIndices, regionIndices, escapeIndices} { 199 | for _, tag := range index { 200 | allIndices = append(allIndices, [3]int{tag[0], tag[1], indexType}) 201 | } 202 | } 203 | sort.Slice(allIndices, func(i int, j int) bool { 204 | return allIndices[i][0] < allIndices[j][0] 205 | }) 206 | 207 | // Remove the tags from the original string. 208 | var from int 209 | buf := make([]byte, 0, len(text)) 210 | for _, indices := range allIndices { 211 | if indices[2] == 2 { // Escape sequences are not simply removed. 212 | buf = append(buf, []byte(text[from:indices[1]-2])...) 213 | buf = append(buf, ']') 214 | from = indices[1] 215 | } else { 216 | buf = append(buf, []byte(text[from:indices[0]])...) 217 | from = indices[1] 218 | } 219 | } 220 | buf = append(buf, text[from:]...) 221 | stripped = string(buf) 222 | 223 | // Get the width of the stripped string. 224 | width = stringWidth(stripped) 225 | 226 | return 227 | } 228 | 229 | // Print prints text onto the screen into the given box at (x,y,maxWidth,1), 230 | // not exceeding that box. "align" is one of AlignLeft, AlignCenter, or 231 | // AlignRight. The screen's background color will not be changed. 232 | // 233 | // You can change the colors and text styles mid-text by inserting a color tag. 234 | // See the package description for details. 235 | // 236 | // Returns the number of actual bytes of the text printed (including color tags) 237 | // and the actual width used for the printed runes. 238 | func Print(screen Screen, text string, x, y, maxWidth, align int, color tcell.Color) (int, int) { 239 | bytes, width, _, _ := printWithStyle(screen, text, x, y, 0, maxWidth, align, tcell.StyleDefault.Foreground(color), true) 240 | return bytes, width 241 | } 242 | 243 | func PrintWithStyle(screen Screen, text string, x, y, maxWidth, align int, style tcell.Style) (int, int) { 244 | bytes, width, _, _ := printWithStyle(screen, text, x, y, 0, maxWidth, align, style, true) 245 | return bytes, width 246 | } 247 | 248 | // printWithStyle works like Print() but it takes a style instead of just a 249 | // foreground color. The skipWidth parameter specifies the number of cells 250 | // skipped at the beginning of the text. It also returns the start and end index 251 | // (exclusively) of the text actually printed. If maintainBackground is "true", 252 | // The existing screen background is not changed (i.e. the style's background 253 | // color is ignored). 254 | func printWithStyle(screen Screen, text string, x, y, skipWidth, maxWidth, align int, style tcell.Style, maintainBackground bool) (int, int, int, int) { 255 | totalWidth, totalHeight := screen.Size() 256 | if maxWidth <= 0 || len(text) == 0 || y < 0 || y >= totalHeight { 257 | return 0, 0, 0, 0 258 | } 259 | 260 | // Decompose the text. 261 | colorIndices, colors, _, _, escapeIndices, strippedText, strippedWidth := decomposeString(text, true, false) 262 | 263 | // We want to reduce all alignments to AlignLeft. 264 | if align == AlignRight { 265 | if strippedWidth-skipWidth <= maxWidth { 266 | // There's enough space for the entire text. 267 | return printWithStyle(screen, text, x+maxWidth-strippedWidth+skipWidth, y, skipWidth, maxWidth, AlignLeft, style, maintainBackground) 268 | } 269 | // Trim characters off the beginning. 270 | var ( 271 | bytes, width, colorPos, escapePos, tagOffset, from, to int 272 | foregroundColor, backgroundColor, attributes string 273 | ) 274 | originalStyle := style 275 | iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { 276 | // Update color/escape tag offset and style. 277 | if colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] { 278 | foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos]) 279 | style = overlayStyle(originalStyle, foregroundColor, backgroundColor, attributes) 280 | tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0] 281 | colorPos++ 282 | } 283 | if escapePos < len(escapeIndices) && textPos+tagOffset >= escapeIndices[escapePos][0] && textPos+tagOffset < escapeIndices[escapePos][1] { 284 | tagOffset++ 285 | escapePos++ 286 | } 287 | if strippedWidth-screenPos <= maxWidth { 288 | // We chopped off enough. 289 | if escapePos > 0 && textPos+tagOffset-1 >= escapeIndices[escapePos-1][0] && textPos+tagOffset-1 < escapeIndices[escapePos-1][1] { 290 | // Unescape open escape sequences. 291 | escapeCharPos := escapeIndices[escapePos-1][1] - 2 292 | text = text[:escapeCharPos] + text[escapeCharPos+1:] 293 | } 294 | // Print and return. 295 | bytes, width, from, to = printWithStyle(screen, text[textPos+tagOffset:], x, y, 0, maxWidth, AlignLeft, style, maintainBackground) 296 | from += textPos + tagOffset 297 | to += textPos + tagOffset 298 | return true 299 | } 300 | return false 301 | }) 302 | return bytes, width, from, to 303 | } else if align == AlignCenter { 304 | if strippedWidth-skipWidth == maxWidth { 305 | // Use the exact space. 306 | return printWithStyle(screen, text, x, y, skipWidth, maxWidth, AlignLeft, style, maintainBackground) 307 | } else if strippedWidth-skipWidth < maxWidth { 308 | // We have more space than we need. 309 | half := (maxWidth - strippedWidth + skipWidth) / 2 310 | return printWithStyle(screen, text, x+half, y, skipWidth, maxWidth-half, AlignLeft, style, maintainBackground) 311 | } else { 312 | // Chop off runes until we have a perfect fit. 313 | var choppedLeft, choppedRight, leftIndex, rightIndex int 314 | rightIndex = len(strippedText) 315 | for rightIndex-1 > leftIndex && strippedWidth-skipWidth-choppedLeft-choppedRight > maxWidth { 316 | if skipWidth > 0 || choppedLeft < choppedRight { 317 | // Iterate on the left by one character. 318 | iterateString(strippedText[leftIndex:], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { 319 | if skipWidth > 0 { 320 | skipWidth -= screenWidth 321 | strippedWidth -= screenWidth 322 | } else { 323 | choppedLeft += screenWidth 324 | } 325 | leftIndex += textWidth 326 | return true 327 | }) 328 | } else { 329 | // Iterate on the right by one character. 330 | iterateStringReverse(strippedText[leftIndex:rightIndex], func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { 331 | choppedRight += screenWidth 332 | rightIndex -= textWidth 333 | return true 334 | }) 335 | } 336 | } 337 | 338 | // Add tag offsets and determine start style. 339 | var ( 340 | colorPos, escapePos, tagOffset int 341 | foregroundColor, backgroundColor, attributes string 342 | ) 343 | originalStyle := style 344 | for index := range strippedText { 345 | // We only need the offset of the left index. 346 | if index > leftIndex { 347 | // We're done. 348 | if escapePos > 0 && leftIndex+tagOffset-1 >= escapeIndices[escapePos-1][0] && leftIndex+tagOffset-1 < escapeIndices[escapePos-1][1] { 349 | // Unescape open escape sequences. 350 | escapeCharPos := escapeIndices[escapePos-1][1] - 2 351 | text = text[:escapeCharPos] + text[escapeCharPos+1:] 352 | } 353 | break 354 | } 355 | 356 | // Update color/escape tag offset. 357 | if colorPos < len(colorIndices) && index+tagOffset >= colorIndices[colorPos][0] && index+tagOffset < colorIndices[colorPos][1] { 358 | if index <= leftIndex { 359 | foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos]) 360 | style = overlayStyle(originalStyle, foregroundColor, backgroundColor, attributes) 361 | } 362 | tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0] 363 | colorPos++ 364 | } 365 | if escapePos < len(escapeIndices) && index+tagOffset >= escapeIndices[escapePos][0] && index+tagOffset < escapeIndices[escapePos][1] { 366 | tagOffset++ 367 | escapePos++ 368 | } 369 | } 370 | bytes, width, from, to := printWithStyle(screen, text[leftIndex+tagOffset:], x, y, 0, maxWidth, AlignLeft, style, maintainBackground) 371 | from += leftIndex + tagOffset 372 | to += leftIndex + tagOffset 373 | return bytes, width, from, to 374 | } 375 | } 376 | 377 | // Draw text. 378 | var ( 379 | drawn, drawnWidth, colorPos, escapePos, tagOffset, from, to int 380 | foregroundColor, backgroundColor, attributes string 381 | ) 382 | iterateString(strippedText, func(main rune, comb []rune, textPos, length, screenPos, screenWidth int) bool { 383 | // Skip character if necessary. 384 | if skipWidth > 0 { 385 | skipWidth -= screenWidth 386 | from = textPos + length 387 | to = from 388 | return false 389 | } 390 | 391 | // Only continue if there is still space. 392 | if drawnWidth+screenWidth > maxWidth || x+drawnWidth >= totalWidth { 393 | return true 394 | } 395 | 396 | // Handle color tags. 397 | for colorPos < len(colorIndices) && textPos+tagOffset >= colorIndices[colorPos][0] && textPos+tagOffset < colorIndices[colorPos][1] { 398 | foregroundColor, backgroundColor, attributes = styleFromTag(foregroundColor, backgroundColor, attributes, colors[colorPos]) 399 | tagOffset += colorIndices[colorPos][1] - colorIndices[colorPos][0] 400 | colorPos++ 401 | } 402 | 403 | // Handle escape tags. 404 | if escapePos < len(escapeIndices) && textPos+tagOffset >= escapeIndices[escapePos][0] && textPos+tagOffset < escapeIndices[escapePos][1] { 405 | if textPos+tagOffset == escapeIndices[escapePos][1]-2 { 406 | tagOffset++ 407 | escapePos++ 408 | } 409 | } 410 | 411 | // Memorize positions. 412 | to = textPos + length 413 | 414 | // Print the rune sequence. 415 | finalX := x + drawnWidth 416 | finalStyle := style 417 | if maintainBackground { 418 | _, _, existingStyle, _ := screen.GetContent(finalX, y) 419 | _, background, _ := existingStyle.Decompose() 420 | finalStyle = finalStyle.Background(background) 421 | } 422 | finalStyle = overlayStyle(finalStyle, foregroundColor, backgroundColor, attributes) 423 | for offset := screenWidth - 1; offset >= 0; offset-- { 424 | // To avoid undesired effects, we populate all cells. 425 | if offset == 0 { 426 | screen.SetContent(finalX+offset, y, main, comb, finalStyle) 427 | } else { 428 | screen.SetContent(finalX+offset, y, ' ', nil, finalStyle) 429 | } 430 | } 431 | 432 | // Advance. 433 | drawn += length 434 | drawnWidth += screenWidth 435 | 436 | return false 437 | }) 438 | 439 | return drawn + tagOffset + len(escapeIndices), drawnWidth, from, to 440 | } 441 | 442 | // PrintSimple prints white text to the screen at the given position. 443 | func PrintSimple(screen Screen, text string, x, y int) { 444 | Print(screen, text, x, y, math.MaxInt32, AlignLeft, Styles.PrimaryTextColor) 445 | } 446 | 447 | // TaggedStringWidth returns the width of the given string needed to print it on 448 | // screen. The text may contain color tags which are not counted. 449 | func TaggedStringWidth(text string) int { 450 | _, _, _, _, _, _, width := decomposeString(text, true, false) 451 | return width 452 | } 453 | 454 | // stringWidth returns the number of horizontal cells needed to print the given 455 | // text. It splits the text into its grapheme clusters, calculates each 456 | // cluster's width, and adds them up to a total. 457 | func stringWidth(text string) (width int) { 458 | g := uniseg.NewGraphemes(text) 459 | for g.Next() { 460 | var chWidth int 461 | for _, r := range g.Runes() { 462 | chWidth = runewidth.RuneWidth(r) 463 | if chWidth > 0 { 464 | break // Our best guess at this point is to use the width of the first non-zero-width rune. 465 | } 466 | } 467 | width += chWidth 468 | } 469 | return 470 | } 471 | 472 | // WordWrap splits a text such that each resulting line does not exceed the 473 | // given screen width. Possible split points are after any punctuation or 474 | // whitespace. Whitespace after split points will be dropped. 475 | // 476 | // This function considers color tags to have no width. 477 | // 478 | // Text is always split at newline characters ('\n'). 479 | func WordWrap(text string, width int) (lines []string) { 480 | colorTagIndices, _, _, _, escapeIndices, strippedText, _ := decomposeString(text, true, false) 481 | 482 | // Find candidate breakpoints. 483 | breakpoints := boundaryPattern.FindAllStringSubmatchIndex(strippedText, -1) 484 | // Results in one entry for each candidate. Each entry is an array a of 485 | // indices into strippedText where a[6] < 0 for newline/punctuation matches 486 | // and a[4] < 0 for whitespace matches. 487 | 488 | // Process stripped text one character at a time. 489 | var ( 490 | colorPos, escapePos, breakpointPos, tagOffset int 491 | lastBreakpoint, lastContinuation, currentLineStart int 492 | lineWidth, overflow int 493 | forceBreak bool 494 | ) 495 | unescape := func(substr string, startIndex int) string { 496 | // A helper function to unescape escaped tags. 497 | for index := escapePos; index >= 0; index-- { 498 | if index < len(escapeIndices) && startIndex > escapeIndices[index][0] && startIndex < escapeIndices[index][1]-1 { 499 | pos := escapeIndices[index][1] - 2 - startIndex 500 | return substr[:pos] + substr[pos+1:] 501 | } 502 | } 503 | return substr 504 | } 505 | iterateString(strippedText, func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool { 506 | // Handle tags. 507 | for { 508 | if colorPos < len(colorTagIndices) && textPos+tagOffset >= colorTagIndices[colorPos][0] && textPos+tagOffset < colorTagIndices[colorPos][1] { 509 | // Colour tags. 510 | tagOffset += colorTagIndices[colorPos][1] - colorTagIndices[colorPos][0] 511 | colorPos++ 512 | } else if escapePos < len(escapeIndices) && textPos+tagOffset == escapeIndices[escapePos][1]-2 { 513 | // Escape tags. 514 | tagOffset++ 515 | escapePos++ 516 | } else { 517 | break 518 | } 519 | } 520 | 521 | // Is this a breakpoint? 522 | if breakpointPos < len(breakpoints) && textPos+tagOffset == breakpoints[breakpointPos][0] { 523 | // Yes, it is. Set up breakpoint infos depending on its type. 524 | lastBreakpoint = breakpoints[breakpointPos][0] + tagOffset 525 | lastContinuation = breakpoints[breakpointPos][1] + tagOffset 526 | overflow = 0 527 | forceBreak = main == '\n' 528 | if breakpoints[breakpointPos][6] < 0 && !forceBreak { 529 | lastBreakpoint++ // Don't skip punctuation. 530 | } 531 | breakpointPos++ 532 | } 533 | 534 | // Check if a break is warranted. 535 | if forceBreak || lineWidth > 0 && lineWidth+screenWidth > width { 536 | breakpoint := lastBreakpoint 537 | continuation := lastContinuation 538 | if forceBreak { 539 | breakpoint = textPos + tagOffset 540 | continuation = textPos + tagOffset + 1 541 | lastBreakpoint = 0 542 | overflow = 0 543 | } else if lastBreakpoint <= currentLineStart { 544 | breakpoint = textPos + tagOffset 545 | continuation = textPos + tagOffset 546 | overflow = 0 547 | } 548 | lines = append(lines, unescape(text[currentLineStart:breakpoint], currentLineStart)) 549 | currentLineStart, lineWidth, forceBreak = continuation, overflow, false 550 | } 551 | 552 | // Remember the characters since the last breakpoint. 553 | if lastBreakpoint > 0 && lastContinuation <= textPos+tagOffset { 554 | overflow += screenWidth 555 | } 556 | 557 | // Advance. 558 | lineWidth += screenWidth 559 | 560 | // But if we're still inside a breakpoint, skip next character (whitespace). 561 | if textPos+tagOffset < currentLineStart { 562 | lineWidth -= screenWidth 563 | } 564 | 565 | return false 566 | }) 567 | 568 | // Flush the rest. 569 | if currentLineStart < len(text) { 570 | lines = append(lines, unescape(text[currentLineStart:], currentLineStart)) 571 | } 572 | 573 | return 574 | } 575 | 576 | // Escape escapes the given text such that color and/or region tags are not 577 | // recognized and substituted by the print functions of this package. For 578 | // example, to include a tag-like string in a box title or in a TextView: 579 | // 580 | // box.SetTitle(tview.Escape("[squarebrackets]")) 581 | // fmt.Fprint(textView, tview.Escape(`["quoted"]`)) 582 | func Escape(text string) string { 583 | return nonEscapePattern.ReplaceAllString(text, "$1[]") 584 | } 585 | 586 | // iterateString iterates through the given string one printed character at a 587 | // time. For each such character, the callback function is called with the 588 | // Unicode code points of the character (the first rune and any combining runes 589 | // which may be nil if there aren't any), the starting position (in bytes) 590 | // within the original string, its length in bytes, the screen position of the 591 | // character, and the screen width of it. The iteration stops if the callback 592 | // returns true. This function returns true if the iteration was stopped before 593 | // the last character. 594 | func iterateString(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool) bool { 595 | var screenPos int 596 | 597 | gr := uniseg.NewGraphemes(text) 598 | for gr.Next() { 599 | r := gr.Runes() 600 | from, to := gr.Positions() 601 | width := stringWidth(gr.Str()) 602 | var comb []rune 603 | if len(r) > 1 { 604 | comb = r[1:] 605 | } 606 | 607 | if callback(r[0], comb, from, to-from, screenPos, width) { 608 | return true 609 | } 610 | 611 | screenPos += width 612 | } 613 | 614 | return false 615 | } 616 | 617 | // iterateStringReverse iterates through the given string in reverse, starting 618 | // from the end of the string, one printed character at a time. For each such 619 | // character, the callback function is called with the Unicode code points of 620 | // the character (the first rune and any combining runes which may be nil if 621 | // there aren't any), the starting position (in bytes) within the original 622 | // string, its length in bytes, the screen position of the character, and the 623 | // screen width of it. The iteration stops if the callback returns true. This 624 | // function returns true if the iteration was stopped before the last character. 625 | func iterateStringReverse(text string, callback func(main rune, comb []rune, textPos, textWidth, screenPos, screenWidth int) bool) bool { 626 | type cluster struct { 627 | main rune 628 | comb []rune 629 | textPos, textWidth, screenPos, screenWidth int 630 | } 631 | 632 | // Create the grapheme clusters. 633 | var clusters []cluster 634 | iterateString(text, func(main rune, comb []rune, textPos int, textWidth int, screenPos int, screenWidth int) bool { 635 | clusters = append(clusters, cluster{ 636 | main: main, 637 | comb: comb, 638 | textPos: textPos, 639 | textWidth: textWidth, 640 | screenPos: screenPos, 641 | screenWidth: screenWidth, 642 | }) 643 | return false 644 | }) 645 | 646 | // Iterate in reverse. 647 | for index := len(clusters) - 1; index >= 0; index-- { 648 | if callback( 649 | clusters[index].main, 650 | clusters[index].comb, 651 | clusters[index].textPos, 652 | clusters[index].textWidth, 653 | clusters[index].screenPos, 654 | clusters[index].screenWidth, 655 | ) { 656 | return true 657 | } 658 | } 659 | 660 | return false 661 | } 662 | 663 | // stripTags strips colour tags from the given string. (Region tags are not 664 | // stripped.) 665 | func stripTags(text string) string { 666 | stripped := colorPattern.ReplaceAllStringFunc(text, func(match string) string { 667 | if len(match) > 2 { 668 | return "" 669 | } 670 | return match 671 | }) 672 | return escapePattern.ReplaceAllString(stripped, `[$1$2]`) 673 | } 674 | --------------------------------------------------------------------------------