├── .appenv ├── README.md ├── TODO.md ├── examples ├── dialog.py ├── form.py ├── git-commit.py ├── helloworld.py └── mail.py ├── setup.py └── src └── py └── urwide.py /.appenv: -------------------------------------------------------------------------------- 1 | appenv_declare urwide 2 | appenv_prepend PYTHONPATH $APPENV_DIR/src/py 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ``` 2 | ____ _____________ __ __.___________ ___________ 3 | | | \______ \/ \ / \ \______ \ \_ _____/ 4 | | | /| _/\ \/\/ / || | \ | __)_ 5 | | | / | | \ \ /| || ` \| \ 6 | |______/ |____|_ / \__/\ / |___/_______ /_______ / 7 | \/ \/ \/ \/ 8 | ``` 9 | 10 | Introduction 11 | ============ 12 | 13 | [URWID](http://www.urwid.org) is a powerful library that allows you to write command-line 14 | interfaces in the Python language. While URWID is very powerful, it is 15 | quite low-level compared to existing UI toolkits, which can make development 16 | of more advanced user interface a bit difficult. 17 | 18 | The main idea behind URWIDE is to extend URWID with a *domain-specific language 19 | to describe console-based interfaces*, drastically reducing the amount of code 20 | required to create console-based applications. 21 | 22 | URWIDE's declarative, text-based UI description language supports: 23 | 24 | - MVC-like architecture 25 | - Custom stylesheets 26 | - Event handling (key, focus, press, edit) 27 | - I18N (string collections) 28 | 29 | To give you an idea of what URWIDE can provide, here is a very simple 30 | `helloworld.py` example: 31 | 32 | ```python 33 | import urwide 34 | 35 | # This is the equivalent of a CSS stylesheet 36 | CONSOLE_STYLE = """ 37 | Frame : Dg, _, SO 38 | header : WH, DC, BO 39 | """ 40 | 41 | # This is the description of the actual interface 42 | CONSOLE_UI = """\ 43 | Hdr URWIDE Hello world 44 | ___ 45 | 46 | Txt Hello World ! args:#txt_hello 47 | GFl 48 | Btn [Hello !] &press=hello 49 | Btn [Bye !] &press=bye 50 | End 51 | """ 52 | 53 | # This is the handling code, providing the logic 54 | class Handler(urwide.Handler): 55 | def onHello( self, button ): 56 | self.ui.widgets.txt_hello.set_text("You said hello !") 57 | def onBye( self, button ): 58 | self.ui.end("bye !") 59 | 60 | # We create a console application 61 | urwide.Console().create(CONSOLE_STYLE, CONSOLE_UI, Handler()).main() 62 | ``` 63 | 64 | UI Description Language 65 | ======================= 66 | 67 | URWIDE allows to describe a user interface using a very simple line-oriented 68 | language. You can define a complete UI in just a few lines. This description 69 | allows you to: 70 | 71 | - Identify your widgets with a unique name 72 | - Associate detailed information and tooltip 73 | - Bind style information to your widgets 74 | - Bind event handlers 75 | 76 | The description syntax is very simple, and simply consists of a set of lines of 77 | the following form: 78 | 79 | ``` 80 | CLS DATA? [ID|STYLE|INFO|EVENT]* [ARGUMENTS] 81 | ``` 82 | 83 | as an example, here is the definition of a button with `Click me!` as label, 84 | which will be available as `btn_click`, displayed using the `clickButton` 85 | style, displaying the `CLICK` tooltip when focused, and calling the 86 | `clicked` callback when pressed : 87 | 88 | ``` 89 | Btn [Click me!] #btn_click @clickButton !CLICK &press=clicked 90 | ``` 91 | 92 | To sum up the available attributes: 93 | 94 | - *CLS* is a three letter code that corresponds to the widget code 95 | - *DATA* is a widget-specific text content 96 | - *ID* sets the identifier of the widget 97 | - *STYLE* sets the style class of the widget 98 | - *EVENT* defines an event handler attached to the widget 99 | - *INFO* defines the widget tooltip and detailed information 100 | - *ARGUMENTS* defines additional widget attributes 101 | 102 | - Widget identifier 103 | 104 | ``` 105 | #id 106 | ``` 107 | 108 | - Widget style class 109 | 110 | ``` 111 | @style 112 | ``` 113 | 114 | - Widget tooltip 115 | 116 | ``` 117 | !TEXT 118 | ``` 119 | 120 | - Widget info 121 | 122 | ``` 123 | ?TEXT 124 | ``` 125 | 126 | - Event handling 127 | 128 | ``` 129 | &event=method 130 | ``` 131 | 132 | Supported events: 133 | 134 | - `focus` 135 | - `edit` 136 | - `key` 137 | 138 | - Python arguments 139 | 140 | ``` 141 | name=value, name=value, name=value 142 | ``` 143 | 144 | - Comments 145 | 146 | ``` 147 | # This is a comment 148 | ``` 149 | 150 | Comments are useful to annotate your URWIDE source code, or to enable/disable 151 | parts of it. Comments are simply lines starting with the `#` character. 152 | 153 | Blocks 154 | ------ 155 | 156 | ``` 157 | Ple 158 | Txt I am within the above pile 159 | End 160 | ``` 161 | 162 | or 163 | 164 | ``` 165 | GFl 166 | Txt Here are buttons 167 | Btn [previous] 168 | Btn [next] 169 | End 170 | ``` 171 | 172 | *SYNTAX* | *DESCRIPTION* 173 | ------------------|:----------------------------------------------------- 174 | `#name` | Widget name, makes it accessible as `ui.widgets.name` 175 | `@class` | Style class associated with the widget. 176 | `&event=callback` | Makes the `onCallback` method of the `ui.handler()` react to the `event` (press, key, edit, focus) when it occurs on the widget. 177 | `!TOOLTIP` | `ui.strings.TOOLTIP` or `"TOOLTIP"` is used as a tooltip for the widget (when it is focused) 178 | `?INFO` | `ui.strings.INFO` or `"INFO"` is used as information for the widget (when it is focused) `arg=value, ...` Additional Python arguments that will be passed to the widget constructor (eg. `multiline=true` for Edit) 179 | `# comment` | a comment line that will be ignored when parsing 180 | 181 | Supported Widgets 182 | ================= 183 | 184 | URWIDE tries to support most used URWID widgets, and also introduces _pseudo 185 | widgets_ that facilitate the specification of your application layout. 186 | 187 | Blank 188 | ----- 189 | 190 | ``` 191 | EOL 192 | ``` 193 | 194 | A blank widget is simply an _empty line_ within the UI description. 195 | 196 | Divider 197 | ------- 198 | 199 | ``` 200 | --- 201 | === 202 | ::: 203 | ``` 204 | 205 | These three forms create dividers composed of respectively `-`, `=` and `:` 206 | characters. In case you will want a particular pattern in your divider, you 207 | can user the following form: 208 | 209 | ``` 210 | Dvd ~-~- 211 | ``` 212 | 213 | Which will make you a divider composed of `~-~-`. 214 | 215 | 216 | Text 217 | ---- 218 | 219 | ``` 220 | Txt TEXT 221 | Txt TEXT args:ARGUMENTS 222 | ``` 223 | 224 | Examples 225 | 226 | ``` 227 | Txt Hello, I'm a text 228 | Txt Hello, I'm a text args:align='left' 229 | ``` 230 | 231 | Note _________________________________________________________________ 232 | Be sure to use the `args:` prefix to give arguments to the text, because 233 | otherwise your arguments will be interpreted as being part of the 234 | displayed text. 235 | 236 | Button 237 | ------ 238 | 239 | ``` 240 | Btn [LABEL] 241 | ``` 242 | 243 | Choice 244 | ------ 245 | 246 | ``` 247 | Chc [ :group] I am an unselected option 248 | Chc [X:group] I am a selected option 249 | Chc [X:other] I am a selected option from the 'other' group 250 | Chc [ :group] I am an unselected option args:#my_choice 251 | ``` 252 | 253 | A choice is composed of: 254 | 255 | - Its _state_ and _group_ represented by the leading '[S:GROUP]', where 'S' 256 | is either ' ' or 'X' and 'GROUP' is any string. Groups are availabled in 257 | as 'ui.groups.GROUP' ('ui.groups' is a 'UI.Collection' instance) 258 | 259 | - Its _label_, following the state and group definition. It can be free-form 260 | text. 261 | 262 | - The _ui arguments_, optionally following the label, but prefixed by 263 | 'args:' 264 | 265 | Pile 266 | ---- 267 | 268 | ``` 269 | Ple 270 | ... 271 | End 272 | ``` 273 | 274 | Gridflow 275 | -------- 276 | 277 | ``` 278 | Gfl 279 | ... 280 | End 281 | ``` 282 | 283 | Box 284 | --- 285 | 286 | ``` 287 | Box border=1 288 | ... 289 | End 290 | ``` 291 | 292 | Boxes allow to draw a border around a widget. You can simply indicate the 293 | size of the border using the `border` attribute. 294 | 295 | Columns 296 | ------- 297 | 298 | ``` 299 | Col 300 | *** 301 | End 302 | ``` 303 | 304 | Summary 305 | ------- 306 | 307 | *CODE* | *WIDGET* |*TYPE* 308 | -------|:-------------------|:------------------------------------------ 309 | `Txt` | Text | widget 310 | `Edt` | Edit | widget 311 | `Btn` | Button | widget 312 | `Chc` | RadioButton | widget 313 | `Dvd` | Divider | widget 314 | `Ple` | Pile | container 315 | `GFl` | GridFlow | container 316 | `Box` | Box (not in URWID) | container 317 | 318 | Event handling 319 | ============== 320 | 321 | URWIDE provides support for handling events and binding event handlers to each 322 | individual widget. The events currently supported are: 323 | 324 | - `focus` (any), which is triggered when the widget received focus 325 | - `key` (any), which is triggered when a key is pressed on a widget 326 | - `edit` (Edit), which is triggered after an Edit was edited 327 | - `press` (Buttons, CheckBox), which is triggered when a button is pressed 328 | 329 | Events are handled by _handlers_, which are objects that define methods that 330 | implement a particular reaction. For instance, if you have an event named 331 | `showHelp`, you handler class will be like that: 332 | 333 | ``` 334 | class MyHandler(urwide.Handler): 335 | 336 | def onShowHelp( self, widget ): 337 | # Do something here 338 | ``` 339 | 340 | And then, if you want to trigger the "`showHelp`" event when a button is 341 | pressed: 342 | 343 | ``` 344 | Btn [Show help] &press=showHelp 345 | ``` 346 | 347 | This will automatically make the binding between the ui and the handler, 348 | provided that you register your handler into the ui: 349 | 350 | ``` 351 | ui.handler(MyHandler()) 352 | ``` 353 | 354 | Collections 355 | =========== 356 | 357 | URWIDE will create an instance of the `urwide.UI` class when given a style (will 358 | be presented later) and a UI description. This instance will take care of 359 | everything for you, from events to widgets. You will generally only want to 360 | access or modify the `widgets` and `strings` collections. 361 | 362 | Both collections can be edited by accessing values as attribute. Setting an 363 | attribute will add a key within the collection, accessing it will return the 364 | bound value, or raise an exception if the value was not found. 365 | 366 | ``` 367 | ui.strings.SOME_TEXT = "This text can be used as a value in a widget" 368 | 369 | ui.widgets 370 | ``` 371 | 372 | 373 | Style syntax 374 | ============ 375 | 376 | ``` 377 | [STYLE] : FG, BG, FN 378 | ``` 379 | 380 | - _STYLE_ is the name of the style 381 | - _FG_ is the foreground color 382 | - _BG_ is the backgrond color 383 | - _FN_ is the font style 384 | 385 | A style name can be: 386 | 387 | - _URWID widget name_ (`Edit`, `Text`, etc) 388 | - _style name_ (defined by `@style` in the widgets list) 389 | - _widget id_, as defined by the `#id` of the UI 390 | 391 | Focus styles can be specified by appending `*` to each style name: 392 | 393 | ``` 394 | Edit : BL, _, SO 395 | Edit* : DM, Lg, SO 396 | ``` 397 | 398 | means that all `Edit` widgets will have black as color when unfocused, and dark 399 | magenta when focused. 400 | 401 | Here is a table that sums up the possible values that can be used to describe 402 | the styles. These values are described in the URWID reference for the 403 | [Screen](http://excess.org/urwid/reference.html#Screen-register_palette_entry) 404 | class. 405 | 406 | *CODE* | *VALUE* |*FOREGROUND*|*BACKGROUND*| *FONT* 407 | -------|:--------------|:-----------|:-----------|:------------------- 408 | WH | white | yes | no | - 409 | BL | black | no | yes | - 410 | YL | yellow | yes | no | - 411 | BR | brown | yes | no | - 412 | DR | dark red | no | yes | - 413 | DB | dark blue | yes | yes | - 414 | DG | dark green | yes | yes | - 415 | DM | dark magenta | yes | yes | - 416 | DC | dark cyan | yes | yes | - 417 | Dg | dark gray | yes | no | - 418 | LR | light red | yes | no | - 419 | LG | light green | yes | no | - 420 | LB | light blue | yes | no | - 421 | LM | light magenta | yes | no | - 422 | LC | light cyan | yes | no | - 423 | Lg | light gray | yes | yes | - 424 | BO | bold | - | - | yes 425 | UL | underline | - | - | yes 426 | SO | standout | - | - | yes 427 | _ | default | yes | yes | yes 428 | 429 | Using dialogs: 430 | 431 | ```python 432 | dialog = Dialog() 433 | # Don't know why this is necessary, but it doesn't work if it's not there 434 | sialog.handler(dialog_handler) 435 | self.pushHandler(dialog_handler) 436 | 437 | def dialog_end(): 438 | self.popHandler() 439 | ``` 440 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | * [ ] Support HEX colors in the style 2 | * [ ] Custom color definition 3 | * [ ] HTML, RAW and Web output/rendering 4 | * [ ] Dynamic re-parsing and rendering of the interface (dynamic lists, etc) 5 | * [ ] Support for textencoding and termencoding, so that the text is displayed 6 | properly. 7 | -------------------------------------------------------------------------------- /examples/dialog.py: -------------------------------------------------------------------------------- 1 | import urwide, os 2 | 3 | # Defines the style and user interface 4 | CONSOLE_STYLE = """ 5 | Frame : Lg, DB, SO 6 | header : WH, DC, BO 7 | shade : DC, DB, BO 8 | 9 | label : WH, DB, SO 10 | dialog : BL, Lg, SO 11 | shadow : WH, BL, SO 12 | shadow.border : BL, _, SO 13 | 14 | Edit : BL, DB, BO 15 | Edit* : DM, Lg, BO 16 | Button : WH, DC, BO 17 | Button* : WH, DM, BO 18 | Divider : Lg, DB, SO 19 | """ 20 | 21 | DIALOG_STYLE = """ 22 | header : BL, Lg, BO 23 | """ 24 | 25 | CONSOLE_UI = """\ 26 | Hdr URWIDE Dialog example 27 | ___ 28 | 29 | Txt Please select any of these buttons to pop-up a dialog ! 30 | GFl 31 | Btn [Alert] #btn_alert &press=alert 32 | Btn [Ask] #btn_ask &press=ask 33 | Btn [Choose] #btn_choose &press=choose 34 | End 35 | 36 | Ftr Press any button to pop up a dialog 37 | """ 38 | 39 | ALERT_UI = """\ 40 | Hdr Alert dialog 41 | 42 | Txt This is an alert box, display the message you want here 43 | 44 | GFl 45 | # And do not forget to provide a buttong with an exit handler 46 | Btn [OK] #btn_end 47 | End 48 | """ 49 | 50 | ASK_UI = """\ 51 | Hdr Ask dialog 52 | 53 | Txt Please respond to this question 54 | Edt [your answer] 55 | 56 | GFl 57 | # And do not forget to provide a buttong with an exit handler 58 | Btn [OK] #btn_end 59 | Btn [Cancel] #btn_cancel 60 | End 61 | """ 62 | 63 | # Event handler 64 | class Handler(urwide.Handler): 65 | 66 | def onAlert( self, button ): 67 | dialog = urwide.Dialog(self.ui, ui=ALERT_UI,palette=DIALOG_STYLE, height=10) 68 | dialog.onPress(dialog.widgets.btn_end, lambda b:dialog.end()) 69 | self.ui.dialog(dialog) 70 | 71 | def onCancel( self, button ): 72 | self.ui.info("Cancel") 73 | self.exit() 74 | 75 | def onAsk( self, button ): 76 | dialog = urwide.Dialog(self.ui, ui=ASK_UI,palette=DIALOG_STYLE, height=10) 77 | dialog.onPress(dialog.widgets.btn_end, lambda b:dialog.end()) 78 | self.ui.dialog(dialog) 79 | 80 | # Defines strings referenced in the UI 81 | ui = urwide.Console().create(CONSOLE_STYLE, CONSOLE_UI, Handler()) 82 | 83 | # Main 84 | if __name__ == "__main__": 85 | ui.main() 86 | 87 | # EOF 88 | -------------------------------------------------------------------------------- /examples/form.py: -------------------------------------------------------------------------------- 1 | import urwide, sys, json 2 | 3 | # Defines the style and user interface 4 | CONSOLE_STYLE = """ 5 | Frame : Dg, DB, SO 6 | header : WH, DC, BO 7 | shade : DC, DB, BO 8 | 9 | label : Lg, DB, SO 10 | 11 | Edit : WH, DB, BO 12 | Edit* : DM, Lg, BO 13 | Button : WH, DC, BO 14 | Button* : WH, DM, BO 15 | Divider : Lg, DB, SO 16 | 17 | #subject : DM, DB, SO 18 | """ 19 | 20 | CONSOLE_UI = """\ 21 | Hdr Form 22 | 23 | {0} 24 | 25 | GFl 26 | Btn [Cancel] #btn_cancel &press=cancel 27 | Btn [Save] #btn_save &press=save 28 | End 29 | """ 30 | 31 | # This is a dynamic list of fields 32 | FIELDS = [ 33 | {"id": "project", "label": "Project name"}, 34 | {"id": "client", "client": "Client name"}, 35 | {"type": "separator"}, 36 | {"id": "description", "client": "Client name", "multiline": True}, 37 | ] 38 | 39 | 40 | def create_fields(fields=FIELDS): 41 | res = [] 42 | labels_len = max(len(_.get("label", _.get("id", ""))) for _ in fields) 43 | for f in fields: 44 | if f.get("type") == "separator": 45 | res.append("---") 46 | else: 47 | id = f.get("id") 48 | ID = id.upper() 49 | label = f.get("label", id).capitalize() 50 | label = label + " " * (labels_len - len(label)) 51 | placeholder = f.get("placeholder", "") 52 | attributes = [] 53 | if f.get("multiline"): 54 | res.append("Txt {label}".format(label=label)) 55 | field = "Edt [{placeholder}] #{id} ?{ID} {attributes}".format( 56 | id=id, 57 | ID=ID, 58 | label=label, 59 | placeholder=placeholder, 60 | attributes=" ".join(attributes), 61 | ) 62 | else: 63 | field = "Edt {label} [{placeholder}] #{id} ?{ID} {attributes}".format( 64 | id=id, 65 | ID=ID, 66 | label=label, 67 | placeholder=placeholder, 68 | attributes=" ".join(attributes), 69 | ) 70 | res.append(field) 71 | return "\n".join(res) 72 | 73 | 74 | # Event handler 75 | class Handler(urwide.Handler): 76 | def onSave(self, button): 77 | self.ui.info("Saving") 78 | res = {} 79 | for field in FIELDS: 80 | fid = field.get("id") 81 | if not fid: 82 | continue 83 | res[fid] = getattr(self.ui.widgets, fid).get_edit_text() 84 | # NOTE: We write the files to `form.json` 85 | with open("form.json", "w") as f: 86 | json.dump(res, f) 87 | self.exit() 88 | 89 | def onCancel(self, button): 90 | self.ui.info("Cancel") 91 | self.exit() 92 | 93 | def exit(self): 94 | sys.exit() 95 | 96 | 97 | # Defines strings referenced in the UI 98 | ui = urwide.Console() 99 | ui.create(CONSOLE_STYLE, CONSOLE_UI.format(create_fields()), Handler()) 100 | 101 | # Main 102 | if __name__ == "__main__": 103 | ui.main() 104 | 105 | # EOF 106 | -------------------------------------------------------------------------------- /examples/git-commit.py: -------------------------------------------------------------------------------- 1 | from urwide import ui 2 | 3 | app = ui( 4 | """\ 5 | Hdr URWIDE - Git Commit $COMMIT 6 | 7 | Box 8 | Edt Name [$USERNAME] #edit_user 9 | Edt Tags [Update] #edit_tags ?TAGS &key=tag 10 | Edt Scope [‥] #edit_scope ?SCOPE &key=scope 11 | Edt Summary [‥] #edit_summary ?SUMMARY &key=sumUp 12 | End 13 | Dvd ┄┄┄ 14 | 15 | Box 16 | Edt [‥] #edit_desc ?DESC &key=describe multiline=True 17 | End 18 | 19 | Dvd ――― 20 | 21 | Ple #changes 22 | End 23 | Dvd ――― 24 | 25 | GFl 26 | Btn [Cancel] #btn_cancel &press=cancel 27 | Btn [Commit] #btn_commit &press=commit 28 | End 29 | """, 30 | """ 31 | header : BL, WH, SO 32 | Edit : Dg, _, BO 33 | Edit* : WH, DB, BO 34 | """, 35 | USERNAME="Joe", 36 | COMMIT="asdsadasdasass", 37 | ) 38 | app.run() 39 | -------------------------------------------------------------------------------- /examples/helloworld.py: -------------------------------------------------------------------------------- 1 | import urwide, os 2 | 3 | # Defines the style and user interface 4 | CONSOLE_STYLE = """ 5 | Frame : Dg, _, SO 6 | header : WH, DC, BO 7 | """ 8 | 9 | CONSOLE_UI = """\ 10 | Hdr URWIDE Hello world 11 | ___ 12 | 13 | Txt Hello World ! args:#txt_hello 14 | GFl 15 | Btn [Hello !] &press=hello 16 | Btn [Bye !] &press=bye 17 | End 18 | """ 19 | 20 | # Event handler 21 | class Handler(urwide.Handler): 22 | def onHello(self, button): 23 | self.ui.widgets.txt_hello.set_text("You said hello !") 24 | 25 | def onBye(self, button): 26 | self.ui.end("bye !") 27 | 28 | 29 | urwide.Console().create(CONSOLE_STYLE, CONSOLE_UI, Handler()).main() 30 | 31 | 32 | # EOF 33 | -------------------------------------------------------------------------------- /examples/mail.py: -------------------------------------------------------------------------------- 1 | import urwide, sys 2 | 3 | # Defines the style and user interface 4 | CONSOLE_STYLE = """ 5 | Frame : Dg, DB, SO 6 | header : WH, DC, BO 7 | shade : DC, DB, BO 8 | 9 | label : Lg, DB, SO 10 | 11 | Edit : WH, DB, BO 12 | Edit* : DM, Lg, BO 13 | Button : WH, DC, BO 14 | Button* : WH, DM, BO 15 | Divider : Lg, DB, SO 16 | 17 | #subject : DM, DB, SO 18 | """ 19 | 20 | CONSOLE_UI = """\ 21 | Hdr URWIDE Mail Editor 22 | ::: @shade 23 | 24 | Edt From [%s] #from ?FROM 25 | Edt To [List of recipients] #to ?TO 26 | --- 27 | Edt Subject [Subject] #subject ?SUBJECT 28 | --- 29 | 30 | Box border=1 31 | Edt [Content] #content &edit=changeContent multiline=True 32 | End 33 | 34 | === 35 | GFl 36 | Btn [Cancel] #btn_cancel &press=cancel 37 | Btn [Save] #btn_save &press=save 38 | Btn [Send] #btn_commit &press=send 39 | End 40 | """ % ( 41 | "me" 42 | ) 43 | 44 | 45 | # Event handler 46 | class Handler(urwide.Handler): 47 | def onSave(self, button): 48 | self.ui.info("Saving") 49 | 50 | def onCancel(self, button): 51 | self.ui.info("Cancel") 52 | self.exit() 53 | 54 | def onSend(self, button): 55 | self.ui.info("Send") 56 | 57 | def onChangeContent(self, widget, oldtext, newtext): 58 | if oldtext != newtext: 59 | self.ui.info("Email content changed !") 60 | 61 | def exit(self): 62 | sys.exit() 63 | 64 | 65 | # Defines strings referenced in the UI 66 | ui = urwide.Console() 67 | ui.strings.FROM = "Your email address" 68 | ui.strings.TO = "Comma separated list of recipient adresses" 69 | ui.strings.SUBJECT = "The subject for your email" 70 | ui.create(CONSOLE_STYLE, CONSOLE_UI, Handler()) 71 | 72 | # Main 73 | if __name__ == "__main__": 74 | ui.main() 75 | 76 | # EOF 77 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf8 3 | # ----------------------------------------------------------------------------- 4 | # Project : URWIDE 5 | # ----------------------------------------------------------------------------- 6 | # Author : Sebastien Pierre 7 | # License : Revised BSD License 8 | # ----------------------------------------------------------------------------- 9 | # Creation : 31-Jul-2017 10 | # Last mod : 31-Jul-2017 11 | # ----------------------------------------------------------------------------- 12 | 13 | import sys ; sys.path.insert(0, "src") 14 | from distutils.core import setup 15 | 16 | SUMMARY = "High-Level interface and DSL to create console interfaces" 17 | DESCRIPTION = """\ 18 | The main idea behind URWIDE is to extend URWID with a *domain-specific language 19 | to describe console-based interfaces*, drastically reducing the amount of code 20 | required to create console-based applications. 21 | """ 22 | # ------------------------------------------------------------------------------ 23 | # 24 | # SETUP DECLARATION 25 | # 26 | # ------------------------------------------------------------------------------ 27 | 28 | setup( 29 | name = "urwide", 30 | version = "0.2.1", 31 | author = "Sebastien Pierre", author_email = "sebastien.pierre@gmail.com", 32 | description = SUMMARY, long_description = DESCRIPTION, 33 | license = "Revised BSD License", 34 | keywords = "tool, interface, gui, command-line", 35 | url = "https://github.com/sebastien/urwide", 36 | package_dir = { "": "src" }, 37 | py_modules = ["urwide"], 38 | ) 39 | 40 | # EOF - vim: tw=80 ts=4 sw=4 noet 41 | 42 | -------------------------------------------------------------------------------- /src/py/urwide.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # encoding: utf8 3 | # ----------------------------------------------------------------------------- 4 | # Project : URWIDE - Extended URWID 5 | # ----------------------------------------------------------------------------- 6 | # Author : Sébastien Pierre 7 | # License : Lesser GNU Public License http://www.gnu.org/licenses/lgpl.html> 8 | # ----------------------------------------------------------------------------- 9 | # Creation : 14-07-2006 10 | # Last mod : 15-12-2016 11 | # ----------------------------------------------------------------------------- 12 | 13 | import sys, string, re, curses 14 | import urwid, urwid.raw_display, urwid.curses_display 15 | from urwid.widget import ( 16 | FLOW, 17 | FIXED, 18 | PACK, 19 | BOX, 20 | GIVEN, 21 | WEIGHT, 22 | LEFT, 23 | RIGHT, 24 | RELATIVE, 25 | TOP, 26 | BOTTOM, 27 | CLIP, 28 | RELATIVE_100, 29 | ) 30 | 31 | __version__ = "0.2.1" 32 | __doc__ = """\ 33 | URWIDE provides a nice wrapper around the awesome URWID Python library. It 34 | enables the creation of complex console user-interfaces, using an easy to use 35 | API . 36 | 37 | URWIDE provides a simple notation to describe text-based UIs, and also provides 38 | extensions to support events, tooltips, dialogs as well as other goodies for 39 | every URWID widget. 40 | 41 | URWID can be downloaded at . 42 | """ 43 | 44 | RE_COLOR = re.compile( 45 | "^#[A-Fa-f0-9][A-Fa-f0-9][A-Fa-f0-9][A-Fa-f0-9][A-Fa-f0-9][A-Fa-f0-9]$" 46 | ) 47 | 48 | COLORS = { 49 | # Colors 50 | "WH": "white", 51 | "BL": "black", 52 | "YL": "yellow", 53 | "BR": "brown", 54 | "LR": "light red", 55 | "LG": "light green", 56 | "LB": "light blue", 57 | "LC": "light cyan", 58 | "LM": "light magenta", 59 | "Lg": "light gray", 60 | "DR": "dark red", 61 | "DG": "dark green", 62 | "DB": "dark blue", 63 | "DC": "dark cyan", 64 | "DM": "dark magenta", 65 | "Dg": "dark gray", 66 | # Font attributes 67 | "BO": "bold", 68 | "SO": "standout", 69 | "UL": "underline", 70 | "_": "default", 71 | } 72 | RIGHT = "right" 73 | LEFT = "left" 74 | CENTER = "center" 75 | 76 | 77 | def ensureString(t, encoding="utf8") -> str: 78 | return t if isinstance(t, str) else str(t, encoding) 79 | 80 | 81 | def add_widget(container, widget, options=None): 82 | w = widget 83 | if isinstance(container, urwid.Pile): 84 | # See: urwid.container.py Pile.__init__ 85 | w = widget 86 | if not isinstance(w, tuple): 87 | container.contents.append((w, (WEIGHT, 1))) 88 | elif w[0] in (FLOW, PACK): 89 | f, w = w 90 | container.contents.append((w, (PACK, None))) 91 | elif len(w) == 2: 92 | height, w = w 93 | container.contents.append((w, (GIVEN, height))) 94 | elif w[0] == FIXED: # backwards compatibility 95 | _ignore, height, w = w 96 | container.contents.append((w, (GIVEN, height))) 97 | elif w[0] == WEIGHT: 98 | f, height, w = w 99 | container.contents.append((w, (f, height))) 100 | else: 101 | raise ValueError("Widget not as expected: {0}".format(widget)) 102 | else: 103 | container.contents.append(widget) 104 | 105 | 106 | def remove_widgets(container): 107 | w = [_ for _ in container.contents] 108 | for _ in w: 109 | container.contents.remove(_) 110 | 111 | 112 | def original_widgets(widget): 113 | if widget: 114 | stack = [widget] 115 | if len(stack) > 0: 116 | while hasattr(stack[0], "original_widget"): 117 | original = stack[0].original_widget 118 | if original not in stack: 119 | stack.insert(0, original) 120 | else: 121 | break 122 | return stack 123 | else: 124 | return [] 125 | 126 | 127 | def original_widget(widget): 128 | r = original_widgets(widget) 129 | return r[0] if r else widget 130 | 131 | 132 | def original_focus(widget): 133 | w = original_widgets(widget) 134 | for _ in w: 135 | if hasattr(_, "focus"): 136 | return _.focus 137 | return w[0] 138 | 139 | 140 | # ------------------------------------------------------------------------------ 141 | # 142 | # URWID Patching 143 | # 144 | # ------------------------------------------------------------------------------ 145 | 146 | 147 | class PatchedListBox(urwid.ListBox): 148 | 149 | _parent = None 150 | 151 | def __init__(self, *args, **kwargs): 152 | super().__init__(*args, **kwargs) 153 | 154 | def remove_widgets(self): 155 | """Remove all widgets from the body.""" 156 | if isinstance(self.body, SimpleListWalker): 157 | self.body = SimpleListWalker([]) 158 | else: 159 | raise Exception("Method only supported for SimpleListWalker") 160 | 161 | def add_widget(self, widget): 162 | """Adds a widget to the body of this list box.""" 163 | if isinstance(self.body, SimpleListWalker): 164 | self.body.contents.append(widget) 165 | else: 166 | raise Exception("Method only supported for SimpleListWalker") 167 | 168 | 169 | class PatchedPile(urwid.Pile): 170 | def __init__(self, widget_list, focus_item=None): 171 | # No need to call the constructor 172 | # super(PatchedPile, self).__init__(widget_list, focus_item) 173 | urwid.Pile.__init__(self, widget_list, focus_item) 174 | self.widget_list = [] 175 | self.item_types = [] 176 | for _ in widget_list: 177 | add_widget(self, _) 178 | if focus_item: 179 | self.set_focus(focus_item) 180 | self.pref_col = None 181 | 182 | def add_widget(self, widget): 183 | """Adds a widget to this pile""" 184 | w = widget 185 | self.widget_list.append(widget) 186 | if type(w) != type(()): 187 | self.item_types.append(("weight", 1)) 188 | elif w[0] == "flow": 189 | f, widget = w 190 | self.widget_list[i] = widget 191 | self.item_types.append((f, None)) 192 | elif w[0] in ("fixed", "weight"): 193 | f, height, widget = w 194 | self.widget_list[i] = widget 195 | self.item_types.append((f, height)) 196 | else: 197 | raise PileError("widget list item invalid %s" % (w)) 198 | 199 | def remove_widget(self, widget): 200 | """Removes a widget from this pile""" 201 | if type(widget) != type(()): 202 | widget = widget[1] 203 | i = self.widget_list.index(widget) 204 | del self.widget_list[i] 205 | del self.item_types[i] 206 | 207 | def remove_widgets(self): 208 | """Removes all widgets from this pile""" 209 | self.widget_list = [] 210 | self.item_types = [] 211 | 212 | 213 | class PatchedColumns(urwid.Columns): 214 | def set_focus(self, widget): 215 | """Set the column in focus with a widget in self.widget_list.""" 216 | if type(widget) != int: 217 | position = self.widget_list.index(widget) 218 | else: 219 | position = widget 220 | self.focus_col = position 221 | 222 | 223 | # TODO: Why is this there? 224 | # urwid.Pile = PatchedPile 225 | # urwid.ListBox = PatchedListBox 226 | # urwid.Columns = PatchedColumns 227 | 228 | # ------------------------------------------------------------------------------ 229 | # 230 | # UI CLASS 231 | # 232 | # ------------------------------------------------------------------------------ 233 | 234 | 235 | class UISyntaxError(Exception): 236 | pass 237 | 238 | 239 | class UIRuntimeError(Exception): 240 | pass 241 | 242 | 243 | class UI: 244 | """The UI class allows to build an URWID user-interface from a simple set of 245 | string definitions. 246 | 247 | Instantiation of this class, may raise syntax error if the given text data 248 | is not formatted as expected, but you can easily get detailed information on 249 | what the problem was.""" 250 | 251 | BLANK = urwid.Text("") 252 | EMPTY = urwid.Text("") 253 | NOP = lambda self: self 254 | 255 | class Collection(object): 256 | """Keys of the given collection are recognized as attributes.""" 257 | 258 | def __init__(self, collection=None): 259 | object.__init__(self) 260 | if collection == None: 261 | collection = {} 262 | self.w_w_content = collection 263 | 264 | def __getattr__(self, name): 265 | if name.startswith("w_w_"): 266 | return super(UI.Collection, self).__getattribute__(name) 267 | else: 268 | w = self.w_w_content 269 | if name not in w: 270 | raise UIRuntimeError("No widget with name: " + name) 271 | return w[name] 272 | 273 | def __setattr__(self, name, value): 274 | if name.startswith("w_w_"): 275 | return super(UI.Collection, self).__setattr__(name, value) 276 | else: 277 | if name in self.w_w_content: 278 | raise SyntaxError("Item name already used: " + name) 279 | self.w_w_content[name] = value 280 | 281 | def __init__(self): 282 | """Creates a new user interface object from the given text 283 | description.""" 284 | self._content = None 285 | self._stack = None 286 | self._currentLine = None 287 | self._ui = None 288 | self._palette = None 289 | self._header = None 290 | self._currentSize = None 291 | self._widgets = {} 292 | self._groups = {} 293 | self._strings = {} 294 | self._data = {} 295 | self._handlers = [] 296 | self.widgets = UI.Collection(self._widgets) 297 | self.groups = UI.Collection(self._groups) 298 | self.strings = UI.Collection(self._strings) 299 | self.data = UI.Collection(self._data) 300 | 301 | def id(self, widget): 302 | """Returns the id for the given widget.""" 303 | if hasattr(widget, "_urwideId"): 304 | return widget._urwideId 305 | else: 306 | return None 307 | 308 | def new(self, widgetClass, *args, **kwargs): 309 | """Creates the given widget by instantiating @widgetClass with the given 310 | args and kwargs. Basically, this is equivalent to 311 | 312 | > return widgetClass(*kwargs['args'], **kwargs['kwargs']) 313 | 314 | Excepted that the widget is wrapped in an `urwid.AttrWrap` object, with the 315 | proper attributes. Also, the given @kwargs are preprocessed before being 316 | forwarded to the widget: 317 | 318 | - `data` is the text data describing ui attributes, constructor args 319 | and kwargs (in the same format as the text UI description) 320 | 321 | - `ui`, `args` and `kwargs` allow to pass preprocessed data to the 322 | constructor. 323 | 324 | In all cases, if you want to pass args and kwargs, you should 325 | explicitly use the `args` and `kwargs` arguments. I know that this is a 326 | bit confusing...""" 327 | return self._createWidget(widgetClass, *args, **kwargs) 328 | 329 | def wrap(self, widget, properties): 330 | """Wraps the given in the given properties.""" 331 | _ui, _, _ = self._parseAttributes(properties) 332 | return self._wrapWidget(widget, _ui) 333 | 334 | def unwrap(self, widget): 335 | """Unwraps the widget (see `new` method).""" 336 | if isinstance(widget, urwid.AttrWrap): 337 | if widget.w: 338 | widget = widget.w 339 | return widget 340 | 341 | # EVENT HANDLERS 342 | # ------------------------------------------------------------------------- 343 | 344 | def handler(self, handler=None): 345 | """Sets/Gets the current event handler. 346 | 347 | This modifies the 'handler.ui' and sets it to this ui.""" 348 | if handler == None: 349 | if not self._handlers: 350 | raise UIRuntimeError("No handler defined for: {self}") 351 | return self._handlers[-1][0] 352 | else: 353 | old_ui = handler.ui 354 | handler.ui = self 355 | if not self._handlers: 356 | self._handlers.append((handler, old_ui)) 357 | else: 358 | self._handlers[-1] = (handler, old_ui) 359 | 360 | def responder(self, event): 361 | """Returns the function that responds to the given event.""" 362 | return self.handler().responder(event) 363 | 364 | def pushHandler(self, handler): 365 | """Push a new handler on the list of handlers. This handler will handle 366 | events until it is popped out or replaced.""" 367 | self._handlers.append((handler, handler.ui)) 368 | handler.ui = self 369 | 370 | def popHandler(self): 371 | """Pops the current handler of the list of handlers. The handler will 372 | not handle events anymore, while the previous handler will start to 373 | handle events.""" 374 | handler, ui = self._handlers.pop() 375 | handler.ui = ui 376 | 377 | def _handle(self, event_name, widget, *args, **kwargs): 378 | """Handle the given given event name.""" 379 | # If the event is an event name, we use the handler mechanism 380 | if type(event_name) in (str,): 381 | handler = self.handler() 382 | if handler.responds(event_name): 383 | return handler.respond(event_name, widget, *args, **kwargs) 384 | elif hasattr(widget, event_name): 385 | getattr(widget, event_name, *args, **kwargs) 386 | else: 387 | raise UIRuntimeError( 388 | "No handler for event: %s in %s" % (event_name, widget) 389 | ) 390 | # Otherwise we assume it is a callback 391 | else: 392 | return event_name(widget, *args, **kwargs) 393 | 394 | def setTooltip(self, widget, tooltip): 395 | widget._urwideTooltip = tooltip 396 | 397 | def setInfo(self, widget, info): 398 | widget._urwideInfo = info 399 | 400 | def onKey(self, widget, callback): 401 | """Sets a callback to the given widget for the 'key' event""" 402 | widget = self.unwrap(widget) 403 | widget._urwideOnKey = callback 404 | 405 | def onFocus(self, widget, callback): 406 | """Sets a callback to the given widget for the 'focus' event""" 407 | widget = self.unwrap(widget) 408 | widget._urwideOnFocus = callback 409 | 410 | def onEdit(self, widget, callback): 411 | """Sets a callback to the given widget for the 'edit' event""" 412 | widget = self.unwrap(widget) 413 | widget._urwideOnEdit = callback 414 | 415 | def onPress(self, widget, callback): 416 | """Sets a callback to the given widget for the 'edit' event""" 417 | widget = self.unwrap(widget) 418 | widget._urwideOnPress = callback 419 | 420 | def _doPress(self, button, *args): 421 | if hasattr(button, "_urwideOnPress"): 422 | event_name = button._urwideOnPress 423 | self._handle(event_name, button, *args) 424 | elif isinstance(button, urwid.RadioButton): 425 | return False 426 | else: 427 | raise UIRuntimeError( 428 | "Widget does not respond to press event: %s" % (button) 429 | ) 430 | 431 | def _doFocus(self, widget, ensure=True): 432 | if hasattr(widget, "_urwideOnFocus"): 433 | event_name = widget._urwideOnFocus 434 | self._handle(event_name, widget) 435 | elif ensure: 436 | raise UIRuntimeError( 437 | "Widget does not respond to focus event: %s" % (widget) 438 | ) 439 | 440 | def _doEdit(self, widget, before, after, ensure=True): 441 | if hasattr(widget, "_urwideOnEdit"): 442 | event_name = widget._urwideOnEdit 443 | self._handle(event_name, widget, before, after) 444 | elif ensure: 445 | raise UIRuntimeError("Widget does not respond to focus edit: %s" % (widget)) 446 | 447 | def _doKeyPress(self, widget, key): 448 | # THE RULES 449 | # --------- 450 | # 451 | # 1) Widget defines an onKey event handler, it is triggered 452 | # 2) If the handler returned False, or was not existent, we 453 | # forward to the top widget 454 | # 3) The onKeyPress event is handled by the keyPress handler if the 455 | # focused widget is not editable 456 | # 4) If no keyPresss handler is defined, the default key_press event is 457 | # handled 458 | topwidget = self.getToplevel() 459 | current_widget = widget 460 | # We traverse the `original_widget` in case the widgets are nested. 461 | # This allows to get the deepest widget. 462 | stack = original_widgets(widget) 463 | # FIXME: Dialogs should prevent processing of events at a lower level 464 | if stack: 465 | for widget in stack: 466 | if hasattr(widget, "_urwideOnKey"): 467 | event_name = widget._urwideOnKey 468 | if self._handle(event_name, widget, key): 469 | return 470 | if current_widget != topwidget and current_widget not in stack: 471 | self._doKeyPress(topwidget, key) 472 | else: 473 | self._doKeyPress(None, key) 474 | elif widget and widget != topwidget: 475 | self._doKeyPress(topwidget, key) 476 | else: 477 | if key == "tab": 478 | self.focusNext() 479 | elif key == "shift tab": 480 | self.focusPrevious() 481 | if self.isEditable(self.getFocused()): 482 | res = False 483 | else: 484 | try: 485 | res = self._handle("keyPress", topwidget, key) 486 | except UIRuntimeError: 487 | res = False 488 | if res is False: 489 | topwidget.keypress(self._currentSize, key) 490 | 491 | def getFocused(self): 492 | raise Exception("Must be implemented by subclasses") 493 | 494 | def focusNext(self): 495 | raise Exception("Must be implemented by subclasses") 496 | 497 | def focusPrevious(self): 498 | raise Exception("Must be implemented by subclasses") 499 | 500 | def getToplevel(self): 501 | raise Exception("Must be implemented by subclasses") 502 | 503 | def isEditable(self, widget): 504 | if isinstance(widget, urwid.Edit): 505 | return True 506 | elif isinstance(widget, urwid.IntEdit): 507 | return True 508 | else: 509 | return False 510 | 511 | def isFocusable(self, widget): 512 | if isinstance(widget, urwid.Edit): 513 | return True 514 | elif isinstance(widget, urwid.IntEdit): 515 | return True 516 | elif isinstance(widget, urwid.Button): 517 | return True 518 | elif isinstance(widget, urwid.CheckBox): 519 | return True 520 | elif isinstance(widget, urwid.RadioButton): 521 | return True 522 | else: 523 | return False 524 | 525 | # PARSING WIDGETS STACK MANAGEMENT 526 | # ------------------------------------------------------------------------- 527 | 528 | def _add(self, widget): 529 | """Adds the given widget to the @_content list. This list will be 530 | added to the current parent widget when the UI is finished or when an 531 | `End` block is encountered (see @_push and @_pop)""" 532 | # Piles cannot be created with [] as content, so we fill them with the 533 | # EMPTY widget, which is replaced whenever we add something 534 | if self._content == [self.EMPTY]: 535 | self._content[0] = widget 536 | self._content.append(widget) 537 | 538 | def _push(self, endCallback, ui=None, args=(), kwargs={}): 539 | """Pushes the given arguments (@ui, @args, @kwargs) on the stack, 540 | together with the @endCallback which will be invoked with the given 541 | arguments when an `End` block will be encountered (and that a @_pop is 542 | triggered).""" 543 | self._stack.append((self._content, endCallback, ui, args, kwargs)) 544 | self._content = [] 545 | return self._content 546 | 547 | def _pop(self): 548 | """Pops out the widget on the top of the stack and invokes the 549 | _callback_ previously associated with it (using @_push).""" 550 | previous_content = self._content 551 | self._content, end_callback, end_ui, end_args, end_kwargs = self._stack.pop() 552 | return previous_content, end_callback, end_ui, end_args, end_kwargs 553 | 554 | # GENERIC PARSING METHODS 555 | # ------------------------------------------------------------------------- 556 | 557 | def create(self, style, ui, handler=None): 558 | self.parseStyle(style) 559 | self.parseUI(ui) 560 | if handler: 561 | self.handler(handler) 562 | return self 563 | 564 | def parseUI(self, text): 565 | """Parses the given text and initializes this user interface object.""" 566 | try: 567 | text = string.Template(text).substitute(self._strings) 568 | except KeyError as e: 569 | raise RuntimeError(f"Missing string template value: {e}") 570 | self._content = [] 571 | self._stack = [] 572 | self._currentLine = 0 573 | for line in text.split("\n"): 574 | line = line.strip() 575 | if not line.startswith("#"): 576 | self._parseLine(line) 577 | self._currentLine += 1 578 | self._listbox = self._createWidget(urwid.ListBox, self._content) 579 | return self._content 580 | 581 | def parseStyle(self, data): 582 | """Parses the given style.""" 583 | res = [] 584 | for line in data.split("\n"): 585 | if not line.strip(): 586 | continue 587 | line = line.replace("\t", " ").replace(" ", " ") 588 | name, attributes = [_.strip() for _ in line.split(":")] 589 | res_line = [name] 590 | for attribute in attributes.split(","): 591 | attribute = attribute.strip() 592 | if RE_COLOR.match(attribute): 593 | color = attribute 594 | elif attribute in COLORS: 595 | color = COLORS.get(attribute) 596 | else: 597 | raise UISyntaxError("Unsupported color: " + attribute) 598 | res_line.append(color) 599 | if not len(res_line) == 4: 600 | raise UISyntaxError("Expected NAME: FOREGROUND BACKGROUND FONT") 601 | res.append(tuple(res_line)) 602 | print("STYLE", res) 603 | self._palette = res 604 | return res 605 | 606 | RE_LINE = re.compile(r"^\s*(...)\s?") 607 | 608 | def _parseLine(self, line): 609 | """Parses a line of the UI definition file. This automatically invokes 610 | the specialized parsers.""" 611 | if not line: 612 | self._add(self.BLANK) 613 | return 614 | match = self.RE_LINE.match(line) 615 | if not match: 616 | raise UISyntaxError("Unrecognized line: " + line) 617 | name = match.group(1) 618 | data = line[match.end() :] 619 | if hasattr(self, "_parse" + name): 620 | getattr(self, "_parse" + name)(data) 621 | elif name[0] == name[1] == name[2]: 622 | self._parseDvd(name + data) 623 | else: 624 | raise UISyntaxError("Unrecognized widget: `" + name + "`") 625 | 626 | def _parseAttributes(self, data): 627 | assert type(data) in (str,) 628 | ui_attrs, data = self._parseUIAttributes(data) 629 | args, kwargs = self._parseArguments(data) 630 | return ui_attrs, args, kwargs 631 | 632 | RE_UI_ATTRIBUTE = re.compile(r"\s*([#@\?\:]|\&[\w]+\=)([\w\d_\-]+)\s*") 633 | 634 | def _parseUIAttributes(self, data): 635 | """Parses the given UI attributes from the data and returns the rest of 636 | the data (which corresponds to something else thatn the UI 637 | attributes.""" 638 | assert type(data) in (str,) 639 | ui = {"events": {}} 640 | while True: 641 | match = self.RE_UI_ATTRIBUTE.match(data) 642 | if not match: 643 | break 644 | ui_type, ui_value = match.groups() 645 | assert type(ui_value) in (str,) 646 | if ui_type == "#": 647 | ui["id"] = ui_value 648 | elif ui_type == "@": 649 | ui["style"] = ui_value 650 | elif ui_type == "?": 651 | ui["info"] = ui_value 652 | elif ui_type == "!": 653 | ui["tooltip"] = ui_value 654 | elif ui_type[0] == "&": 655 | ui["events"][ui_type[1:-1]] = ui_value 656 | data = data[match.end() :] 657 | return ui, data 658 | 659 | def _parseArguments(self, data): 660 | """Parses the given text data which should be a list of attributes. This 661 | returns a dict with the attributes.""" 662 | assert type(data) in (str,) 663 | 664 | def as_dict(*args, **kwargs): 665 | return args, kwargs 666 | 667 | res = eval("as_dict(%s)" % (data)) 668 | try: 669 | res = eval("as_dict(%s)" % (data)) 670 | except: 671 | raise SyntaxError("Malformed arguments: " + repr(data)) 672 | return res 673 | 674 | def hasStyle(self, *styles): 675 | for s in styles: 676 | for r in self._palette: 677 | if r[0] == s: 678 | return s 679 | return False 680 | 681 | def _styleWidget(self, widget, ui): 682 | """Wraps the given widget so that it belongs to the given style.""" 683 | styles = [] 684 | if "id" in ui: 685 | styles.append("#" + ui["id"]) 686 | if "style" in ui: 687 | s = ui["style"] 688 | if type(s) in (tuple, list): 689 | styles.extend(s) 690 | else: 691 | styles.append(s) 692 | styles.append(widget.__class__.__name__) 693 | unf_styles = [_ for _ in styles if self.hasStyle(_)] 694 | foc_styles = [_ + "*" for _ in styles if self.hasStyle(_ + "*")] 695 | if unf_styles: 696 | if foc_styles: 697 | return urwid.AttrWrap(widget, unf_styles[0], foc_styles[0]) 698 | else: 699 | return urwid.AttrWrap(widget, unf_styles[0]) 700 | else: 701 | return widget 702 | 703 | def _createWidget(self, widgetClass, *args, **kwargs): 704 | """Creates the given widget by instantiating @widgetClass with the given 705 | args and kwargs. Basically, this is equivalent to 706 | 707 | > return widgetClass(*kwargs['args'], **kwargs['kwargs']) 708 | 709 | Excepted that the widget is wrapped in an `urwid.AttrWrap` object, with the 710 | proper attributes. Also, the given @kwargs are preprocessed before being 711 | forwarded to the widget: 712 | 713 | - `data` is the text data describing ui attributes, constructor args 714 | and kwargs (in the same format as the text UI description) 715 | 716 | - `ui`, `args` and `kwargs` allow to pass preprocessed data to the 717 | constructor. 718 | 719 | In all cases, if you want to pass args and kwargs, you should 720 | explicitly use the `args` and `kwargs` arguments. I know that this is a 721 | bit confusing...""" 722 | _data = _ui = _args = _kwargs = None 723 | for arg, value in kwargs.items(): 724 | if arg == "data": 725 | _data = value 726 | elif arg == "ui": 727 | _ui = value 728 | elif arg == "args": 729 | _args = value 730 | elif arg == "kwargs": 731 | _kwargs = value 732 | else: 733 | raise Exception("Unrecognized optional argument: " + arg) 734 | if _data: 735 | _ui, _args, _kwargs = self._parseAttributes(_data) 736 | args = list(args) 737 | if _args: 738 | args.extend(_args) 739 | kwargs = _kwargs or {} 740 | widget = widgetClass(*args, **kwargs) 741 | return self._wrapWidget(widget, _ui) 742 | 743 | def _wrapWidget(self, widget, _ui): 744 | """Wraps the given widget into anotger widget, and applies the various 745 | properties listed in the '_ui' (internal structure).""" 746 | # And now we process the ui information 747 | if not _ui: 748 | _ui = {} 749 | if "id" in _ui: 750 | setattr(self.widgets, _ui["id"], widget) 751 | widget._urwideId = _ui["id"] 752 | if _ui.get("events"): 753 | for event, handler in _ui["events"].items(): 754 | if event == "press": 755 | if not isinstance(widget, urwid.Button) and not isinstance( 756 | widget, urwid.RadioButton 757 | ): 758 | raise UISyntaxError( 759 | "Press event only applicable to Button: " + repr(widget) 760 | ) 761 | widget._urwideOnPress = handler 762 | elif event == "edit": 763 | if not isinstance(widget, urwid.Edit): 764 | raise UISyntaxError( 765 | "Edit event only applicable to Edit: " + repr(widget) 766 | ) 767 | widget._urwideOnEdit = handler 768 | elif event == "focus": 769 | widget._urwideOnFocus = handler 770 | elif event == "key": 771 | widget._urwideOnKey = handler 772 | else: 773 | raise UISyntaxError("Unknown event type: " + event) 774 | if _ui.get("info"): 775 | widget._urwideInfo = _ui["info"] 776 | if _ui.get("tooltip"): 777 | widget._urwideTooltip = _ui["tooltip"] 778 | res = self._styleWidget(widget, _ui) 779 | return res 780 | 781 | # WIDGET-SPECIFIC METHODS 782 | # ------------------------------------------------------------------------- 783 | 784 | def _argsFind(self, data): 785 | args = data.find("args:") 786 | if args == -1: 787 | attr = "" 788 | else: 789 | attr = data[args + 5 :] 790 | data = data[:args] 791 | return attr, data 792 | 793 | def _parseTxt(self, data): 794 | attr, data = self._argsFind(data) 795 | ui, args, kwargs = self._parseAttributes(attr) 796 | self._add(self._createWidget(urwid.Text, data, ui=ui, args=args, kwargs=kwargs)) 797 | 798 | def _parseHdr(self, data): 799 | if self._header != None: 800 | raise UISyntaxError("Header can occur only once") 801 | attr, data = self._argsFind(data) 802 | ui, args, kwargs = self._parseAttributes(attr) 803 | ui.setdefault("style", "header") 804 | self._header = self._createWidget( 805 | urwid.Text, data, ui=ui, args=args, kwargs=kwargs 806 | ) 807 | 808 | RE_BTN = re.compile(r"\s*\[([^\]]+)\]") 809 | 810 | def _parseBtn(self, data): 811 | match = self.RE_BTN.match(data) 812 | if not match: 813 | raise SyntaxError("Malformed button: " + repr(data)) 814 | data = data[match.end() :] 815 | self._add( 816 | self._createWidget(urwid.Button, match.group(1), self._doPress, data=data) 817 | ) 818 | 819 | RE_CHC = re.compile(r"\s*\[([xX ])\:(\w+)\](.+)") 820 | 821 | def _parseChc(self, data): 822 | attr, data = self._argsFind(data) 823 | # Parses the declaration 824 | match = self.RE_CHC.match(data) 825 | if not match: 826 | raise SyntaxError("Malformed choice: " + repr(data)) 827 | state = not (match.group(1) == " ") 828 | group = group_name = match.group(2).strip() 829 | group = self._groups.setdefault(group, []) 830 | assert self._groups[group_name] == group 831 | assert getattr(self.groups, group_name) == group 832 | label = match.group(3) 833 | # Parses the attributes 834 | ui, args, kwargs = self._parseAttributes(attr) 835 | # Creates the widget 836 | self._add( 837 | self._createWidget( 838 | urwid.RadioButton, 839 | group, 840 | label, 841 | state, 842 | self._doPress, 843 | ui=ui, 844 | args=args, 845 | kwargs=kwargs, 846 | ) 847 | ) 848 | 849 | def _parseDvd(self, data): 850 | ui, args, kwargs = self._parseAttributes(data[3:]) 851 | self._add( 852 | self._createWidget(urwid.Divider, data, ui=ui, args=args, kwargs=kwargs) 853 | ) 854 | 855 | def _parseBox(self, data): 856 | def end(content, ui=None, **kwargs): 857 | if not content: 858 | content = [self.EMPTY] 859 | if len(content) == 1: 860 | w = content[0] 861 | else: 862 | w = self._createWidget(urwid.Pile, content) 863 | border = kwargs.get("border") or 1 864 | w = self._createWidget( 865 | urwid.Padding, w, ("fixed left", border), ("fixed right", border) 866 | ) 867 | # TODO: Filler does not work 868 | # w = self._createWidget(urwid.Filler, w, ('fixed top', border), ('fixed bottom', border) ) 869 | # w = urwid.Filler(w, ('fixed top', 1), ('fixed bottom',1)) 870 | self._add(w) 871 | 872 | ui, args, kwargs = self._parseAttributes(data) 873 | self._push(end, ui=ui, args=args, kwargs=kwargs) 874 | 875 | RE_EDT = re.compile(r"([^\[]*)\[([^\]]*)\]") 876 | 877 | def _parseEdt(self, data): 878 | match = self.RE_EDT.match(data) 879 | data = data[match.end() :] 880 | label, text = match.groups() 881 | ui, args, kwargs = self._parseAttributes(data) 882 | if label and self.hasStyle("label"): 883 | label = ("label", label) 884 | if label: 885 | self._add( 886 | self._createWidget( 887 | urwid.Edit, label, text, ui=ui, args=args, kwargs=kwargs 888 | ) 889 | ) 890 | else: 891 | self._add( 892 | self._createWidget( 893 | urwid.Edit, label, text, ui=ui, args=args, kwargs=kwargs 894 | ) 895 | ) 896 | 897 | def _parsePle(self, data): 898 | def end(content, ui=None, **kwargs): 899 | if not content: 900 | content = [self.EMPTY] 901 | self._add(self._createWidget(urwid.Pile, content, ui=ui, kwargs=kwargs)) 902 | 903 | ui, args, kwargs = self._parseAttributes(data) 904 | self._push(end, ui=ui, args=args, kwargs=kwargs) 905 | 906 | def _parseCol(self, data): 907 | def end(content, ui=None, **kwargs): 908 | if not content: 909 | content = [self.EMPTY] 910 | self._add(self._createWidget(urwid.Columns, content, ui=ui, kwargs=kwargs)) 911 | 912 | ui, args, kwargs = self._parseAttributes(data) 913 | self._push(end, ui=ui, args=args, kwargs=kwargs) 914 | 915 | def _parseGFl(self, data): 916 | def end(content, ui=None, **kwargs): 917 | max_width = 0 918 | # Gets the maximum width for the content 919 | for widget in content: 920 | if hasattr(widget, "get_text"): 921 | max_width = max(len(widget.get_text()), max_width) 922 | if hasattr(widget, "get_label"): 923 | max_width = max(len(widget.get_label()), max_width) 924 | kwargs.setdefault("cell_width", max_width + 4) 925 | kwargs.setdefault("h_sep", 1) 926 | kwargs.setdefault("v_sep", 1) 927 | kwargs.setdefault("align", "center") 928 | self._add(self._createWidget(urwid.GridFlow, content, ui=ui, kwargs=kwargs)) 929 | 930 | ui, args, kwargs = self._parseAttributes(data) 931 | self._push(end, ui=ui, args=args, kwargs=kwargs) 932 | 933 | def _parseLBx(self, data): 934 | def end(content, ui=None, **kwargs): 935 | self._add(self._createWidget(urwid.ListBox, content, ui=ui, kwargs=kwargs)) 936 | 937 | ui, args, kwargs = self._parseAttributes(data) 938 | self._push(end, ui=ui, args=args, kwargs=kwargs) 939 | 940 | def _parseEnd(self, data): 941 | if data.strip(): 942 | raise UISyntaxError("End takes no argument: " + repr(data)) 943 | # We get the end callback that will instantiate the widget and add it to 944 | # the content. 945 | if not self._stack: 946 | raise SyntaxError("End called without container widget") 947 | end_content, end_callback, end_ui, end_args, end_kwargs = self._pop() 948 | end_callback(end_content, end_ui, *end_args, **end_kwargs) 949 | 950 | 951 | # ------------------------------------------------------------------------------ 952 | # 953 | # CONSOLE CLASS 954 | # 955 | # ------------------------------------------------------------------------------ 956 | 957 | 958 | class Console(UI): 959 | """The console class allows to create console applications that work 'full 960 | screen' within a terminal.""" 961 | 962 | def __init__(self): 963 | UI.__init__(self) 964 | self._ui = None 965 | self._frame = None 966 | self._header = None 967 | self._footer = None 968 | self._listbox = None 969 | self._dialog = None 970 | self._tooltiptext = "" 971 | self._infotext = "" 972 | self._footertext = "" 973 | self.isRunning = False 974 | self.endMessage = "" 975 | self.endStatus = 1 976 | 977 | # USER INTERACTION API 978 | # ------------------------------------------------------------------------- 979 | 980 | def tooltip(self, text=-1): 981 | """Sets/Gets the current tooltip text.""" 982 | if text == -1: 983 | return self._tooltiptext 984 | else: 985 | self._tooltiptext = ensureString(text) 986 | 987 | def info(self, text=-1): 988 | """Sets/Gets the current info text.""" 989 | if text == -1: 990 | return self._infotext 991 | else: 992 | self._infotext = ensureString(text) 993 | 994 | def footer(self, text=-1): 995 | """Sets/Gets the current footer text.""" 996 | if text == -1: 997 | return self._footertext 998 | else: 999 | self._footertext = ensureString(text) 1000 | 1001 | def dialog(self, dialog): 1002 | """Sets the dialog as this UI dialog. All events will be forwarded to 1003 | the dialog until exit.""" 1004 | self._dialog = dialog 1005 | 1006 | # WIDGET INFORMATION 1007 | # ------------------------------------------------------------------------- 1008 | 1009 | def getFocused(self): 1010 | """Gets the focused widget""" 1011 | # We get the original widget to focus on 1012 | focused = original_widget(self._listbox.get_focus()[0]) 1013 | old_focused = None 1014 | while focused != old_focused: 1015 | old_focused = focused 1016 | # There are some types that are not focuable 1017 | if isinstance(focused, urwid.AttrWrap): 1018 | if focused.w: 1019 | focused = focused.w 1020 | elif isinstance(focused, urwid.Padding): 1021 | if focused.min_width: 1022 | focused = focused.min_width 1023 | elif isinstance(focused, urwid.Filler): 1024 | if focused.w: 1025 | focused = focused.w 1026 | elif hasattr(focused, "get_focus"): 1027 | if focused.get_focus(): 1028 | focused = focused.get_focus() 1029 | return focused 1030 | 1031 | def focusNext(self): 1032 | focused = self._listbox.get_focus()[1] + 1 1033 | self._listbox.set_focus(focused) 1034 | while True: 1035 | if ( 1036 | not self.isFocusable(self.getFocused()) 1037 | and self._listbox.body.get_next(focused)[0] != None 1038 | ): 1039 | focused += 1 1040 | self._listbox.set_focus(focused) 1041 | else: 1042 | break 1043 | 1044 | def focusPrevious(self): 1045 | focused = max(self._listbox.get_focus()[1] - 1, 0) 1046 | self._listbox.set_focus(focused) 1047 | while True: 1048 | if not self.isFocusable(self.getFocused()) and focused > 0: 1049 | focused -= 1 1050 | self._listbox.set_focus(focused) 1051 | else: 1052 | break 1053 | 1054 | def getToplevel(self): 1055 | """Returns the toplevel widget, which may be a dialog's view, if there 1056 | was a dialog.""" 1057 | if self._dialog: 1058 | return self._dialog.view() 1059 | else: 1060 | return self._frame 1061 | 1062 | def getCurrentSize(self): 1063 | """Returns the current size for this UI as a couple.""" 1064 | return self._currentSize 1065 | 1066 | # URWID EVENT-LOOP 1067 | # ------------------------------------------------------------------------- 1068 | 1069 | def main(self): 1070 | """This is the main event-loop. That is what you should invoke to start 1071 | your application.""" 1072 | # self._ui = urwid.curses_display.Screen() 1073 | self._ui = urwid.raw_display.Screen() 1074 | self._ui.clear() 1075 | if self._palette: 1076 | self._ui.register_palette(self._palette) 1077 | self._ui.run_wrapper(self.run) 1078 | # We clear the screen (I know, I should use URWID, but that was the 1079 | # quickest way I found) 1080 | curses.setupterm() 1081 | sys.stdout.write(curses.tigetstr("clear")) 1082 | if self.endMessage: 1083 | print(self.endMessage) 1084 | return self.endStatus 1085 | 1086 | def run(self): 1087 | """Run function to be used by URWID. You should not call it directly, 1088 | use the 'main' function instead.""" 1089 | # self._ui.set_mouse_tracking() 1090 | self._currentSize = self._ui.get_cols_rows() 1091 | self.isRunning = True 1092 | while self.isRunning: 1093 | self._currentSize = self._ui.get_cols_rows() 1094 | self.loop() 1095 | 1096 | def end(self, msg=None, status=1): 1097 | """Ends the application, registering the given 'msg' as end message, and 1098 | returning the given 'status' ('1' by default).""" 1099 | self.isRunning = False 1100 | self.endMessage = msg 1101 | self.endStatus = status 1102 | 1103 | def loop(self): 1104 | """This is the main URWID loop, where the event processing and 1105 | dispatching is done.""" 1106 | # We get the focused element, and update the info and and tooltip 1107 | if self._dialog: 1108 | focused = self._dialog.view() 1109 | else: 1110 | focused = self.getFocused() or self._frame 1111 | # We trigger the on focus event 1112 | self._doFocus(focused, ensure=False) 1113 | # We update the tooltip and info in the footer 1114 | if hasattr(focused, "_urwideInfo"): 1115 | self.info(self._strings.get(focused._urwideInfo) or focused._urwideInfo) 1116 | if hasattr(focused, "_urwideTooltip"): 1117 | self.tooltip( 1118 | self._strings.get(focused._urwideTooltip) or focused._urwideTooltip 1119 | ) 1120 | # We draw the screen 1121 | self._updateFooter() 1122 | self.draw() 1123 | self.tooltip("") 1124 | self.info("") 1125 | # And process keys 1126 | if not self.isRunning: 1127 | return 1128 | keys = self._ui.get_input() 1129 | if isinstance(focused, urwid.Edit): 1130 | old_text = focused.get_edit_text() 1131 | # We handle keys 1132 | for key in keys: 1133 | # if urwid.is_mouse_event(key): 1134 | # event, button, col, row = key 1135 | # self.view.mouse_event( self._currentSize, event, button, col, row, focus=True ) 1136 | # pass 1137 | # NOTE: The key press might actually be send not to the focused 1138 | # widget but to its original_widget 1139 | if key == "window resize": 1140 | self._currentSize = self._ui.get_cols_rows() 1141 | elif self._dialog: 1142 | self._doKeyPress(self._dialog.view(), key) 1143 | else: 1144 | self._doKeyPress(focused, key) 1145 | # We check if there was a change in the edit, and we fire and event 1146 | if isinstance(focused, urwid.Edit): 1147 | self._doEdit(focused, old_text, focused.get_edit_text(), ensure=False) 1148 | 1149 | def draw(self): 1150 | """Main loop to draw the console. This takes into account the fact that 1151 | there may be a dialog to display.""" 1152 | if self._dialog != None: 1153 | o = urwid.Overlay( 1154 | self._dialog.view(), 1155 | self._frame, 1156 | "center", 1157 | self._dialog.width(), 1158 | "middle", 1159 | self._dialog.height(), 1160 | ) 1161 | canvas = o.render(self._currentSize, focus=True) 1162 | else: 1163 | canvas = self._frame.render(self._currentSize, focus=True) 1164 | self._ui.draw_screen(self._currentSize, canvas) 1165 | 1166 | def _updateFooter(self): 1167 | """Updates the frame footer according to info and tooltip""" 1168 | remove_widgets(self._footer) 1169 | footer = [] 1170 | if self.tooltip(): 1171 | footer.append( 1172 | self._styleWidget(urwid.Text(self.tooltip()), {"style": "tooltip"}) 1173 | ) 1174 | if self.info(): 1175 | footer.append(self._styleWidget(urwid.Text(self.info()), {"style": "info"})) 1176 | if self.footer(): 1177 | footer.append( 1178 | self._styleWidget(urwid.Text(self.footer()), {"style": "footer"}) 1179 | ) 1180 | if footer: 1181 | for _ in footer: 1182 | add_widget(self._footer, _) 1183 | self._footer.set_focus(0) 1184 | 1185 | def parseUI(self, text): 1186 | """Parses the given text and initializes this user interface object.""" 1187 | UI.parseUI(self, text) 1188 | self._listbox = self._createWidget(urwid.ListBox, self._content) 1189 | self._footer = urwid.Pile([self.EMPTY]) 1190 | self._frame = self._createWidget( 1191 | urwid.Frame, self._listbox, self._header, self._footer 1192 | ) 1193 | return self._content 1194 | 1195 | def _parseFtr(self, data): 1196 | self.footer(data) 1197 | 1198 | 1199 | # ------------------------------------------------------------------------------ 1200 | # 1201 | # DIALOG CLASSES 1202 | # 1203 | # ------------------------------------------------------------------------------ 1204 | 1205 | 1206 | def idem(_): 1207 | return _ 1208 | 1209 | 1210 | class Dialog(UI): 1211 | """Utility class to create dialogs that will fit within a console 1212 | application. 1213 | 1214 | See the constructor documentation for more information.""" 1215 | 1216 | PALETTE = """ 1217 | dialog : BL, Lg, SO 1218 | dialog.shadow : DB, BL, SO 1219 | dialog.border : Lg, DB, SO 1220 | """ 1221 | 1222 | def __init__( 1223 | self, 1224 | parent, 1225 | ui, 1226 | width: int = 40, 1227 | height: int = -1, 1228 | style: str = "dialog", 1229 | header: str = "", 1230 | palette: str = "", 1231 | ): 1232 | """Creates a new dialog that will be attached to the given 'parent'. The 1233 | user interface is described by the 'ui' string. The dialog 'width' and 1234 | 'height' will indicate the dialog size, when 'height' is '-1', it will 1235 | be automatically computed from the given 'ui'.""" 1236 | UI.__init__(self) 1237 | self._width = width 1238 | self._height = ui.count("\n") + 1 if height == -1 else height 1239 | self._style = style 1240 | self._view = None 1241 | self._headertext = header 1242 | self._parent = parent 1243 | self._startCallback = idem 1244 | self._endCallback = idem 1245 | self._palette = None 1246 | self.make(ui, palette) 1247 | 1248 | # TODO: Shouldn't these be properties 1249 | def width(self): 1250 | """Returns the dialog width""" 1251 | return self._width 1252 | 1253 | def height(self): 1254 | """Returns the dialog height""" 1255 | return self._height 1256 | 1257 | def view(self): 1258 | """Returns the view attached to this 'Dialog'. The _view_ is created by 1259 | the 'make' method, and is an 'urwid.Frame' instance.""" 1260 | assert self._view 1261 | return self._view 1262 | 1263 | def make(self, uitext, palui=None): 1264 | """Makes the dialog using a UI description ('uitext') and a style 1265 | definition for the palette ('palui'), which can be 'None', in which case 1266 | the value will be 'Dialog.PALETTE'.""" 1267 | if not palui: 1268 | palui = self.PALETTE 1269 | self.parseStyle(palui) 1270 | style = self._styleWidget 1271 | assert self._view == None 1272 | content = [] 1273 | if self._headertext: 1274 | content.append( 1275 | style( 1276 | urwid.Text(self._headertext), 1277 | {"style": (self._style + ".header", "dialog.header", "header")}, 1278 | ) 1279 | ) 1280 | content.append(urwid.Text("")) 1281 | content.append(urwid.Divider("_")) 1282 | content.extend(self.parseUI(uitext)) 1283 | w = style( 1284 | urwid.ListBox(content), 1285 | {"style": (self._style + ".content", "dialog.content", self._style)}, 1286 | ) 1287 | # We wrap the dialog into a box 1288 | w = urwid.Padding(w, ("fixed left", 1), ("fixed right", 1)) 1289 | # w = urwid.Filler(w, ('fixed top', 1), ('fixed bottom',1)) 1290 | w = style(w, {"style": (self._style + ".body", "dialog.body", self._style)}) 1291 | w = style(w, {"style": (self._style, "dialog")}) 1292 | # Shadow 1293 | shadow = self.hasStyle(self._style + ".shadow", "dialog.shadow", "shadow") 1294 | border = self.hasStyle(self._style + ".border", "dialog.border", "border") 1295 | if shadow: 1296 | if border: 1297 | border = (border, " ") 1298 | else: 1299 | border = " " 1300 | w = urwid.Columns( 1301 | [ 1302 | w, 1303 | ( 1304 | "fixed", 1305 | 2, 1306 | urwid.AttrWrap(urwid.Filler(urwid.Text(border), "top"), shadow), 1307 | ), 1308 | ] 1309 | ) 1310 | w = urwid.Frame(w, footer=urwid.AttrWrap(urwid.Text(border), shadow)) 1311 | self._view = w 1312 | self._startCallback(self) 1313 | w._urwideOnKey = self.doKeyPress 1314 | 1315 | def onStart(self, callback): 1316 | """Registers the callback that will be triggered on dialog start.""" 1317 | self._startCallback = callback 1318 | 1319 | def onEnd(self, callback): 1320 | """Registers the callback that will be triggered on dialog end.""" 1321 | self._endCallback = callback 1322 | 1323 | def doKeyPress(self, widget, key): 1324 | self._handle("keyPress", widget, key) 1325 | 1326 | def end(self): 1327 | """Call this to close the dialog.""" 1328 | self._endCallback(self) 1329 | self._parent._dialog = None 1330 | 1331 | def _parseHdr(self, data): 1332 | if self._header != None: 1333 | raise UISyntaxError("Header can occur only once") 1334 | attr, data = self._argsFind(data) 1335 | ui, args, kwargs = self._parseAttributes(attr) 1336 | ui.setdefault("style", ("dialog.header", "header")) 1337 | self._content.append( 1338 | self._createWidget(urwid.Text, data, ui=ui, args=args, kwargs=kwargs) 1339 | ) 1340 | 1341 | 1342 | # ------------------------------------------------------------------------------ 1343 | # 1344 | # HANDLER CLASS 1345 | # 1346 | # ------------------------------------------------------------------------------ 1347 | 1348 | FORWARD = False 1349 | 1350 | 1351 | class Handler: 1352 | """A handler can be subclassed an can be plugged into a UI to react to a 1353 | specific set of events. The interest of handlers is that they can be 1354 | dynamically switched, then making "modal UI" implementation easier. 1355 | 1356 | For instance, you could have a handler for your UI in "normal mode", and 1357 | have another handler when a dialog box is displayed.""" 1358 | 1359 | def __init__(self): 1360 | self.ui = None 1361 | 1362 | def respond(self, event, *args, **kwargs): 1363 | """Responds to the given event name. An exception must be raised if the 1364 | event cannot be responded to. False is returned if the handler does not 1365 | want to handle the event, True if the event was handled.""" 1366 | responder = self.responder(event) 1367 | return responder(*args, **kwargs) != FORWARD 1368 | 1369 | def responds(self, event): 1370 | """Tells if the handler responds to the given event.""" 1371 | name = "on" + event[0].upper() + event[1:] 1372 | if hasattr(self, name): 1373 | return name 1374 | else: 1375 | return None 1376 | 1377 | def responder(self, event): 1378 | """Returns the function that responds to the given event.""" 1379 | name = "on" + event[0].upper() + event[1:] 1380 | if hasattr(self, name): 1381 | res = getattr(self, name) 1382 | if not res: 1383 | raise UIRuntimeError(f"Event handler assigned to None: {name}") 1384 | return res 1385 | else: 1386 | raise UIRuntimeError("Event not implemented: " + event) 1387 | 1388 | 1389 | # ----------------------------------------------------------------------------- 1390 | # 1391 | # HIGH LEVEL API 1392 | # 1393 | # ----------------------------------------------------------------------------- 1394 | 1395 | 1396 | class TUIApplication: 1397 | def __init__(self, app: Console, handler: Handler): 1398 | self.app: Console = app 1399 | self.handler: Handler = handler 1400 | 1401 | def run(self): 1402 | return self.app.main() 1403 | 1404 | 1405 | def ui(ui: str, style: str = "", **strings: str) -> TUIApplication: 1406 | app = Console() 1407 | for k, v in strings.items(): 1408 | setattr(app.strings, k, v) 1409 | handler = Handler() 1410 | return TUIApplication(app.create(style, ui, handler), handler) 1411 | 1412 | 1413 | # EOF 1414 | --------------------------------------------------------------------------------