├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── hexview ├── hexviewlib ├── __init__.py ├── hexview.py └── textmode.py ├── images └── hexview.png └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .swp 2 | *.swp 3 | *.pyc 4 | build 5 | dist 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Walter de Jong 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include hexview 2 | recursive-include hexviewlib *.py 3 | include README.md 4 | include LICENSE 5 | include images/hexview.png 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | hexview 2 | ======= 3 | 4 | Interactive console mode hex viewer 5 | 6 | ![screenshot](https://raw.githubusercontent.com/walterdejong/hexview/master/images/hexview.png) 7 | 8 | For the love of console programs ... this hex viewer looks a lot 9 | like a classic DOS program, and/but its user interface is somewhat 10 | inspired by the `vim` editor. For example, get built-in help by typing 11 | '`:help`'. You don't need to hit `Esc` like you would in `vim`. 12 | 13 | 14 | Using hexview 15 | ------------- 16 | You can search text by pressing '`/`' and search backwards with the '`?`' 17 | command, and you may search hexadecimal strings with '`x`'. Jump to a 18 | particular offset using '`@`'. Use the arrow keys for recalling search and 19 | address history. 20 | 21 | Use the number keys '`1`', '`2`', and '`4`' to select different views: 22 | bytes, words, and quads. 23 | 24 | Hit '`p`' to toggle the printing the values in the subwindow at the bottom. 25 | There's much more, so be sure to read the `:help`. 26 | 27 | `hexview` can examine files as large as 256 TiB. I haven't been able to 28 | verify this though ... 29 | 30 | 31 | Starting hexview 32 | ---------------- 33 | If you don't like colors, `hexview` may be started with: 34 | 35 | hexview.py --no-color 36 | 37 | See `hexview.py --help` for more options. 38 | 39 | 40 | Installing hexview 41 | ------------------ 42 | Run the following command in a terminal: 43 | 44 | python setup.py install 45 | 46 | or use `setup.py bdist` to create a package. 47 | 48 | 49 | - - - 50 | _Copyright 2016 by Walter de Jong _ 51 | -------------------------------------------------------------------------------- /hexview: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # hexview WJ116 4 | # 5 | # Copyright 2016 by Walter de Jong 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | 25 | '''hex file viewer''' 26 | 27 | from hexviewlib import hexview, textmode 28 | 29 | 30 | if __name__ == '__main__': 31 | filename = hexview.get_options() 32 | 33 | textmode.init() 34 | textmode.linemode(hexview.OPT_LINEMODE) 35 | 36 | try: 37 | hexview.hexview_main(filename) 38 | finally: 39 | textmode.terminate() 40 | 41 | # EOB 42 | -------------------------------------------------------------------------------- /hexviewlib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/walterdejong/hexview/fe555d50a812a04c6cd42ea387b7dd3b5d42dbd9/hexviewlib/__init__.py -------------------------------------------------------------------------------- /hexviewlib/hexview.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # hexview.py WJ116 4 | # 5 | # Copyright 2016 by Walter de Jong 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in 15 | # all copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | # THE SOFTWARE. 24 | 25 | '''hex file viewer''' 26 | 27 | import os 28 | import sys 29 | import curses 30 | import struct 31 | import getopt 32 | 33 | from hexviewlib import textmode 34 | 35 | from hexviewlib.textmode import Rect 36 | from hexviewlib.textmode import WHITE, YELLOW, GREEN, CYAN, BLUE #, MAGENTA 37 | from hexviewlib.textmode import RED, BLACK 38 | from hexviewlib.textmode import getch, KEY_ESC, KEY_RETURN 39 | from hexviewlib.textmode import KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT 40 | from hexviewlib.textmode import KEY_PAGEUP, KEY_PAGEDOWN, KEY_HOME, KEY_END 41 | from hexviewlib.textmode import KEY_TAB, KEY_BTAB, KEY_BS, KEY_DEL 42 | #from hexviewlib.textmode import debug 43 | 44 | VERSION = '1.3' 45 | 46 | OPT_LINEMODE = textmode.LM_HLINE | textmode.LM_VLINE 47 | 48 | 49 | class MemoryFile: 50 | '''access file data as if it is an in-memory array''' 51 | 52 | IOSIZE = 256 * 1024 53 | 54 | def __init__(self, filename=None, pagesize=25*16): 55 | '''initialise''' 56 | 57 | self.filename = filename 58 | self.filesize = 0 59 | self.fd = None 60 | self.low = self.high = 0 61 | self.pagesize = pagesize 62 | self.cachesize = self.pagesize * 3 63 | # round up to nearest multiple of 64 | if self.cachesize % MemoryFile.IOSIZE != 0: 65 | self.cachesize += (MemoryFile.IOSIZE - 66 | (self.cachesize % MemoryFile.IOSIZE)) 67 | self.data = None 68 | 69 | if filename is not None: 70 | self.load(filename) 71 | 72 | def load(self, filename): 73 | '''open file''' 74 | 75 | self.filename = filename 76 | self.filesize = os.path.getsize(self.filename) 77 | self.fd = open(filename, 'rb') 78 | self.data = bytearray(self.fd.read(self.cachesize)) 79 | self.low = 0 80 | self.high = len(self.data) 81 | 82 | def close(self): 83 | '''close the file''' 84 | 85 | if self.fd is not None: 86 | self.fd.close() 87 | self.fd = None 88 | 89 | self.filename = None 90 | self.filesize = 0 91 | self.data = None 92 | 93 | def __len__(self): 94 | '''Returns length''' 95 | 96 | return self.filesize 97 | 98 | def __getitem__(self, idx): 99 | '''Return byte or range at idx''' 100 | 101 | if isinstance(idx, int): 102 | # return byte at address 103 | if idx < 0 or idx >= self.filesize: 104 | raise IndexError('MemoryFile out of bounds error') 105 | 106 | if idx < self.low or idx >= self.high: 107 | self.pagefault(idx) 108 | 109 | return self.data[idx - self.low] 110 | 111 | if isinstance(idx, slice): 112 | # return slice 113 | if idx.start < 0 or idx.stop > self.filesize: 114 | raise IndexError('MemoryFile out of bounds error') 115 | 116 | if idx.start < self.low or idx.stop > self.high: 117 | self.pagefault(self.low) 118 | 119 | return self.data[idx.start - self.low: 120 | idx.stop - self.low:idx.step] 121 | 122 | raise TypeError('invalid argument type') 123 | 124 | def pagefault(self, addr): 125 | '''page in data as needed''' 126 | 127 | self.low = addr - self.cachesize // 2 128 | if self.low < 0: 129 | self.low = 0 130 | 131 | self.high = addr + self.cachesize // 2 132 | if self.high > self.filesize: 133 | self.high = self.filesize 134 | 135 | self.fd.seek(self.low, os.SEEK_SET) 136 | size = self.high - self.low 137 | self.data = bytearray(self.fd.read(size)) 138 | self.high = self.low + len(self.data) 139 | 140 | def find(self, searchtext, pos): 141 | '''find searchtext 142 | Returns -1 if not found 143 | ''' 144 | 145 | if isinstance(searchtext, str): 146 | searchtext = bytes(searchtext, 'utf-8') 147 | 148 | if pos < 0 or pos >= self.filesize: 149 | return -1 150 | 151 | if pos < self.low or pos + len(searchtext) >= self.high: 152 | self.pagefault(self.low) 153 | 154 | pos -= self.low 155 | 156 | while True: 157 | idx = self.data.find(searchtext, pos) 158 | if idx >= 0: 159 | # found 160 | return idx + self.low 161 | 162 | if self.high >= self.filesize: 163 | # not found 164 | return -1 165 | 166 | self.low = self.high - len(searchtext) 167 | self.pagefault(self.low) 168 | 169 | 170 | 171 | class HexWindow(textmode.Window): 172 | '''hex viewer main window''' 173 | 174 | MODE_8BIT = 1 175 | MODE_16BIT = 2 176 | MODE_32BIT = 4 177 | CLEAR_VIEWMODE = 0xffff & ~7 178 | MODE_SELECT = 8 179 | MODE_VALUES = 0x10 180 | 181 | # search direction 182 | FORWARD = 0 183 | BACKWARD = 1 184 | 185 | def __init__(self, x, y, w, h, colors, title=None, border=True): 186 | '''initialize''' 187 | 188 | # take off height for ValueSubWindow 189 | h -= 6 190 | # turn off window shadow for HexWindow 191 | # because it clobbers the bottom statusbar 192 | super().__init__(x, y, w, h, colors, title, border, shadow=False) 193 | self.data = None 194 | self.address = 0 195 | self.cursor_x = self.cursor_y = 0 196 | self.mode = HexWindow.MODE_8BIT | HexWindow.MODE_VALUES 197 | self.selection_start = self.selection_end = 0 198 | self.old_addr = self.old_x = self.old_y = 0 199 | 200 | colors = textmode.ColorSet(WHITE, BLACK) 201 | colors.cursor = textmode.video_color(WHITE, GREEN, bold=True) 202 | self.cmdline = CommandBar(colors, prompt=':') 203 | self.search = CommandBar(colors, prompt='/') 204 | self.searchdir = HexWindow.FORWARD 205 | self.hexsearch = CommandBar(colors, prompt='x/', 206 | inputfilter=hex_inputfilter) 207 | self.jumpaddr = CommandBar(colors, prompt='@', 208 | inputfilter=hex_inputfilter) 209 | self.addaddr = CommandBar(colors, prompt='@+', 210 | inputfilter=hex_inputfilter) 211 | 212 | # this is a hack; I always want a visible cursor 213 | # even though the command bar can be the front window 214 | # so we can ignore focus events sometimes 215 | self.ignore_focus = False 216 | 217 | colors = textmode.ColorSet(WHITE, BLACK) 218 | colors.border = textmode.video_color(CYAN, BLACK) 219 | colors.status = textmode.video_color(CYAN, BLACK) 220 | self.valueview = ValueSubWindow(x, y + self.frame.h - 1, w, 7, 221 | colors) 222 | 223 | self.address_fmt = '{:08X} ' 224 | self.bytes_offset = 10 225 | self.ascii_offset = 60 226 | 227 | def resize_event(self): 228 | '''the terminal was resized''' 229 | 230 | # always keep same width, but height may vary 231 | x = self.frame.x 232 | y = self.frame.y 233 | w = self.frame.w 234 | if self.mode & HexWindow.MODE_VALUES: 235 | h = self.frame.h = (textmode.VIDEO.h - 1 - 236 | (self.valueview.frame.h - 1)) 237 | else: 238 | h = self.frame.h = textmode.VIDEO.h - 1 239 | 240 | # bounds is the inner area; for view content 241 | if self.has_border: 242 | self.bounds = Rect(x + 1, y + 1, w - 2, h - 2) 243 | else: 244 | self.bounds = self.frame.copy() 245 | 246 | # rect is the outer area; larger because of shadow 247 | if self.has_shadow: 248 | self.rect = Rect(x, y, w + 2, h + 1) 249 | else: 250 | self.rect = self.frame.copy() 251 | 252 | if self.cursor_y >= self.bounds.h: 253 | self.cursor_y = self.bounds.h - 1 254 | 255 | # resize the command and search bars 256 | self.cmdline.resize_event() 257 | self.search.resize_event() 258 | self.hexsearch.resize_event() 259 | self.jumpaddr.resize_event() 260 | self.addaddr.resize_event() 261 | self.valueview.resize_event() 262 | 263 | def load(self, filename): 264 | '''load file 265 | Raises OSError on error 266 | ''' 267 | 268 | self.data = MemoryFile(filename, self.bounds.h * 16) 269 | 270 | self.title = os.path.basename(filename) 271 | if len(self.title) > self.bounds.w: 272 | self.title = self.title[:self.bounds.w - 6] + '...' 273 | 274 | self.set_address_format(len(self.data)) 275 | 276 | def set_address_format(self, top_addr): 277 | '''set address notation''' 278 | 279 | # slightly change layout so that app stays goodlooking 280 | 281 | if top_addr <= 0xffff: 282 | # up to 64 kiB 283 | self.address_fmt = '{:04X} ' 284 | self.bytes_offset = 8 285 | self.ascii_offset = 60 286 | elif top_addr <= 0xffffffff: 287 | # up to 4 GiB 288 | self.address_fmt = '{:08X} ' 289 | self.bytes_offset = 10 290 | self.ascii_offset = 60 291 | elif top_addr <= 0xffffffffff: 292 | # up to 1 TiB 293 | self.address_fmt = '{:010X} ' 294 | self.bytes_offset = 12 295 | self.ascii_offset = 62 296 | else: 297 | # up to 256 TiB will look fine 298 | self.address_fmt = '{:012X} ' 299 | self.bytes_offset = 13 300 | self.ascii_offset = 62 301 | 302 | def show(self): 303 | '''open the window''' 304 | 305 | if self.mode & HexWindow.MODE_VALUES: 306 | self.valueview.show() 307 | 308 | super().show() 309 | 310 | def close(self): 311 | '''close window''' 312 | 313 | self.data.close() 314 | 315 | super().close() 316 | 317 | def lose_focus(self): 318 | '''we lose focus''' 319 | 320 | if self.ignore_focus: 321 | # ignore only once 322 | self.ignore_focus = False 323 | return 324 | 325 | super().lose_focus() 326 | 327 | def draw(self): 328 | '''draw the window''' 329 | 330 | if not self.flags & textmode.Window.SHOWN: 331 | return 332 | 333 | super().draw() 334 | 335 | if self.mode & HexWindow.MODE_8BIT: 336 | self.draw_view_8bit() 337 | 338 | elif self.mode & HexWindow.MODE_16BIT: 339 | self.draw_view_16bit() 340 | 341 | elif self.mode & HexWindow.MODE_32BIT: 342 | self.draw_view_32bit() 343 | 344 | self.draw_statusbar() 345 | 346 | def draw_statusbar(self): 347 | '''draw statusbar''' 348 | 349 | status = None 350 | if self.mode & HexWindow.MODE_SELECT: 351 | status = 'Select' 352 | 353 | if status is None: 354 | textmode.VIDEO.hline(self.bounds.x + self.bounds.w - 12, 355 | self.bounds.y + self.bounds.h, 10, curses.ACS_HLINE, 356 | self.colors.border) 357 | else: 358 | textmode.VIDEO.puts(self.bounds.x + self.bounds.w - 2 - len(status), 359 | self.bounds.y + self.bounds.h, status, 360 | self.colors.status) 361 | 362 | def draw_view_8bit(self): 363 | '''draw hexview for single bytes''' 364 | 365 | y = 0 366 | while y < self.bounds.h: 367 | # address 368 | offset = self.address + y * 16 369 | line = self.address_fmt.format(offset) 370 | 371 | # bytes (left block) 372 | try: 373 | # try fast(er) implementation 374 | line += (('{:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} ' 375 | '{:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X} {:02X}').format 376 | (self.data[offset], self.data[offset + 1], 377 | self.data[offset + 2], self.data[offset + 3], 378 | self.data[offset + 4], self.data[offset + 5], 379 | self.data[offset + 6], self.data[offset + 7], 380 | self.data[offset + 8], self.data[offset + 9], 381 | self.data[offset + 10], self.data[offset + 11], 382 | self.data[offset + 12], self.data[offset + 13], 383 | self.data[offset + 14], self.data[offset + 15])) 384 | except IndexError: 385 | # do the slower version 386 | for i in range(0, 8): 387 | try: 388 | line += '{:02X} '.format(self.data[offset + i]) 389 | except IndexError: 390 | line += ' ' 391 | line += ' ' 392 | for i in range(8, 16): 393 | try: 394 | line += '{:02X} '.format(self.data[offset + i]) 395 | except IndexError: 396 | line += ' ' 397 | 398 | self.puts(0, y, line, self.colors.text) 399 | 400 | self.draw_ascii(y) 401 | y += 1 402 | 403 | def draw_view_16bit(self): 404 | '''draw hexview for 16 bit words''' 405 | 406 | y = 0 407 | while y < self.bounds.h: 408 | # address 409 | offset = self.address + y * 16 410 | line = self.address_fmt.format(offset) 411 | 412 | # left block 413 | try: 414 | # try fast(er) implementation 415 | line += (('{:02X}{:02X} {:02X}{:02X} {:02X}{:02X} {:02X}{:02X} ' 416 | '{:02X}{:02X} {:02X}{:02X} {:02X}{:02X} {:02X}{:02X}').format 417 | (self.data[offset], self.data[offset + 1], 418 | self.data[offset + 2], self.data[offset + 3], 419 | self.data[offset + 4], self.data[offset + 5], 420 | self.data[offset + 6], self.data[offset + 7], 421 | self.data[offset + 8], self.data[offset + 9], 422 | self.data[offset + 10], self.data[offset + 11], 423 | self.data[offset + 12], self.data[offset + 13], 424 | self.data[offset + 14], self.data[offset + 15])) 425 | except IndexError: 426 | # do the slower version 427 | for i in range(0, 4): 428 | try: 429 | line += '{:02X}'.format(self.data[offset + i * 2]) 430 | except IndexError: 431 | line += ' ' 432 | try: 433 | line += '{:02X}'.format(self.data[offset + i * 2 + 1]) 434 | except IndexError: 435 | line += ' ' 436 | line += ' ' 437 | 438 | offset += 8 439 | line += ' ' 440 | # right block 441 | for i in range(0, 4): 442 | try: 443 | line += '{:02X}'.format(self.data[offset + i * 2]) 444 | except IndexError: 445 | line += ' ' 446 | try: 447 | line += '{:02X}'.format(self.data[offset + i * 2 + 1]) 448 | except IndexError: 449 | line += ' ' 450 | line += ' ' 451 | 452 | self.puts(0, y, line, self.colors.text) 453 | 454 | self.draw_ascii(y) 455 | y += 1 456 | 457 | def draw_view_32bit(self): 458 | '''draw hexview for 32 bit words''' 459 | 460 | y = 0 461 | while y < self.bounds.h: 462 | # address 463 | offset = self.address + y * 16 464 | line = self.address_fmt.format(offset) 465 | 466 | # left block 467 | try: 468 | # try fast(er) implementation 469 | line += (('{:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X} ' 470 | '{:02X}{:02X}{:02X}{:02X} {:02X}{:02X}{:02X}{:02X}').format 471 | (self.data[offset], self.data[offset + 1], 472 | self.data[offset + 2], self.data[offset + 3], 473 | self.data[offset + 4], self.data[offset + 5], 474 | self.data[offset + 6], self.data[offset + 7], 475 | self.data[offset + 8], self.data[offset + 9], 476 | self.data[offset + 10], self.data[offset + 11], 477 | self.data[offset + 12], self.data[offset + 13], 478 | self.data[offset + 14], self.data[offset + 15])) 479 | except IndexError: 480 | # do the slower version 481 | for i in range(0, 2): 482 | try: 483 | line += '{:02X}'.format(self.data[offset + i * 4]) 484 | except IndexError: 485 | line += ' ' 486 | try: 487 | line += '{:02X}'.format(self.data[offset + i * 4 + 1]) 488 | except IndexError: 489 | line += ' ' 490 | try: 491 | line += '{:02X}'.format(self.data[offset + i * 4 + 2]) 492 | except IndexError: 493 | line += ' ' 494 | try: 495 | line += '{:02X}'.format(self.data[offset + i * 4 + 3]) 496 | except IndexError: 497 | line += ' ' 498 | line += ' ' 499 | 500 | offset += 8 501 | line += ' ' 502 | # right block 503 | for i in range(0, 2): 504 | try: 505 | line += '{:02X}'.format(self.data[offset + i * 4]) 506 | except IndexError: 507 | line += ' ' 508 | try: 509 | line += '{:02X}'.format(self.data[offset + i * 4 + 1]) 510 | except IndexError: 511 | line += ' ' 512 | try: 513 | line += '{:02X}'.format(self.data[offset + i * 4 + 2]) 514 | except IndexError: 515 | line += ' ' 516 | try: 517 | line += '{:02X}'.format(self.data[offset + i * 4 + 3]) 518 | except IndexError: 519 | line += ' ' 520 | line += ' ' 521 | 522 | self.puts(0, y, line, self.colors.text) 523 | 524 | self.draw_ascii(y) 525 | y += 1 526 | 527 | def draw_ascii(self, y): 528 | '''draw ascii bytes for line y''' 529 | 530 | invis = [] 531 | line = '' 532 | offset = self.address + y * 16 533 | for i in range(0, 16): 534 | try: 535 | ch = self.data[offset + i] 536 | if ord(' ') <= ch <= ord('~'): 537 | line += chr(ch) 538 | else: 539 | line += '.' 540 | invis.append(i) 541 | except IndexError: 542 | ch = ' ' 543 | 544 | # put the ASCII bytes line 545 | self.puts(self.ascii_offset, y, line, self.colors.text) 546 | 547 | # color invisibles 548 | for i in invis: 549 | self.color_putch(self.ascii_offset + i, y, 550 | self.colors.invisibles) 551 | 552 | def draw_cursor(self, clear=False, mark=None): # pylint: disable=arguments-differ 553 | '''draw cursor''' 554 | 555 | if not self.flags & textmode.Window.FOCUS: 556 | clear = True 557 | 558 | if clear: 559 | color = self.colors.text 560 | else: 561 | color = self.colors.cursor 562 | 563 | if mark is not None: 564 | color = mark 565 | 566 | if self.mode & HexWindow.MODE_SELECT: 567 | self.draw_selection() 568 | 569 | offset = self.address + self.cursor_y * 16 + self.cursor_x 570 | x = self.hexview_position(offset) 571 | self.draw_cursor_at(self.bytes_offset + x, self.cursor_y, color, 572 | clear) 573 | 574 | y = self.cursor_y 575 | ch = self.data[self.address + y * 16 + self.cursor_x] 576 | self.draw_ascii_cursor(ch, color, clear) 577 | 578 | self.update_values() 579 | 580 | def draw_ascii_cursor(self, ch, color, clear): 581 | '''draw ascii cursor''' 582 | 583 | if clear: 584 | color = self.colors.text 585 | else: 586 | color = self.colors.cursor 587 | 588 | if ord(' ') <= ch <= ord('~'): 589 | ch = chr(ch) 590 | else: 591 | ch = '.' 592 | if clear: 593 | color = self.colors.invisibles 594 | 595 | alt = not clear 596 | self.color_putch(self.ascii_offset + self.cursor_x, self.cursor_y, 597 | color, alt) 598 | 599 | def draw_cursor_at(self, x, y, color, clear): 600 | '''draw hex bytes cursor at x, y''' 601 | 602 | alt = not clear 603 | textmode.VIDEO.color_hline(self.bounds.x + x, self.bounds.y + y, 604 | 2, color, alt) 605 | 606 | def clear_cursor(self): 607 | '''clear the cursor''' 608 | 609 | self.draw_cursor(clear=True) 610 | 611 | def hexview_position(self, offset): 612 | '''Returns x position in hex view for offset 613 | Returns -1 for out of bounds offset 614 | ''' 615 | 616 | if offset < 0: 617 | return -1 618 | 619 | pagesize = self.bounds.h * 16 620 | if offset > self.address + pagesize: 621 | return -1 622 | 623 | offset = (offset - self.address) % 16 624 | 625 | x = 0 626 | if self.mode & HexWindow.MODE_8BIT: 627 | x = offset * 3 628 | if offset >= 8: 629 | x += 1 630 | 631 | elif self.mode & HexWindow.MODE_16BIT: 632 | x = offset // 2 * 6 633 | if offset & 1: 634 | x += 2 635 | if offset >= 8: 636 | x += 1 637 | 638 | elif self.mode & HexWindow.MODE_32BIT: 639 | x = offset // 4 * 12 640 | mod = offset % 4 641 | x += mod * 2 642 | if offset >= 8: 643 | x += 1 644 | 645 | return x 646 | 647 | def draw_selection(self): 648 | '''draw selection''' 649 | 650 | start = self.selection_start 651 | if start < self.address: 652 | start = self.address 653 | pagesize = self.bounds.h * 16 654 | end = self.selection_end 655 | if end > self.address + pagesize: 656 | end = self.address + pagesize 657 | 658 | startx = (start - self.address) % 16 659 | starty = (start - self.address) // 16 660 | endx = (end - self.address) % 16 661 | endy = (end - self.address) // 16 662 | 663 | # ASCII view 664 | if starty == endy: 665 | textmode.VIDEO.color_hline((self.bounds.x + self.ascii_offset + 666 | startx), 667 | self.bounds.y + starty, endx - startx, 668 | self.colors.cursor) 669 | else: 670 | textmode.VIDEO.color_hline((self.bounds.x + self.ascii_offset + 671 | startx), 672 | self.bounds.y + starty, 16 - startx, 673 | self.colors.cursor) 674 | for j in range(starty + 1, endy): 675 | textmode.VIDEO.color_hline((self.bounds.x + 676 | self.ascii_offset), 677 | self.bounds.y + j, 16, 678 | self.colors.cursor) 679 | textmode.VIDEO.color_hline(self.bounds.x + self.ascii_offset, 680 | self.bounds.y + endy, endx, 681 | self.colors.cursor) 682 | 683 | # hex view start/end position depend on viewing mode 684 | startx = self.hexview_position(start) 685 | endx = self.hexview_position(end) 686 | 687 | if starty == endy: 688 | textmode.VIDEO.color_hline((self.bounds.x + self.bytes_offset + 689 | startx), 690 | self.bounds.y + starty, endx - startx, 691 | self.colors.cursor) 692 | else: 693 | w = 16 * 3 694 | textmode.VIDEO.color_hline((self.bounds.x + self.bytes_offset + 695 | startx), 696 | self.bounds.y + starty, w - startx, 697 | self.colors.cursor) 698 | for j in range(starty + 1, endy): 699 | textmode.VIDEO.color_hline(self.bounds.x + self.bytes_offset, 700 | self.bounds.y + j, w, 701 | self.colors.cursor) 702 | textmode.VIDEO.color_hline(self.bounds.x + self.bytes_offset, 703 | self.bounds.y + endy, endx, 704 | self.colors.cursor) 705 | 706 | def update_values(self): 707 | '''update value view''' 708 | 709 | if not self.mode & HexWindow.MODE_VALUES: 710 | return 711 | 712 | # get data at cursor 713 | offset = self.address + self.cursor_y * 16 + self.cursor_x 714 | try: 715 | data = self.data[offset:offset + 8] 716 | except IndexError: 717 | # get data, do zero padding 718 | data = bytearray(8) 719 | for i in range(0, 8): 720 | try: 721 | data[i] = self.data[offset + i] 722 | except IndexError: 723 | break 724 | 725 | self.valueview.update(data) 726 | 727 | def mark_address(self, y, color=-1): 728 | '''only used to draw a marked address 729 | Marked addresses are copied into the jumpaddr history 730 | ''' 731 | 732 | if color == -1: 733 | color = textmode.video_color(WHITE, RED, bold=True) 734 | 735 | textmode.VIDEO.color_hline(self.bounds.x, self.bounds.y + y, 8, 736 | color) 737 | 738 | def scroll_up(self, nlines=1): 739 | '''scroll nlines up''' 740 | 741 | self.address -= nlines * 16 742 | if self.address < 0: 743 | self.address = 0 744 | 745 | self.draw() 746 | 747 | def scroll_down(self, nlines=1): 748 | '''scroll nlines down''' 749 | 750 | addr = self.address + nlines * 16 751 | 752 | pagesize = self.bounds.h * 16 753 | if addr > len(self.data) - pagesize: 754 | addr = len(self.data) - pagesize 755 | if addr < 0: 756 | addr = 0 757 | 758 | if addr != self.address: 759 | self.address = addr 760 | self.draw() 761 | 762 | def move_up(self): 763 | '''move cursor up''' 764 | 765 | if not self.cursor_y and not self.address: 766 | return 767 | 768 | self.clear_cursor() 769 | 770 | if not self.cursor_y: 771 | self.scroll_up() 772 | else: 773 | self.cursor_y -= 1 774 | 775 | self.update_selection() 776 | self.draw_cursor() 777 | 778 | def move_down(self): 779 | '''move cursor down''' 780 | 781 | if self.cursor_y >= self.bounds.h - 1: 782 | # scroll down 783 | addr = self.address 784 | self.scroll_down() 785 | if self.address == addr: 786 | # no change (already at end) 787 | return 788 | else: 789 | addr = self.address + (self.cursor_y + 1) * 16 + self.cursor_x 790 | if addr >= len(self.data): 791 | # can not go beyond EOF 792 | return 793 | 794 | self.clear_cursor() 795 | self.cursor_y += 1 796 | 797 | self.update_selection() 798 | self.draw_cursor() 799 | 800 | def move_left(self): 801 | '''move cursor left''' 802 | 803 | if not self.cursor_x and not self.cursor_y: 804 | if not self.address: 805 | return 806 | 807 | self.scroll_up() 808 | else: 809 | self.clear_cursor() 810 | 811 | if not self.cursor_x: 812 | if self.cursor_y > 0: 813 | self.cursor_y -= 1 814 | self.cursor_x = 15 815 | else: 816 | self.cursor_x -= 1 817 | 818 | self.update_selection() 819 | self.draw_cursor() 820 | 821 | def move_right(self): 822 | '''move cursor right''' 823 | 824 | if self.cursor_x >= 15 and self.cursor_y >= self.bounds.h - 1: 825 | # scroll down 826 | addr = self.address 827 | self.scroll_down() 828 | if self.address == addr: 829 | # no change (already at end) 830 | return 831 | else: 832 | addr = self.address + self.cursor_y * 16 + self.cursor_x + 1 833 | if addr >= len(self.data): 834 | # can not go beyond EOF 835 | return 836 | 837 | self.clear_cursor() 838 | 839 | if self.cursor_x >= 15: 840 | self.cursor_x = 0 841 | if self.cursor_y < self.bounds.h - 1: 842 | self.cursor_y += 1 843 | else: 844 | self.cursor_x += 1 845 | 846 | self.update_selection() 847 | self.draw_cursor() 848 | 849 | def roll_left(self): 850 | '''move left by one byte''' 851 | 852 | if not self.address: 853 | return 854 | 855 | self.address -= 1 856 | self.draw() 857 | self.draw_cursor() 858 | 859 | def roll_right(self): 860 | '''move right by one byte''' 861 | 862 | top = len(self.data) - self.bounds.h * 16 863 | if self.address < top: 864 | self.address += 1 865 | self.draw() 866 | self.draw_cursor() 867 | 868 | def pageup(self): 869 | '''page up''' 870 | 871 | if not self.address: 872 | if not self.cursor_y: 873 | return 874 | 875 | self.clear_cursor() 876 | self.cursor_y = 0 877 | self.update_selection() 878 | self.draw_cursor() 879 | return 880 | 881 | if self.cursor_y == self.bounds.h - 1: 882 | self.clear_cursor() 883 | self.cursor_y = 0 884 | else: 885 | self.scroll_up(self.bounds.h - 1) 886 | 887 | self.update_selection() 888 | self.draw_cursor() 889 | 890 | def pagedown(self): 891 | '''page down''' 892 | 893 | if self.cursor_y == 0: 894 | self.clear_cursor() 895 | self.cursor_y = self.bounds.h - 1 896 | else: 897 | addr = self.address 898 | self.scroll_down(self.bounds.h - 1) 899 | if self.address == addr: 900 | # no change 901 | if self.cursor_y >= self.bounds.h - 1: 902 | return 903 | 904 | self.clear_cursor() 905 | self.cursor_y = self.bounds.h - 1 906 | 907 | self.update_selection() 908 | self.draw_cursor() 909 | 910 | def move_home(self): 911 | '''go to top of document''' 912 | 913 | if not self.address: 914 | if not self.cursor_x and not self.cursor_y: 915 | return 916 | 917 | self.clear_cursor() 918 | else: 919 | self.address = 0 920 | self.draw() 921 | 922 | self.cursor_x = self.cursor_y = 0 923 | self.update_selection() 924 | self.draw_cursor() 925 | 926 | def move_end(self): 927 | '''go to last page of document''' 928 | 929 | pagesize = self.bounds.h * 16 930 | top = len(self.data) - pagesize 931 | if top < 0: 932 | top = 0 933 | 934 | if self.address != top: 935 | self.address = top 936 | self.draw() 937 | else: 938 | self.clear_cursor() 939 | 940 | if len(self.data) < pagesize: 941 | self.cursor_y = len(self.data) // 16 942 | self.cursor_x = len(self.data) % 16 943 | else: 944 | self.cursor_y = self.bounds.h - 1 945 | self.cursor_x = 15 946 | 947 | self.update_selection() 948 | self.draw_cursor() 949 | 950 | def select_view(self, key): 951 | '''set view option''' 952 | 953 | update = False 954 | if (key == '1' and 955 | self.mode & HexWindow.MODE_8BIT != HexWindow.MODE_8BIT): 956 | self.mode &= HexWindow.CLEAR_VIEWMODE 957 | self.mode |= HexWindow.MODE_8BIT 958 | update = True 959 | 960 | elif (key == '2' and 961 | self.mode & HexWindow.MODE_16BIT != HexWindow.MODE_16BIT): 962 | self.mode &= HexWindow.CLEAR_VIEWMODE 963 | self.mode |= HexWindow.MODE_16BIT 964 | update = True 965 | 966 | elif (key == '4' and 967 | self.mode & HexWindow.MODE_32BIT != HexWindow.MODE_32BIT): 968 | self.mode &= HexWindow.CLEAR_VIEWMODE 969 | self.mode |= HexWindow.MODE_32BIT 970 | update = True 971 | 972 | if update: 973 | self.draw() 974 | self.draw_cursor() 975 | 976 | def mode_selection(self): 977 | '''toggle selection mode''' 978 | 979 | if not self.mode & HexWindow.MODE_SELECT: 980 | self.selection_start = (self.address + self.cursor_y * 16 + 981 | self.cursor_x) 982 | self.selection_end = self.selection_start 983 | 984 | self.mode ^= HexWindow.MODE_SELECT 985 | self.update_selection() 986 | 987 | if not self.mode & HexWindow.MODE_SELECT: 988 | # was not yet redrawn ... do it now 989 | self.draw() 990 | 991 | self.draw_cursor() 992 | 993 | def update_selection(self): 994 | '''update selection start/end''' 995 | 996 | if self.mode & HexWindow.MODE_SELECT: 997 | old_addr = self.old_addr + self.old_y * 16 + self.old_x 998 | addr = self.address + self.cursor_y * 16 + self.cursor_x 999 | 1000 | if self.selection_start == self.selection_end: 1001 | if addr < self.selection_start: 1002 | self.selection_start = addr 1003 | elif addr > self.selection_end: 1004 | self.selection_end = addr 1005 | else: 1006 | if old_addr == self.selection_start: 1007 | self.selection_start = addr 1008 | elif old_addr == self.selection_end: 1009 | self.selection_end = addr 1010 | 1011 | if self.selection_start > self.selection_end: 1012 | # swap start, end 1013 | # and PEP-8 looks amazingly stupid here 1014 | (self.selection_start, 1015 | self.selection_end) = (self.selection_end, 1016 | self.selection_start) 1017 | 1018 | self.draw() 1019 | 1020 | def search_error(self, msg): 1021 | '''display error message for search functions''' 1022 | 1023 | self.ignore_focus = True 1024 | self.search.show() 1025 | self.search.cputs(0, 0, msg, textmode.video_color(WHITE, RED, 1026 | bold=True)) 1027 | getch() 1028 | self.search.hide() 1029 | 1030 | def find(self, again=False): 1031 | '''text search''' 1032 | 1033 | self.searchdir = HexWindow.FORWARD 1034 | searchtext = '' 1035 | 1036 | if not again: 1037 | self.search.prompt = '/' 1038 | self.ignore_focus = True 1039 | self.search.show() 1040 | ret = self.search.runloop() 1041 | if ret != textmode.ENTER: 1042 | return 1043 | 1044 | searchtext = self.search.textfield.text 1045 | if not searchtext: 1046 | again = True 1047 | 1048 | if again: 1049 | try: 1050 | searchtext = self.search.textfield.history[-1] 1051 | except IndexError: 1052 | return 1053 | 1054 | if not searchtext: 1055 | return 1056 | 1057 | pos = self.address + self.cursor_y * 16 + self.cursor_x 1058 | if again: 1059 | pos += 1 1060 | 1061 | try: 1062 | offset = self.data.find(searchtext, pos) 1063 | except ValueError: 1064 | # not found 1065 | offset = -1 1066 | 1067 | if offset == -1: 1068 | self.search_error('Not found') 1069 | return 1070 | 1071 | # text was found at offset 1072 | self.clear_cursor() 1073 | # if on the same page, move the cursor 1074 | pagesize = self.bounds.h * 16 1075 | if self.address < offset + len(searchtext) < self.address + pagesize: 1076 | pass 1077 | else: 1078 | # scroll the page; change base address 1079 | self.address = offset - self.bounds.h * 8 1080 | if self.address > len(self.data) - pagesize: 1081 | self.address = len(self.data) - pagesize 1082 | if self.address < 0: 1083 | self.address = 0 1084 | 1085 | self.draw() 1086 | 1087 | # move cursor location 1088 | diff = offset - self.address 1089 | self.cursor_y = diff // 16 1090 | self.cursor_x = diff % 16 1091 | self.draw_cursor() 1092 | 1093 | def find_backwards(self, again=False): 1094 | '''text search backwards''' 1095 | 1096 | self.searchdir = HexWindow.BACKWARD 1097 | searchtext = '' 1098 | 1099 | if not again: 1100 | self.search.prompt = '?' 1101 | self.ignore_focus = True 1102 | self.search.show() 1103 | ret = self.search.runloop() 1104 | if ret != textmode.ENTER: 1105 | return 1106 | 1107 | searchtext = self.search.textfield.text 1108 | if not searchtext: 1109 | again = True 1110 | 1111 | if again: 1112 | try: 1113 | searchtext = self.search.textfield.history[-1] 1114 | except IndexError: 1115 | return 1116 | 1117 | if not searchtext: 1118 | return 1119 | 1120 | pos = self.address + self.cursor_y * 16 + self.cursor_x 1121 | try: 1122 | offset = bytearray_find_backwards(self.data, searchtext, pos) 1123 | except ValueError: 1124 | # not found 1125 | offset = -1 1126 | 1127 | if offset == -1: 1128 | self.search_error('Not found') 1129 | return 1130 | 1131 | # text was found at offset 1132 | self.clear_cursor() 1133 | # if on the same page, move the cursor 1134 | pagesize = self.bounds.h * 16 1135 | if self.address < offset + len(searchtext) < self.address + pagesize: 1136 | pass 1137 | else: 1138 | # scroll the page; change base address 1139 | self.address = offset - self.bounds.h * 8 1140 | if self.address > len(self.data) - pagesize: 1141 | self.address = len(self.data) - pagesize 1142 | if self.address < 0: 1143 | self.address = 0 1144 | 1145 | self.draw() 1146 | 1147 | # move cursor location 1148 | diff = offset - self.address 1149 | self.cursor_y = diff // 16 1150 | self.cursor_x = diff % 16 1151 | self.draw_cursor() 1152 | 1153 | def find_hex(self, again=False): 1154 | '''search hex string''' 1155 | 1156 | self.searchdir = HexWindow.FORWARD 1157 | searchtext = '' 1158 | 1159 | if not again: 1160 | self.ignore_focus = True 1161 | self.hexsearch.show() 1162 | ret = self.hexsearch.runloop() 1163 | if ret != textmode.ENTER: 1164 | return 1165 | 1166 | searchtext = self.hexsearch.textfield.text 1167 | if not searchtext: 1168 | again = True 1169 | 1170 | if again: 1171 | try: 1172 | searchtext = self.hexsearch.textfield.history[-1] 1173 | except IndexError: 1174 | return 1175 | 1176 | if not searchtext: 1177 | return 1178 | 1179 | # convert ascii searchtext to raw byte string 1180 | searchtext = searchtext.replace(' ', '') 1181 | if not searchtext: 1182 | return 1183 | 1184 | if len(searchtext) & 1: 1185 | self.search_error('Invalid byte string (uneven number of digits)') 1186 | return 1187 | 1188 | raw = '' 1189 | for x in range(0, len(searchtext), 2): 1190 | hex_string = searchtext[x:x + 2] 1191 | try: 1192 | value = int(hex_string, 16) 1193 | except ValueError: 1194 | self.search_error('Invalid value in byte string') 1195 | return 1196 | 1197 | raw += chr(value) 1198 | 1199 | pos = self.address + self.cursor_y * 16 + self.cursor_x 1200 | if again: 1201 | pos += 1 1202 | 1203 | try: 1204 | offset = self.data.find(raw, pos) 1205 | except ValueError: 1206 | # not found 1207 | offset = -1 1208 | 1209 | if offset == -1: 1210 | self.search_error('Not found') 1211 | return 1212 | 1213 | # text was found at offset 1214 | self.clear_cursor() 1215 | # if on the same page, move the cursor 1216 | pagesize = self.bounds.h * 16 1217 | if self.address < offset + len(searchtext) < self.address + pagesize: 1218 | pass 1219 | else: 1220 | # scroll the page; change base address 1221 | self.address = offset - self.bounds.h * 8 1222 | if self.address > len(self.data) - pagesize: 1223 | self.address = len(self.data) - pagesize 1224 | if self.address < 0: 1225 | self.address = 0 1226 | 1227 | self.draw() 1228 | 1229 | # move cursor location 1230 | diff = offset - self.address 1231 | self.cursor_y = diff // 16 1232 | self.cursor_x = diff % 16 1233 | self.draw_cursor() 1234 | 1235 | def jump_address(self): 1236 | '''jump to address''' 1237 | 1238 | self.ignore_focus = True 1239 | self.jumpaddr.show() 1240 | ret = self.jumpaddr.runloop() 1241 | if ret != textmode.ENTER: 1242 | return 1243 | 1244 | text = self.jumpaddr.textfield.text 1245 | text = text.replace(' ', '') 1246 | if not text: 1247 | return 1248 | 1249 | try: 1250 | addr = int(text, 16) 1251 | except ValueError: 1252 | self.search_error('Invalid address') 1253 | return 1254 | 1255 | # make addr appear at cursor_y 1256 | addr -= self.cursor_y * 16 1257 | 1258 | pagesize = self.bounds.h * 16 1259 | if addr > len(self.data) - pagesize: 1260 | addr = len(self.data) - pagesize 1261 | if addr < 0: 1262 | addr = 0 1263 | 1264 | if addr != self.address: 1265 | self.address = addr 1266 | self.draw() 1267 | self.draw_cursor() 1268 | 1269 | def plus_offset(self): 1270 | '''add offset''' 1271 | 1272 | self.addaddr.prompt = '@+' 1273 | self.ignore_focus = True 1274 | self.addaddr.show() 1275 | ret = self.addaddr.runloop() 1276 | if ret != textmode.ENTER: 1277 | return 1278 | 1279 | text = self.addaddr.textfield.text 1280 | text = text.replace(' ', '') 1281 | if not text: 1282 | return 1283 | 1284 | try: 1285 | if text[0] in '0ABCDEF': 1286 | offset = int(text, 16) 1287 | else: 1288 | offset = int(text, 10) 1289 | except ValueError: 1290 | self.search_error('Invalid address') 1291 | return 1292 | 1293 | curr_addr = self.address + self.cursor_y * 16 + self.cursor_x 1294 | addr = curr_addr + offset 1295 | if addr < 0: 1296 | addr = 0 1297 | 1298 | if addr >= len(self.data): 1299 | addr = len(self.data) - 1 1300 | if addr < 0: 1301 | addr = 0 1302 | 1303 | if addr == curr_addr: 1304 | return 1305 | 1306 | pagesize = self.bounds.h * 16 1307 | if self.address <= addr < self.address + pagesize: 1308 | # move the cursor 1309 | self.clear_cursor() 1310 | else: 1311 | # move base address 1312 | self.address = addr 1313 | if self.address > len(self.data) - pagesize: 1314 | self.address = len(self.data) - pagesize 1315 | self.draw() 1316 | 1317 | self.cursor_x = (addr - self.address) % 16 1318 | self.cursor_y = (addr - self.address) // 16 1319 | self.draw_cursor() 1320 | 1321 | def minus_offset(self): 1322 | '''minus offset''' 1323 | 1324 | self.addaddr.prompt = '@-' 1325 | self.ignore_focus = True 1326 | self.addaddr.show() 1327 | ret = self.addaddr.runloop() 1328 | if ret != textmode.ENTER: 1329 | return 1330 | 1331 | text = self.addaddr.textfield.text 1332 | text = text.replace(' ', '') 1333 | if not text: 1334 | return 1335 | 1336 | try: 1337 | if text[0] in '0ABCDEF': 1338 | offset = int(text, 16) 1339 | else: 1340 | offset = int(text, 10) 1341 | except ValueError: 1342 | self.search_error('Invalid address') 1343 | return 1344 | 1345 | curr_addr = self.address + self.cursor_y * 16 + self.cursor_x 1346 | addr = curr_addr - offset 1347 | if addr < 0: 1348 | addr = 0 1349 | 1350 | if addr >= len(self.data): 1351 | addr = len(self.data) - 1 1352 | if addr < 0: 1353 | addr = 0 1354 | 1355 | if addr == curr_addr: 1356 | return 1357 | 1358 | pagesize = self.bounds.h * 16 1359 | if self.address <= addr < self.address + pagesize: 1360 | # move the cursor 1361 | self.clear_cursor() 1362 | else: 1363 | # move base address 1364 | self.address = addr 1365 | if self.address > len(self.data) - pagesize: 1366 | self.address = len(self.data) - pagesize 1367 | self.draw() 1368 | 1369 | self.cursor_x = (addr - self.address) % 16 1370 | self.cursor_y = (addr - self.address) // 16 1371 | self.draw_cursor() 1372 | 1373 | def copy_address(self): 1374 | '''copy current address to jump history''' 1375 | 1376 | addr = self.address + self.cursor_y * 16 + self.cursor_x 1377 | self.jumpaddr.textfield.history.append('{:08X}'.format(addr)) 1378 | 1379 | # give visual feedback 1380 | color = textmode.video_color(WHITE, RED, bold=True) 1381 | self.mark_address(self.cursor_y, color) 1382 | self.draw_cursor(mark=color) 1383 | 1384 | def move_begin_line(self): 1385 | '''goto beginning of line''' 1386 | 1387 | if self.cursor_x != 0: 1388 | self.clear_cursor() 1389 | self.cursor_x = 0 1390 | self.draw_cursor() 1391 | 1392 | def move_end_line(self): 1393 | '''goto end of line''' 1394 | 1395 | if self.cursor_x != 15: 1396 | self.clear_cursor() 1397 | self.cursor_x = 15 1398 | self.draw_cursor() 1399 | 1400 | def move_top(self): 1401 | '''goto top of screen''' 1402 | 1403 | if self.cursor_y != 0: 1404 | self.clear_cursor() 1405 | self.cursor_y = 0 1406 | self.draw_cursor() 1407 | 1408 | def move_middle(self): 1409 | '''goto middle of screen''' 1410 | 1411 | y = self.bounds.h // 2 1412 | if self.cursor_y != y: 1413 | self.clear_cursor() 1414 | self.cursor_y = y 1415 | self.draw_cursor() 1416 | 1417 | def move_bottom(self): 1418 | '''goto bottom of screen''' 1419 | 1420 | if self.cursor_y != self.bounds.h - 1: 1421 | self.clear_cursor() 1422 | self.cursor_y = self.bounds.h - 1 1423 | self.draw_cursor() 1424 | 1425 | def move_word(self): 1426 | '''move to next word''' 1427 | 1428 | end = len(self.data) - 1 1429 | addr = self.address + self.cursor_y * 16 + self.cursor_x 1430 | 1431 | if isalphanum(self.data[addr]): 1432 | while isalphanum(self.data[addr]) and addr < end: 1433 | addr += 1 1434 | 1435 | while isspace(self.data[addr]) and addr < end: 1436 | addr += 1 1437 | 1438 | if addr == self.address: 1439 | return 1440 | 1441 | pagesize = self.bounds.h * 16 1442 | if self.address < addr < self.address + pagesize: 1443 | # only move cursor 1444 | self.clear_cursor() 1445 | diff = addr - self.address 1446 | self.cursor_y = diff // 16 1447 | self.cursor_x = diff % 16 1448 | else: 1449 | # scroll page 1450 | # round up to nearest 16 1451 | addr2 = addr 1452 | mod = addr2 % 16 1453 | if mod != 0: 1454 | addr2 += 16 - mod 1455 | else: 1456 | addr2 += 16 1457 | self.address = addr2 - pagesize 1458 | diff = addr - self.address 1459 | self.cursor_y = diff // 16 1460 | self.cursor_x = diff % 16 1461 | self.draw() 1462 | 1463 | self.draw_cursor() 1464 | 1465 | def move_word_back(self): 1466 | '''move to previous word''' 1467 | 1468 | addr = self.address + self.cursor_y * 16 + self.cursor_x 1469 | 1470 | # skip back over any spaces 1471 | while addr > 0 and isspace(self.data[addr - 1]): 1472 | addr -= 1 1473 | 1474 | # move to beginning of word 1475 | while addr > 0 and isalphanum(self.data[addr - 1]): 1476 | addr -= 1 1477 | 1478 | pagesize = self.bounds.h * 16 1479 | if self.address < addr < self.address + pagesize: 1480 | # only move cursor 1481 | self.clear_cursor() 1482 | diff = addr - self.address 1483 | self.cursor_y = diff // 16 1484 | self.cursor_x = diff % 16 1485 | else: 1486 | # scroll page 1487 | # round up to nearest 16 1488 | addr2 = addr 1489 | mod = addr2 % 16 1490 | if mod != 0: 1491 | addr2 += 16 - mod 1492 | else: 1493 | addr2 += 16 1494 | self.address = addr2 - pagesize 1495 | if self.address < 0: 1496 | self.address = 0 1497 | diff = addr - self.address 1498 | self.cursor_y = diff // 16 1499 | self.cursor_x = diff % 16 1500 | self.draw() 1501 | 1502 | self.draw_cursor() 1503 | 1504 | def command(self): 1505 | '''command mode 1506 | Returns 0 (do nothing) or app code 1507 | ''' 1508 | 1509 | self.ignore_focus = True 1510 | self.cmdline.show() 1511 | ret = self.cmdline.runloop() 1512 | if ret != textmode.ENTER: 1513 | return 0 1514 | 1515 | cmd = self.cmdline.textfield.text 1516 | if ' ' in cmd: 1517 | cmd, arg = cmd.split(' ', 1) 1518 | else: 1519 | arg = None 1520 | if not cmd: 1521 | return 0 1522 | 1523 | if cmd == 'help' or cmd == '?': # pylint: disable=consider-using-in 1524 | self.show_help() 1525 | 1526 | elif cmd in ('about', 'version'): 1527 | self.show_about() 1528 | 1529 | elif cmd == 'license': 1530 | self.show_license() 1531 | 1532 | elif cmd in ('q', 'q!', 'quit'): 1533 | return textmode.QUIT 1534 | 1535 | elif cmd in ('wq', 'wq!', 'ZZ', 'exit'): 1536 | return textmode.EXIT 1537 | 1538 | elif cmd == 'load': 1539 | self.loadfile(arg) 1540 | 1541 | elif cmd in ('print', 'values'): 1542 | self.print_values() 1543 | 1544 | elif cmd == 'big': 1545 | self.set_big_endian() 1546 | 1547 | elif cmd == 'little': 1548 | self.set_little_endian() 1549 | 1550 | elif cmd == '0': 1551 | self.move_home() 1552 | 1553 | else: 1554 | self.ignore_focus = True 1555 | self.cmdline.show() 1556 | self.cmdline.cputs(0, 0, "Unknown command '{}'".format(cmd), 1557 | textmode.video_color(WHITE, RED, bold=True)) 1558 | getch() 1559 | self.cmdline.hide() 1560 | 1561 | return 0 1562 | 1563 | def loadfile(self, filename): 1564 | '''load file''' 1565 | 1566 | if not filename: 1567 | return 1568 | 1569 | if filename[0] == '~': 1570 | filename = os.path.expanduser(filename) 1571 | if '$' in filename: 1572 | filename = os.path.expandvars(filename) 1573 | try: 1574 | self.load(filename) 1575 | except OSError as err: 1576 | self.ignore_focus = True 1577 | self.cmdline.show() 1578 | self.cmdline.cputs(0, 0, err.strerror, 1579 | textmode.video_color(WHITE, RED, bold=True)) 1580 | getch() 1581 | self.cmdline.hide() 1582 | else: 1583 | self.draw() 1584 | self.draw_cursor() 1585 | 1586 | def show_help(self): 1587 | '''show help window''' 1588 | 1589 | win = HelpWindow(self) 1590 | win.show() 1591 | win.runloop() 1592 | win.close() 1593 | 1594 | def show_about(self): 1595 | '''show About box''' 1596 | 1597 | win = AboutBox() 1598 | win.show() 1599 | win.runloop() 1600 | 1601 | def show_license(self): 1602 | '''show the software license''' 1603 | 1604 | win = LicenseBox() 1605 | win.show() 1606 | if win.runloop() == 0: 1607 | # license not accepted 1608 | textmode.terminate() 1609 | print ("If you do not accept the license, you really shouldn't " 1610 | "be using this software") 1611 | sys.exit(1) 1612 | 1613 | def print_values(self): 1614 | '''toggle values subwindow''' 1615 | 1616 | self.mode ^= HexWindow.MODE_VALUES 1617 | if self.mode & HexWindow.MODE_VALUES: 1618 | self.shrink_window(self.valueview.frame.h - 1) 1619 | self.valueview.show() 1620 | else: 1621 | self.valueview.hide() 1622 | self.expand_window(self.valueview.frame.h - 1) 1623 | 1624 | self.draw() 1625 | self.draw_cursor() 1626 | self.update_values() 1627 | 1628 | def shrink_window(self, lines): 1629 | '''shrink main window by n lines''' 1630 | 1631 | self.hide() 1632 | self.frame.h -= lines 1633 | self.bounds.h -= lines 1634 | self.rect.h -= lines 1635 | self.show() 1636 | 1637 | if self.cursor_y > self.bounds.h - 1: 1638 | self.cursor_y = self.bounds.h - 1 1639 | 1640 | def expand_window(self, lines): 1641 | '''grow main window by n lines''' 1642 | 1643 | self.hide() 1644 | self.frame.h += lines 1645 | self.bounds.h += lines 1646 | self.rect.h += lines 1647 | self.show() 1648 | 1649 | def toggle_endianness(self): 1650 | '''toggle endianness in values subwindow''' 1651 | 1652 | if self.valueview.endian == ValueSubWindow.BIG_ENDIAN: 1653 | self.valueview.endian = ValueSubWindow.LITTLE_ENDIAN 1654 | else: 1655 | self.valueview.endian = ValueSubWindow.BIG_ENDIAN 1656 | 1657 | self.update_values() 1658 | self.valueview.update_status() 1659 | 1660 | def set_big_endian(self): 1661 | '''set big endian mode''' 1662 | 1663 | if self.valueview.endian != ValueSubWindow.BIG_ENDIAN: 1664 | self.valueview.endian = ValueSubWindow.BIG_ENDIAN 1665 | self.update_values() 1666 | self.valueview.update_status() 1667 | 1668 | def set_little_endian(self): 1669 | '''set little endian mode''' 1670 | 1671 | if self.valueview.endian != ValueSubWindow.LITTLE_ENDIAN: 1672 | self.valueview.endian = ValueSubWindow.LITTLE_ENDIAN 1673 | self.update_values() 1674 | self.valueview.update_status() 1675 | 1676 | def runloop(self): 1677 | '''run the input loop 1678 | Returns state change code 1679 | ''' 1680 | 1681 | self.gain_focus() 1682 | while True: 1683 | self.old_addr = self.address 1684 | self.old_x = self.cursor_x 1685 | self.old_y = self.cursor_y 1686 | 1687 | key = getch() 1688 | 1689 | if key == KEY_ESC: 1690 | if self.mode & HexWindow.MODE_SELECT: 1691 | self.mode_selection() 1692 | 1693 | elif key == KEY_UP or key == 'k': # pylint: disable=consider-using-in 1694 | self.move_up() 1695 | 1696 | elif key == KEY_DOWN or key == 'j': # pylint: disable=consider-using-in 1697 | self.move_down() 1698 | 1699 | elif key == KEY_LEFT or key == 'h': # pylint: disable=consider-using-in 1700 | self.move_left() 1701 | 1702 | elif key == KEY_RIGHT or key == 'l': # pylint: disable=consider-using-in 1703 | self.move_right() 1704 | 1705 | elif key == '<' or key == ',': # pylint: disable=consider-using-in 1706 | self.roll_left() 1707 | 1708 | elif key == '>' or key == '.': # pylint: disable=consider-using-in 1709 | self.roll_right() 1710 | 1711 | elif key == KEY_PAGEUP or key == 'Ctrl-U': # pylint: disable=consider-using-in 1712 | self.pageup() 1713 | 1714 | elif key == KEY_PAGEDOWN or key == 'Ctrl-D': # pylint: disable=consider-using-in 1715 | self.pagedown() 1716 | 1717 | elif key == KEY_HOME or key == 'g': # pylint: disable=consider-using-in 1718 | self.move_home() 1719 | 1720 | elif key == KEY_END or key == 'G': # pylint: disable=consider-using-in 1721 | self.move_end() 1722 | 1723 | elif key in ('1', '2', '3', '4', '5'): 1724 | self.select_view(key) 1725 | 1726 | elif key == 'v': 1727 | self.mode_selection() 1728 | 1729 | elif key == ':': 1730 | # command mode 1731 | ret = self.command() 1732 | if ret != 0: 1733 | return ret 1734 | 1735 | elif key == '?': 1736 | # find backwards 1737 | self.find_backwards() 1738 | 1739 | elif key == '/' or key == 'Ctrl-F': # pylint: disable=consider-using-in 1740 | self.find() 1741 | 1742 | elif key == 'n' or key == 'Ctrl-G': # pylint: disable=consider-using-in 1743 | # search again 1744 | if self.searchdir == HexWindow.FORWARD: 1745 | self.find(again=True) 1746 | elif self.searchdir == HexWindow.BACKWARD: 1747 | self.find_backwards(again=True) 1748 | 1749 | elif key == 'x' or key == 'Ctrl-X': # pylint: disable=consider-using-in 1750 | self.find_hex() 1751 | 1752 | elif key == '0' or key == '^': # pylint: disable=consider-using-in 1753 | self.move_begin_line() 1754 | 1755 | elif key == '$': 1756 | self.move_end_line() 1757 | 1758 | elif key == 'H': 1759 | self.move_top() 1760 | 1761 | elif key == 'M': 1762 | self.move_middle() 1763 | 1764 | elif key == 'L': 1765 | self.move_bottom() 1766 | 1767 | elif key == '@': 1768 | self.jump_address() 1769 | 1770 | elif key == '+': 1771 | self.plus_offset() 1772 | 1773 | elif key == '-': 1774 | self.minus_offset() 1775 | 1776 | elif key == 'm': 1777 | self.copy_address() 1778 | 1779 | elif key == 'w': 1780 | self.move_word() 1781 | 1782 | elif key == 'b': 1783 | self.move_word_back() 1784 | 1785 | elif key == 'p': 1786 | self.print_values() 1787 | 1788 | elif key == 'P': 1789 | self.toggle_endianness() 1790 | 1791 | 1792 | 1793 | class ValueSubWindow(textmode.Window): 1794 | '''subwindow that shows values''' 1795 | 1796 | BIG_ENDIAN = 1 1797 | LITTLE_ENDIAN = 2 1798 | 1799 | def __init__(self, x, y, w, h, colors): 1800 | '''initialize''' 1801 | 1802 | super().__init__(x, y, w, h, colors, 'Values', border=True, shadow=False) 1803 | if sys.byteorder == 'big': 1804 | self.endian = ValueSubWindow.BIG_ENDIAN 1805 | else: 1806 | self.endian = ValueSubWindow.LITTLE_ENDIAN 1807 | 1808 | def resize_event(self): 1809 | '''the terminal was resized''' 1810 | 1811 | # use fixed width and height, but 1812 | # the position may change; stick to bottom 1813 | 1814 | self.frame.y = textmode.VIDEO.h - self.frame.h - 1 1815 | if self.has_border: 1816 | self.bounds.y = self.frame.y + 1 1817 | else: 1818 | self.bounds.y = self.frame.y 1819 | 1820 | self.rect.y = self.frame.y 1821 | 1822 | def draw(self): 1823 | '''draw the value subwindow''' 1824 | 1825 | super().draw() 1826 | 1827 | # draw statusline 1828 | self.update_status() 1829 | 1830 | def update(self, data): 1831 | '''show the values for data''' 1832 | 1833 | int8 = struct.unpack_from('@b', data)[0] 1834 | uint8 = struct.unpack_from('@B', data)[0] 1835 | 1836 | if self.endian == ValueSubWindow.BIG_ENDIAN: 1837 | fmt = '>' 1838 | elif self.endian == ValueSubWindow.LITTLE_ENDIAN: 1839 | fmt = '<' 1840 | else: 1841 | fmt = '=' 1842 | 1843 | int16 = struct.unpack_from(fmt + 'h', data)[0] 1844 | uint16 = struct.unpack_from(fmt + 'H', data)[0] 1845 | int32 = struct.unpack_from(fmt + 'i', data)[0] 1846 | uint32 = struct.unpack_from(fmt + 'I', data)[0] 1847 | int64 = struct.unpack_from(fmt + 'q', data)[0] 1848 | uint64 = struct.unpack_from(fmt + 'Q', data)[0] 1849 | float32 = struct.unpack_from(fmt + 'f', data)[0] 1850 | float64 = struct.unpack_from(fmt + 'd', data)[0] 1851 | 1852 | line = ' int8 : {:<20} uint8 : {:<20} 0x{:02x}'.format(int8, uint8, uint8) # pylint: disable=(duplicate-string-formatting-argument 1853 | self.puts(0, 0, line, self.colors.text) 1854 | 1855 | line = ' int16: {:<20} uint16: {:<20} 0x{:04x}'.format(int16, uint16, uint16) # pylint: disable=(duplicate-string-formatting-argument 1856 | self.puts(0, 1, line, self.colors.text) 1857 | 1858 | line = ' int32: {:<20} uint32: {:<20} 0x{:08x}'.format(int32, uint32, uint32) # pylint: disable=(duplicate-string-formatting-argument 1859 | self.puts(0, 2, line, self.colors.text) 1860 | 1861 | line = ' int64: {:<20} uint64: {:<20} 0x{:016x}'.format(int64, uint64, uint64) # pylint: disable=(duplicate-string-formatting-argument 1862 | self.puts(0, 3, line, self.colors.text) 1863 | 1864 | line = ' float: {:<20} double: {:<20} 0x{:016x}'.format(float32, float64, uint64) # pylint: disable=(duplicate-string-formatting-argument 1865 | self.puts(0, 4, line, self.colors.text) 1866 | 1867 | def update_status(self): 1868 | '''redraw statusline''' 1869 | 1870 | if self.endian == ValueSubWindow.BIG_ENDIAN: 1871 | text = ' big endian ' 1872 | else: 1873 | text = ' little endian ' 1874 | 1875 | w = len(text) 1876 | 1877 | textmode.VIDEO.hline(self.frame.x + self.frame.w - 20, 1878 | self.frame.y + self.frame.h - 1, 18, 1879 | curses.ACS_HLINE, self.colors.border) 1880 | textmode.VIDEO.puts(self.frame.x + self.frame.w - w - 1, 1881 | self.frame.y + self.frame.h - 1, text, 1882 | self.colors.status) 1883 | 1884 | 1885 | 1886 | def bytearray_find_backwards(data, search, pos=-1): 1887 | '''search bytearray backwards for string 1888 | Returns index if found or -1 if not found 1889 | May raise ValueError for invalid search 1890 | ''' 1891 | 1892 | if data is None or not data: 1893 | raise ValueError 1894 | 1895 | if search is None or not search: 1896 | raise ValueError 1897 | 1898 | if pos == -1: 1899 | pos = len(data) 1900 | 1901 | if pos < 0: 1902 | return ValueError 1903 | 1904 | pos -= len(search) 1905 | while pos >= 0: 1906 | if data[pos:pos + len(search)] == search: 1907 | return pos 1908 | 1909 | pos -= 1 1910 | 1911 | return -1 1912 | 1913 | 1914 | def hex_inputfilter(key): 1915 | '''hexadecimal input filter 1916 | Returns character or None if invalid 1917 | ''' 1918 | 1919 | val = ord(key) 1920 | if (ord('0') <= val <= ord('9') or 1921 | ord('a') <= val <= ord('f') or 1922 | ord('A') <= val <= ord('F') or 1923 | val == ord(' ')): 1924 | if ord('a') <= val <= ord('f'): 1925 | key = key.upper() 1926 | return key 1927 | 1928 | return None 1929 | 1930 | 1931 | def isalphanum(ch): 1932 | '''Returns True if character is alphanumeric''' 1933 | 1934 | return (ord('0') <= ch <= ord('9') or 1935 | ord('a') <= ch <= ord('z') or 1936 | ord('A') <= ch <= ord('Z') or 1937 | ch == ord('_')) 1938 | 1939 | 1940 | def isspace(ch): 1941 | '''Returns True if character is treated as space''' 1942 | 1943 | return not isalphanum(ch) 1944 | 1945 | 1946 | class CommandBar(textmode.CmdLine): 1947 | '''command bar 1948 | Same as CmdLine, but backspace can exit the command mode 1949 | ''' 1950 | 1951 | def __init__(self, colors, prompt=None, inputfilter=None): 1952 | '''initialize''' 1953 | 1954 | x = 0 1955 | y = textmode.VIDEO.h - 1 1956 | w = textmode.VIDEO.w 1957 | super().__init__(x, y, w, colors, prompt) 1958 | 1959 | if self.prompt is not None: 1960 | x += len(self.prompt) 1961 | w -= len(self.prompt) 1962 | if w < 1: 1963 | w = 1 1964 | 1965 | self.textfield = CommandField(self, x, self.bounds.y, w, colors, 1966 | True, inputfilter) 1967 | 1968 | def resize_event(self): 1969 | '''the terminal was resized''' 1970 | 1971 | self.frame.w = self.bounds.w = self.rect.w = textmode.VIDEO.w 1972 | self.frame.y = self.bounds.y = self.rect.y = textmode.VIDEO.h - 1 1973 | 1974 | w = textmode.VIDEO.w 1975 | if self.prompt is not None: 1976 | w -= len(self.prompt) 1977 | if w < 1: 1978 | w = 1 1979 | 1980 | self.textfield.y = textmode.VIDEO.h - 1 1981 | self.textfield.w = w 1982 | 1983 | 1984 | 1985 | class CommandField(textmode.TextField): 1986 | '''command bar edit field 1987 | Same as TextField, but backspace can exit the command mode 1988 | ''' 1989 | 1990 | def runloop(self): 1991 | '''run the CommandField 1992 | Same as TextField, but backspace can exit 1993 | ''' 1994 | 1995 | # reset the text 1996 | self.text = '' 1997 | self.cursor = 0 1998 | self.draw() 1999 | 2000 | self.gain_focus() 2001 | 2002 | while True: 2003 | key = getch() 2004 | if key == KEY_ESC: 2005 | self.text = '' 2006 | self.cursor = 0 2007 | self.lose_focus() 2008 | self.clear() 2009 | return textmode.RETURN_TO_PREVIOUS 2010 | 2011 | if key == KEY_BTAB: 2012 | self.lose_focus() 2013 | self.clear() 2014 | return textmode.BACK 2015 | 2016 | if key == KEY_TAB: 2017 | self.lose_focus() 2018 | self.clear() 2019 | return textmode.NEXT 2020 | 2021 | if key == KEY_RETURN: 2022 | self.add_history() 2023 | self.lose_focus() 2024 | self.clear() 2025 | return textmode.ENTER 2026 | 2027 | if key == KEY_BS: 2028 | if self.cursor > 0: 2029 | self.text = (self.text[:self.cursor - 1] + 2030 | self.text[self.cursor:]) 2031 | self.cursor -= 1 2032 | self.draw() 2033 | 2034 | elif self.cursor == 0 and not self.text: 2035 | # exit 2036 | self.lose_focus() 2037 | self.clear() 2038 | return textmode.RETURN_TO_PREVIOUS 2039 | 2040 | elif key == KEY_DEL: 2041 | if self.cursor < len(self.text): 2042 | self.text = (self.text[:self.cursor] + 2043 | self.text[self.cursor + 1:]) 2044 | self.draw() 2045 | 2046 | elif self.cursor == 0 and not self.text: 2047 | # exit 2048 | self.lose_focus() 2049 | self.clear() 2050 | return textmode.RETURN_TO_PREVIOUS 2051 | 2052 | elif key == KEY_LEFT: 2053 | if self.cursor > 0: 2054 | self.cursor -= 1 2055 | self.draw() 2056 | 2057 | elif key == KEY_RIGHT: 2058 | if self.cursor < len(self.text): 2059 | self.cursor += 1 2060 | self.draw() 2061 | 2062 | elif key == KEY_HOME: 2063 | if self.cursor > 0: 2064 | self.cursor = 0 2065 | self.draw() 2066 | 2067 | elif key == KEY_END: 2068 | if self.cursor != len(self.text): 2069 | self.cursor = len(self.text) 2070 | self.draw() 2071 | 2072 | elif key == KEY_UP: 2073 | self.recall_up() 2074 | 2075 | elif key == KEY_DOWN: 2076 | self.recall_down() 2077 | 2078 | elif len(key) == 1 and len(self.text) < self.w: 2079 | if self.inputfilter is not None: 2080 | ch = self.inputfilter(key) 2081 | else: 2082 | ch = self.default_inputfilter(key) 2083 | 2084 | if ch is not None: 2085 | self.text = (self.text[:self.cursor] + ch + 2086 | self.text[self.cursor:]) 2087 | self.cursor += 1 2088 | self.draw() 2089 | 2090 | 2091 | 2092 | class HelpWindow(textmode.TextWindow): 2093 | '''displays usage information''' 2094 | 2095 | def __init__(self, parent): 2096 | '''initialize''' 2097 | 2098 | self.parent = parent 2099 | 2100 | text = '''Command keys 2101 | : Enter command mode 2102 | / Ctrl-F Find 2103 | ? Find backwards 2104 | n Ctrl-G Find again 2105 | x Ctrl-X Find hexadecimal 2106 | 2107 | 1 View single bytes 2108 | 2 View 16-bit words 2109 | 4 View 32-bit words 2110 | p Toggle printed values 2111 | P Toggle endianness 2112 | < Roll left 2113 | > Roll right 2114 | v Toggle selection mode 2115 | 2116 | @ Jump to address 2117 | m Mark; copy address to 2118 | jump history 2119 | + Add offset 2120 | - Minus offset 2121 | 2122 | hjkl arrows Move cursor 2123 | Ctrl-U PageUp Go one page up 2124 | Ctrl-D PageDown Go one page down 2125 | g Home Go to top 2126 | G End Go to end 2127 | ^ 0 Go to start of line 2128 | $ Go to end of line 2129 | H Go to top of screen 2130 | M Go to middle of screen 2131 | L Go to bottom of screen 2132 | w Go to next ASCII word 2133 | b Go to previous ASCII word 2134 | 2135 | Ctrl-R Redraw screen 2136 | Ctrl-Q Force quit 2137 | 2138 | Commands 2139 | :0 Go to top 2140 | :print :values Toggle printed values 2141 | :big Set big endian mode 2142 | :little Set little endian mode 2143 | :load FILENAME Load alternate file 2144 | :help :? Show this information 2145 | :license Show software license 2146 | :about :version Show About box 2147 | :q :q! Quit''' 2148 | 2149 | colors = textmode.ColorSet(BLACK, WHITE) 2150 | colors.title = textmode.video_color(RED, WHITE) 2151 | colors.cursor = textmode.video_color(BLACK, GREEN) 2152 | 2153 | w = 52 2154 | h = textmode.VIDEO.h - 6 2155 | if h < 4: 2156 | h = 4 2157 | x = textmode.center_x(w, self.parent.frame.w) 2158 | y = textmode.center_y(h, textmode.VIDEO.h) 2159 | 2160 | super().__init__(x, y, w, h, colors, title='Help', border=True, 2161 | text=text.split('\n'), scrollbar=False, status=False) 2162 | 2163 | def resize_event(self): 2164 | '''the terminal was resized''' 2165 | 2166 | w = self.frame.w 2167 | h = textmode.VIDEO.h - 6 2168 | if h < 4: 2169 | h = 4 2170 | x = textmode.center_x(w, self.parent.frame.w) 2171 | y = textmode.center_y(h, textmode.VIDEO.h) 2172 | 2173 | self.frame = Rect(x, y, w, h) 2174 | 2175 | # bounds is the inner area; for view content 2176 | if self.has_border: 2177 | self.bounds = Rect(x + 1, y + 1, w - 2, h - 2) 2178 | else: 2179 | self.bounds = self.frame 2180 | 2181 | # rect is the outer area; larger because of shadow 2182 | if self.has_shadow: 2183 | self.rect = Rect(x, y, w + 2, h + 1) 2184 | else: 2185 | self.rect = Rect(x, y, w, h) 2186 | 2187 | if self.cursor >= self.bounds.h: 2188 | self.cursor = self.bounds.h - 1 2189 | 2190 | if self.top > len(self.text) - self.bounds.h: 2191 | self.top = len(self.text) - self.bounds.h 2192 | if self.top < 0: 2193 | self.top = 0 2194 | 2195 | def runloop(self): 2196 | '''run the Help window''' 2197 | 2198 | # this is the same as for TextWindow, but the 2199 | # hexview app uses some more navigation keys 2200 | 2201 | while True: 2202 | key = getch() 2203 | 2204 | if key == KEY_ESC or key == ' ' or key == KEY_RETURN: # pylint: disable=consider-using-in 2205 | self.lose_focus() 2206 | return textmode.RETURN_TO_PREVIOUS 2207 | 2208 | if key == KEY_UP or key == 'k': # pylint: disable=consider-using-in 2209 | self.move_up() 2210 | 2211 | elif key == KEY_DOWN or key == 'j': # pylint: disable=consider-using-in 2212 | self.move_down() 2213 | 2214 | elif key == KEY_PAGEUP or key == 'Ctrl-U': # pylint: disable=consider-using-in 2215 | self.pageup() 2216 | 2217 | elif key == KEY_PAGEDOWN or key == 'Ctrl-D': # pylint: disable=consider-using-in 2218 | self.pagedown() 2219 | 2220 | elif key == KEY_HOME or key == 'g': # pylint: disable=consider-using-in 2221 | self.goto_top() 2222 | 2223 | elif key == KEY_END or key == 'G': # pylint: disable=consider-using-in 2224 | self.goto_bottom() 2225 | 2226 | 2227 | 2228 | class LicenseBox(textmode.Alert): 2229 | '''shows software license''' 2230 | 2231 | def __init__(self): 2232 | '''initialize''' 2233 | 2234 | text = '''Copyright (c) 2016 Walter de Jong 2235 | 2236 | Permission is hereby granted, free of charge, to any person obtaining a copy 2237 | of this software and associated documentation files (the "Software"), to deal 2238 | in the Software without restriction, including without limitation the rights 2239 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 2240 | copies of the Software, and to permit persons to whom the Software is 2241 | furnished to do so, subject to the following conditions: 2242 | 2243 | The above copyright notice and this permission notice shall be included in 2244 | all copies or substantial portions of the Software. 2245 | 2246 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 2247 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 2248 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 2249 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 2250 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 2251 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 2252 | THE SOFTWARE.''' 2253 | 2254 | colors = textmode.ColorSet(BLACK, WHITE) 2255 | colors.title = textmode.video_color(RED, WHITE) 2256 | colors.button = textmode.video_color(WHITE, BLUE, bold=True) 2257 | colors.buttonhotkey = textmode.video_color(YELLOW, BLUE, bold=True) 2258 | colors.activebutton = textmode.video_color(WHITE, GREEN, bold=True) 2259 | colors.activebuttonhotkey = textmode.video_color(YELLOW, GREEN, 2260 | bold=True) 2261 | super().__init__(colors, 'License', text, ['ecline', 'ccept'], 2262 | default=1, center_text=False) 2263 | 2264 | 2265 | 2266 | class AboutBox(textmode.Alert): 2267 | '''about box''' 2268 | 2269 | def __init__(self): 2270 | '''initialize''' 2271 | 2272 | text = '''HexView 2273 | --------{} 2274 | version {} 2275 | 2276 | Copyright 2016 by 2277 | Walter de Jong 2278 | 2279 | This is free software, available 2280 | under terms of the MIT license'''.format('-' * len(VERSION), VERSION) 2281 | 2282 | colors = textmode.ColorSet(BLACK, WHITE) 2283 | colors.title = textmode.video_color(RED, WHITE) 2284 | colors.button = textmode.video_color(WHITE, BLUE, bold=True) 2285 | colors.buttonhotkey = textmode.video_color(YELLOW, BLUE, bold=True) 2286 | colors.activebutton = textmode.video_color(WHITE, GREEN, bold=True) 2287 | colors.activebuttonhotkey = textmode.video_color(YELLOW, GREEN, 2288 | bold=True) 2289 | super().__init__(colors, 'About', text) 2290 | 2291 | def draw(self): 2292 | '''draw the About box''' 2293 | 2294 | super().draw() 2295 | 2296 | # draw pretty horizontal line in text 2297 | w = len(VERSION) + 8 2298 | x = self.bounds.x + textmode.center_x(w, self.bounds.w) 2299 | textmode.VIDEO.hline(x, self.frame.y + 3, w, curses.ACS_HLINE, 2300 | self.colors.text) 2301 | 2302 | 2303 | 2304 | def hexview_main(filename): 2305 | '''main program''' 2306 | 2307 | colors = textmode.ColorSet(BLACK, CYAN) 2308 | colors.cursor = textmode.video_color(WHITE, BLACK, bold=True) 2309 | colors.status = colors.cursor 2310 | colors.invisibles = textmode.video_color(BLUE, CYAN, bold=True) 2311 | 2312 | view = HexWindow(0, 0, 80, textmode.VIDEO.h - 1, colors) 2313 | try: 2314 | view.load(filename) 2315 | except OSError as err: 2316 | textmode.terminate() 2317 | print('{}: {}'.format(filename, err.strerror)) 2318 | sys.exit(-1) 2319 | 2320 | view.show() 2321 | 2322 | textmode.VIDEO.puts(0, textmode.VIDEO.h - 1, 2323 | 'Enter :help for usage information', 2324 | textmode.video_color(WHITE, BLACK)) 2325 | view.runloop() 2326 | 2327 | 2328 | def short_usage(): 2329 | '''print short usage information and exit''' 2330 | 2331 | print('usage: {} [options] '.format(os.path.basename(sys.argv[0]))) 2332 | sys.exit(1) 2333 | 2334 | 2335 | def usage(): 2336 | '''print usage information and exit''' 2337 | 2338 | print('usage: {} [options] '.format(os.path.basename(sys.argv[0]))) 2339 | print('''options: 2340 | -h, --help Show this information 2341 | --no-color Disable colors 2342 | --ascii-lines Use plain ASCII for line drawing 2343 | --no-lines Disable all line drawing 2344 | --no-hlines Disable horizontal lines 2345 | --no-vlines Disable vertical lines 2346 | -v, --version Display version and exit 2347 | ''') 2348 | sys.exit(1) 2349 | 2350 | 2351 | def get_options(): 2352 | '''parse command line options''' 2353 | 2354 | global OPT_LINEMODE 2355 | 2356 | try: 2357 | opts, args = getopt.getopt(sys.argv[1:], 'hv', 2358 | ['help', 'no-color', 'no-lines', 2359 | 'ascii-lines', 'no-hlines', 'no-vlines', 2360 | 'version']) 2361 | except getopt.GetoptError: 2362 | short_usage() 2363 | 2364 | for opt, _ in opts: 2365 | if opt in ('-h', '--help'): 2366 | usage() 2367 | 2368 | elif opt == '--no-color': 2369 | textmode.WANT_COLORS = False 2370 | 2371 | elif opt == '--no-lines': 2372 | OPT_LINEMODE = 0 2373 | 2374 | elif opt == '--ascii-lines': 2375 | OPT_LINEMODE |= textmode.LM_ASCII 2376 | 2377 | elif opt == '--no-hlines': 2378 | OPT_LINEMODE &= ~textmode.LM_HLINE 2379 | 2380 | elif opt == '--no-vlines': 2381 | OPT_LINEMODE &= ~textmode.LM_VLINE 2382 | 2383 | elif opt in ('-v', '--version'): 2384 | print('hexview version {}'.format(VERSION)) 2385 | print('Copyright 2016 by Walter de Jong ') 2386 | sys.exit(1) 2387 | 2388 | if not args: 2389 | short_usage() 2390 | 2391 | # return just one filename 2392 | return args[0] 2393 | 2394 | 2395 | 2396 | if __name__ == '__main__': 2397 | filename_ = get_options() 2398 | 2399 | textmode.init() 2400 | textmode.linemode(OPT_LINEMODE) 2401 | 2402 | try: 2403 | hexview_main(filename_) 2404 | finally: 2405 | textmode.terminate() 2406 | 2407 | # EOB 2408 | -------------------------------------------------------------------------------- /hexviewlib/textmode.py: -------------------------------------------------------------------------------- 1 | # 2 | # textmode.py WJ116 3 | # 4 | # Copyright 2016 by Walter de Jong 5 | # 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | # 13 | # The above copyright notice and this permission notice shall be included in 14 | # all copies or substantial portions of the Software. 15 | # 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | # THE SOFTWARE. 23 | 24 | '''classes and routines for text mode screens''' 25 | 26 | import curses 27 | import os 28 | import re 29 | import sys 30 | import time 31 | import traceback 32 | 33 | # the main video object 34 | VIDEO = None 35 | 36 | # the curses stdscr 37 | STDSCR = None 38 | 39 | # color codes 40 | (BLACK, 41 | BLUE, 42 | GREEN, 43 | CYAN, 44 | RED, 45 | MAGENTA, 46 | YELLOW, 47 | WHITE) = range(0, 8) 48 | BOLD = 8 49 | 50 | # list of curses color codes 51 | # initialized during init() (because curses is crazy; it doesn't have 52 | # any of it's static constants/symbols until you initialize it) 53 | CURSES_COLORS = None 54 | CURSES_COLORPAIRS = {} 55 | CURSES_COLORPAIR_IDX = 0 56 | # user wants colors 57 | WANT_COLORS = True 58 | # terminal can do color at all 59 | HAS_COLORS = False 60 | 61 | LM_HLINE = 1 62 | LM_VLINE = 2 63 | LM_ASCII = 4 64 | LINEMODE = LM_HLINE | LM_VLINE 65 | CURSES_LINES = None 66 | 67 | # curses key codes get translated to strings by getch() 68 | # There is no support for F-keys! Functions keys are evil on Macs 69 | KEY_ESC = 'ESC' 70 | KEY_RETURN = 'RETURN' 71 | KEY_TAB = 'TAB' 72 | KEY_BTAB = 'BTAB' 73 | KEY_LEFT = 'LEFT' 74 | KEY_RIGHT = 'RIGHT' 75 | KEY_UP = 'UP' 76 | KEY_DOWN = 'DOWN' 77 | KEY_PAGEUP = 'PAGEUP' 78 | KEY_PAGEDOWN = 'PAGEDOWN' 79 | KEY_HOME = 'HOME' 80 | KEY_END = 'END' 81 | KEY_DEL = 'DEL' 82 | KEY_BS = 'BS' 83 | 84 | KEY_TABLE = {'0x1b': KEY_ESC, '0x0a': KEY_RETURN, '0x0d': KEY_RETURN, 85 | '0x09': KEY_TAB, '0x161': KEY_BTAB, 86 | '0x102': KEY_DOWN, '0x103': KEY_UP, 87 | '0x104': KEY_LEFT, '0x105': KEY_RIGHT, 88 | '0x152': KEY_PAGEDOWN, '0x153': KEY_PAGEUP, 89 | '0x106': KEY_HOME, '0x168': KEY_END, 90 | '0x14a': KEY_DEL, '0x107': KEY_BS, '0x7f': KEY_BS} 91 | 92 | REGEX_HOTKEY = re.compile(r'.*<((Ctrl-)?[!-~])>.*$') 93 | 94 | # window stack 95 | STACK = None 96 | 97 | # debug messages 98 | DEBUG_LOG = [] 99 | 100 | # program states 101 | # these enums are all negative; cursor choices are positive (inc. 0) 102 | # FIXME cleanup app codes list, use the shorter codes 103 | (RETURN_TO_PREVIOUS, 104 | GOTO_MENUBAR, 105 | MENU_LEFT, 106 | MENU_RIGHT, 107 | MENU_CLOSE, 108 | CANCEL, 109 | ENTER, 110 | BACK, 111 | NEXT, 112 | EXIT, 113 | QUIT) = range(-1, -12, -1) 114 | 115 | 116 | def debug(msg): 117 | '''keep message in debug log''' 118 | 119 | DEBUG_LOG.append(msg) 120 | 121 | 122 | def dump_debug(): 123 | '''dump out the debug log''' 124 | 125 | global DEBUG_LOG 126 | 127 | for msg in DEBUG_LOG: 128 | print(msg) 129 | 130 | DEBUG_LOG = [] 131 | 132 | 133 | class ScreenBuf: 134 | '''base class for screen buffers 135 | It basically implements a monochrome screenbuf in which 136 | colors are ignored. You should however use either 137 | MonoScreenBuf or ColorScreenBuf and not directly instantiate 138 | this ScreenBuf class 139 | 140 | The text buffer holds one byte per character in 7 bits ASCII 141 | If the 8th bit is set, it is a special character (eg. a curses line) 142 | ''' 143 | 144 | # codepage defines the character set 145 | # the original charset of as IBM PC VGA screen was cp437 146 | # but it gives me artifacts now (prints '$' signs, which could be a bug?) 147 | # so use latin_1 or cp1252 148 | # Note: we must use single-byte character encoding because we use a bytearray 149 | # (which is a bad choice in today's world, but ScreenBuf mimics VGA) 150 | CODEPAGE = 'cp1252' 151 | 152 | def __init__(self, w, h): 153 | '''initialize''' 154 | 155 | assert w > 0 156 | assert h > 0 157 | 158 | self.w = w 159 | self.h = h 160 | 161 | self.textbuf = bytearray(w * h) 162 | 163 | def __getitem__(self, idx): 164 | '''Returns tuple: (ch, color) at idx 165 | idx may be an offset or tuple: (x, y) position 166 | 167 | Raises IndexError, ValueError on invalid index 168 | ''' 169 | 170 | if isinstance(idx, int): 171 | offset = idx 172 | 173 | elif isinstance(idx, tuple): 174 | x, y = idx 175 | offset = self.w * y + x 176 | 177 | else: 178 | raise ValueError('invalid argument') 179 | 180 | ch = self.textbuf[offset] 181 | if ch == 0: 182 | # blank 183 | ch = ' ' 184 | elif ch > 0x7f: 185 | # special curses character 186 | ch &= 0x7f 187 | ch |= 0x400000 188 | else: 189 | # make string 190 | ch = chr(ch) 191 | 192 | return ch, 0 193 | 194 | def __setitem__(self, idx, value): 195 | '''put tuple: (ch, color) at idx 196 | idx may be an offset or tuple: (x, y) position 197 | 198 | Raises IndexError, ValueError on invalid index 199 | or ValueError on invalid value 200 | ''' 201 | 202 | ch, _ = value 203 | 204 | if isinstance(ch, str): 205 | ch = ord(ch) 206 | elif ch > 0x400000: 207 | # special curses character 208 | ch &= 0x7f 209 | ch |= 0x80 210 | 211 | if isinstance(idx, int): 212 | offset = idx 213 | 214 | elif isinstance(idx, tuple): 215 | x, y = idx 216 | offset = self.w * y + x 217 | 218 | else: 219 | raise ValueError('invalid argument') 220 | 221 | self.textbuf[offset] = ch 222 | 223 | def puts(self, x, y, msg, color=0): 224 | '''write message into buffer at x, y''' 225 | 226 | offset = self.w * y + x 227 | w = len(msg) 228 | self.textbuf[offset:offset + w] = bytes(msg, ScreenBuf.CODEPAGE) 229 | 230 | def hline(self, x, y, w, ch, color=0): 231 | '''repeat character horizontally''' 232 | 233 | if isinstance(ch, str): 234 | ch = ord(ch) 235 | elif ch > 0x400000: 236 | # special curses character 237 | ch &= 0x7f 238 | ch |= 0x80 239 | 240 | offset = self.w * y + x 241 | self.textbuf[offset:offset + w] = bytes(chr(ch) * w, ScreenBuf.CODEPAGE) 242 | 243 | def vline(self, x, y, h, ch, color=0): 244 | '''repeat character horizontally''' 245 | 246 | if isinstance(ch, str): 247 | ch = ord(ch) 248 | elif ch > 0x400000: 249 | # special curses character 250 | ch &= 0x7f 251 | ch |= 0x80 252 | 253 | offset = self.w * y + x 254 | for _ in range(0, h): 255 | self.textbuf[offset] = ch 256 | offset += self.w 257 | 258 | def memmove(self, dst_idx, src_idx, num): 259 | '''copy num bytes at src_idx to dst_idx''' 260 | 261 | dst2 = dst_idx + num 262 | src2 = src_idx + num 263 | self.textbuf[dst_idx:dst2] = self.textbuf[src_idx:src2] 264 | 265 | def copyrect(self, dx, dy, src, sx=0, sy=0, sw=0, sh=0): 266 | '''copy src rect sx,sy,sw,sh to dest self at dx,dy''' 267 | 268 | assert isinstance(src, ScreenBuf) 269 | assert dx >= 0 270 | assert dy >= 0 271 | assert sx >= 0 272 | assert sy >= 0 273 | 274 | if sw == 0: 275 | sw = src.w 276 | if sh == 0: 277 | sh = src.h 278 | 279 | if sw > self.w: 280 | sw = self.w 281 | if sh > self.h: 282 | sh = self.h 283 | 284 | assert sw > 0 285 | assert sh > 0 286 | 287 | # local function 288 | def copyline(dst, dx, dy, src, sx, sy, sw): 289 | '''copy line at sx,sy to dest dx,dy''' 290 | 291 | si = sy * src.w + sx 292 | di = dy * dst.w + dx 293 | dst.textbuf[di:di + sw] = src.textbuf[si:si + sw] 294 | 295 | # copy rect by copying line by line 296 | for j in range(0, sh): 297 | copyline(self, dx, dy + j, src, sx, sy + j, sw) 298 | 299 | 300 | 301 | class MonoScreenBuf(ScreenBuf): 302 | '''monochrome screen buffer has no colors''' 303 | 304 | 305 | 306 | 307 | class ColorScreenBuf(ScreenBuf): 308 | '''a color screen buffer consist of two planes: 309 | a text buffer and a color buffer 310 | The text buffer holds one byte per character in 7 bits ASCII 311 | If the 8th bit is set, it is a special character (eg. a curses line) 312 | The color buffer holds one byte per color 313 | encoded as (bg << 4) | bold | fg 314 | ''' 315 | 316 | # There are some asserts, but in general, 317 | # ScreenBuf shouldn't care about buffer overruns or clipping 318 | # because a) that's a bug b) performance c) handled by class Video 319 | 320 | def __init__(self, w, h): 321 | '''initialize''' 322 | 323 | super().__init__(w, h) 324 | 325 | self.colorbuf = bytearray(w * h) 326 | 327 | def __getitem__(self, idx): 328 | '''Returns tuple: (ch, color) at idx 329 | idx may be an offset or tuple: (x, y) position 330 | 331 | Raises IndexError, ValueError on invalid index 332 | ''' 333 | 334 | if isinstance(idx, int): 335 | offset = idx 336 | 337 | elif isinstance(idx, tuple): 338 | x, y = idx 339 | offset = self.w * y + x 340 | 341 | else: 342 | raise ValueError('invalid argument') 343 | 344 | ch = self.textbuf[offset] 345 | if ch == 0: 346 | # blank 347 | ch = ' ' 348 | elif ch > 0x7f: 349 | # special curses character 350 | ch &= 0x7f 351 | ch |= 0x400000 352 | else: 353 | # make string 354 | ch = chr(ch) 355 | 356 | color = self.colorbuf[offset] 357 | return ch, color 358 | 359 | def __setitem__(self, idx, value): 360 | '''put tuple: (ch, color) at idx 361 | idx may be an offset or tuple: (x, y) position 362 | 363 | Raises IndexError, ValueError on invalid index 364 | or ValueError on invalid value 365 | ''' 366 | 367 | ch, color = value 368 | 369 | if isinstance(ch, str): 370 | ch = ord(ch) 371 | elif ch > 0x400000: 372 | # special curses character 373 | ch &= 0x7f 374 | ch |= 0x80 375 | 376 | if isinstance(idx, int): 377 | offset = idx 378 | 379 | elif isinstance(idx, tuple): 380 | x, y = idx 381 | offset = self.w * y + x 382 | 383 | else: 384 | raise ValueError('invalid argument') 385 | 386 | self.textbuf[offset] = ch 387 | self.colorbuf[offset] = color 388 | 389 | def puts(self, x, y, msg, color): # pylint: disable=signature-differs 390 | '''write message into buffer at x, y''' 391 | 392 | offset = self.w * y + x 393 | w = len(msg) 394 | self.textbuf[offset:offset + w] = bytes(msg, ScreenBuf.CODEPAGE) 395 | self.colorbuf[offset:offset + w] = bytes(chr(color) * w, ScreenBuf.CODEPAGE) 396 | 397 | def hline(self, x, y, w, ch, color): # pylint: disable=signature-differs 398 | '''repeat character horizontally''' 399 | 400 | if isinstance(ch, str): 401 | ch = ord(ch) 402 | elif ch > 0x400000: 403 | # special curses character 404 | ch &= 0x7f 405 | ch |= 0x80 406 | 407 | offset = self.w * y + x 408 | self.textbuf[offset:offset + w] = bytes(chr(ch) * w, ScreenBuf.CODEPAGE) 409 | self.colorbuf[offset:offset + w] = bytes(chr(color) * w, ScreenBuf.CODEPAGE) 410 | 411 | def vline(self, x, y, h, ch, color): # pylint: disable=signature-differs 412 | '''repeat character horizontally''' 413 | 414 | if isinstance(ch, str): 415 | ch = ord(ch) 416 | elif ch > 0x400000: 417 | # special curses character 418 | ch &= 0x7f 419 | ch |= 0x80 420 | 421 | offset = self.w * y + x 422 | for _ in range(0, h): 423 | self.textbuf[offset] = ch 424 | self.colorbuf[offset] = color 425 | offset += self.w 426 | 427 | def memmove(self, dst_idx, src_idx, num): 428 | '''copy num bytes at src_idx to dst_idx''' 429 | 430 | dst2 = dst_idx + num 431 | src2 = src_idx + num 432 | self.textbuf[dst_idx:dst2] = self.textbuf[src_idx:src2] 433 | self.colorbuf[dst_idx:dst2] = self.colorbuf[src_idx:src2] 434 | 435 | def copyrect(self, dx, dy, src, sx=0, sy=0, sw=0, sh=0): 436 | '''copy src rect sx,sy,sw,sh to dest self at dx,dy''' 437 | 438 | assert isinstance(src, ScreenBuf) 439 | assert dx >= 0 440 | assert dy >= 0 441 | assert sx >= 0 442 | assert sy >= 0 443 | 444 | if sw == 0: 445 | sw = src.w 446 | if sh == 0: 447 | sh = src.h 448 | 449 | if sw > self.w: 450 | sw = self.w 451 | if sh > self.h: 452 | sh = self.h 453 | 454 | assert sw > 0 455 | assert sh > 0 456 | 457 | # local function 458 | def copyline(dst, dx, dy, src, sx, sy, sw): 459 | '''copy line at sx,sy to dest dx,dy''' 460 | 461 | si = sy * src.w + sx 462 | di = dy * dst.w + dx 463 | dst.textbuf[di:di + sw] = src.textbuf[si:si + sw] 464 | dst.colorbuf[di:di + sw] = src.colorbuf[si:si + sw] 465 | 466 | # copy rect by copying line by line 467 | for j in range(0, sh): 468 | copyline(self, dx, dy + j, src, sx, sy + j, sw) 469 | 470 | 471 | 472 | class Rect: 473 | '''represents a rectangle''' 474 | 475 | def __init__(self, x, y, w, h): 476 | '''initialize''' 477 | 478 | assert w > 0 479 | assert h > 0 480 | 481 | self.x = x 482 | self.y = y 483 | self.w = w 484 | self.h = h 485 | 486 | def __str__(self): 487 | '''Returns string representation''' 488 | 489 | return '{{{}, {}, {}, {}}}'.format(self.x, self.y, self.w, self.h) 490 | 491 | def copy(self): 492 | '''Returns a copy''' 493 | 494 | return Rect(self.x, self.y, self.w, self.h) 495 | 496 | def clamp(self, x, y): 497 | '''Returns clamped tuple: x, y''' 498 | 499 | if x < self.x: 500 | x = self.x 501 | if x > self.x + self.w: 502 | x = self.x + self.w 503 | if y < self.y: 504 | y = self.y 505 | if y > self.y + self.h: 506 | y = self.y + self.h 507 | 508 | return x, y 509 | 510 | # Note: the clipping methods work with relative coordinates 511 | # and don't care about the position of the Rect 512 | # ie. they don't use self.x, self.y 513 | # To obtain the absolute clipping coordinates, translate 514 | # the result by rect.x, rect.y 515 | 516 | def clip_point(self, x, y): 517 | '''clip point at relative x, y against rect 518 | Returns True if point is in the rect; 519 | if True then the point is visible 520 | ''' 521 | 522 | return 0 <= x < self.w and 0 <= y < self.h 523 | 524 | def clip_hline(self, x, y, w): 525 | '''clip horizontal line against rect 526 | If visible, returns clipped tuple: True, x, y, w 527 | ''' 528 | 529 | if x + w < 0 or x >= self.w or y < 0 or y >= self.h: # pylint: disable=chained-comparison 530 | return False, -1, -1, -1 531 | 532 | if x < 0: 533 | w += x 534 | x = 0 535 | 536 | if x + w > self.w: 537 | w = self.w - x 538 | 539 | return True, x, y, w 540 | 541 | def clip_vline(self, x, y, h): 542 | '''clip vertical line against rect 543 | If visible, returns clipped tuple: True, x, y, h 544 | ''' 545 | 546 | if x < 0 or x >= self.w or y + h < 0 or y >= self.h: # pylint: disable=chained-comparison 547 | return False, -1, -1, -1 548 | 549 | if y < 0: 550 | h += y 551 | y = 0 552 | 553 | if y + h > self.h: 554 | h = self.h - y 555 | 556 | return True, x, y, h 557 | 558 | def clip_rect(self, x, y, w, h): 559 | '''clip rectangle against rect 560 | If visible, returns clipped tuple: True, x, y, w, h 561 | ''' 562 | 563 | if x + w < 0 or x >= self.w or y + h < 0 or y >= self.h: # pylint: disable=chained-comparison 564 | return False, -1, -1, -1, -1 565 | 566 | if x < 0: 567 | w += x 568 | x = 0 569 | 570 | if x + w > self.w: 571 | w = self.w - x 572 | 573 | if y < 0: 574 | h += y 575 | y = 0 576 | 577 | if y + h > self.h: 578 | h = self.h - y 579 | 580 | return True, x, y, w, h 581 | 582 | 583 | 584 | class Video: 585 | '''text mode video''' 586 | 587 | def __init__(self): 588 | '''initialize''' 589 | 590 | if STDSCR is None: 591 | init_curses() 592 | 593 | self.h, self.w = STDSCR.getmaxyx() 594 | if HAS_COLORS: 595 | self.screenbuf = ColorScreenBuf(self.w, self.h) 596 | else: 597 | self.screenbuf = MonoScreenBuf(self.w, self.h) 598 | 599 | self.rect = Rect(0, 0, self.w, self.h) 600 | self.color = video_color(WHITE, BLACK, bold=False) 601 | self.curses_color = curses_color(WHITE, BLACK, bold=False) 602 | 603 | def set_color(self, fg, bg=None, bold=True, alt=False): 604 | '''set current color 605 | Returns the combined color code 606 | ''' 607 | 608 | self.color = video_color(fg, bg, bold) 609 | self.curses_color = curses_color(fg, bg, bold, alt) 610 | return self.color 611 | 612 | def putch(self, x, y, ch, color=-1, alt=False): 613 | '''put character at x, y''' 614 | 615 | # clipping 616 | if not self.rect.clip_point(x, y): 617 | return 618 | 619 | if color == -1: 620 | color = self.color 621 | attr = self.curses_color 622 | else: 623 | attr = curses_color(color, alt=alt) 624 | 625 | self.screenbuf[x, y] = (ch, color) 626 | self.curses_putch(x, y, ch, attr) 627 | 628 | def puts(self, x, y, msg, color=-1, alt=False): 629 | '''write message at x, y''' 630 | 631 | visible, cx, cy, cw = self.rect.clip_hline(x, y, len(msg)) 632 | if not visible: 633 | return 634 | 635 | # clip message 636 | if x < 0: 637 | msg = msg[-x:] 638 | if not msg: 639 | return 640 | 641 | if len(msg) > cw: 642 | msg = msg[:cw] 643 | if not msg: 644 | return 645 | 646 | if color == -1: 647 | color = self.color 648 | attr = self.curses_color 649 | else: 650 | attr = curses_color(color, alt=alt) 651 | 652 | self.screenbuf.puts(cx, cy, msg, color) 653 | self.curses_puts(cx, cy, msg, attr) 654 | 655 | def curses_putch(self, x, y, ch, attr=None, alt=False): 656 | '''put character into the curses screen x, y''' 657 | 658 | # curses.addch() has issues with drawing in the right bottom corner 659 | # because it wants to scroll, but it can't 660 | # curses.insch() messes up the screen royally, because it inserts 661 | # the character and pushes the remainder of the line forward 662 | # Both aren't ideal to work with, but we _can_ use insch() 663 | # at the end of screen 664 | # Note that it doesn't matter whether you use scrollok() or not 665 | 666 | if attr is None: 667 | attr = self.curses_color 668 | 669 | if y >= self.h - 1 and x >= self.w - 1: 670 | STDSCR.insch(y, x, ch, attr) 671 | else: 672 | STDSCR.addch(y, x, ch, attr) 673 | 674 | def curses_puts(self, x, y, msg, attr=None): 675 | '''print message into the curses screen at x, y''' 676 | 677 | # curses.addstr() has issues with drawing in the right bottom corner 678 | # because it wants to scroll, but it can't 679 | # curses.insstr() messes up the screen royally, because it inserts text 680 | # and pushes the remainder of the line forward 681 | # Both aren't ideal to work with, but we _can_ use insstr() 682 | # at the end of screen 683 | # Note that it doesn't matter whether you use scrollok() or not 684 | 685 | if attr is None: 686 | attr = self.curses_color 687 | 688 | if y >= self.h - 1 and x + len(msg) >= self.w: 689 | STDSCR.insstr(y, x, msg, attr) 690 | else: 691 | STDSCR.addstr(y, x, msg, attr) 692 | 693 | def hline(self, x, y, w, ch, color=-1, alt=False): 694 | '''draw horizontal line at x, y''' 695 | 696 | visible, x, y, w = self.rect.clip_hline(x, y, w) 697 | if not visible: 698 | return 699 | 700 | if color == -1: 701 | color = self.color 702 | attr = self.curses_color 703 | else: 704 | attr = curses_color(color, alt=alt) 705 | 706 | self.screenbuf.hline(x, y, w, ch, color) 707 | if isinstance(ch, str): 708 | ch = ord(ch) 709 | STDSCR.hline(y, x, ch, w, attr) 710 | 711 | def vline(self, x, y, h, ch, color=-1, alt=False): 712 | '''draw vertical line at x, y''' 713 | 714 | visible, x, y, h = self.rect.clip_vline(x, y, h) 715 | if not visible: 716 | return 717 | 718 | if color == -1: 719 | color = self.color 720 | attr = self.curses_color 721 | else: 722 | attr = curses_color(color, alt=alt) 723 | 724 | self.screenbuf.vline(x, y, h, ch, color) 725 | if isinstance(ch, str): 726 | ch = ord(ch) 727 | STDSCR.vline(y, x, ch, h, attr) 728 | 729 | def hsplit(self, x, y, w, ch, color=-1, alt=False): 730 | '''draw a horizontal split''' 731 | 732 | self.hline(x + 1, y, w - 2, curses.ACS_HLINE, color, alt) 733 | # put tee characters on the sides 734 | self.putch(x, y, curses.ACS_LTEE, color, alt) 735 | self.putch(x + w - 1, y, curses.ACS_RTEE, color, alt) 736 | 737 | def vsplit(self, x, y, h, ch, color=-1, alt=False): 738 | '''draw a vertical split''' 739 | 740 | self.vline(x, y + 1, h - 2, curses.ACS_VLINE, color, alt) 741 | # put tee characters on the sides 742 | self.putch(x, y, curses.ACS_TTEE, color, alt) 743 | self.putch(x, y + h - 1, curses.ACS_BTEE, color, alt) 744 | 745 | def fillrect(self, x, y, w, h, color=-1, alt=False): 746 | '''draw rectangle at x, y''' 747 | 748 | visible, x, y, w, h = self.rect.clip_rect(x, y, w, h) 749 | if not visible: 750 | return 751 | 752 | if color == -1: 753 | color = self.color 754 | attr = self.curses_color 755 | else: 756 | attr = curses_color(color, alt=alt) 757 | 758 | for j in range(0, h): 759 | self.screenbuf.hline(x, y + j, w, ' ', color) 760 | STDSCR.hline(y + j, x, ' ', w, attr) 761 | 762 | def border(self, x, y, w, h, color=-1, alt=False): 763 | '''draw rectangle border''' 764 | 765 | visible, cx, cy, cw, ch = self.rect.clip_rect(x, y, w, h) 766 | if not visible: 767 | return 768 | 769 | if color == -1: 770 | color = self.color 771 | attr = self.curses_color 772 | else: 773 | attr = curses_color(color, alt=alt) 774 | 775 | # top 776 | if 0 <= y < self.h: 777 | self.screenbuf.hline(cx, y, cw, curses.ACS_HLINE, color) 778 | STDSCR.hline(y, cx, curses.ACS_HLINE, cw, attr) 779 | 780 | # left 781 | if 0 <= x < self.w: 782 | self.screenbuf.vline(x, cy, ch, curses.ACS_VLINE, color) 783 | STDSCR.vline(cy, x, curses.ACS_VLINE, ch, attr) 784 | 785 | # right 786 | rx = x + w - 1 787 | if 0 <= rx < self.w: 788 | self.screenbuf.vline(rx, cy, ch, curses.ACS_VLINE, color) 789 | STDSCR.vline(cy, rx, curses.ACS_VLINE, ch, attr) 790 | 791 | # bottom 792 | by = y + h - 1 793 | if 0 <= by < self.h: 794 | self.screenbuf.hline(cx, by, cw, curses.ACS_HLINE, color) 795 | STDSCR.hline(by, cx, curses.ACS_HLINE, cw, attr) 796 | 797 | # top left corner 798 | if self.rect.clip_point(x, y): 799 | self.screenbuf[x, y] = (curses.ACS_ULCORNER, color) 800 | self.curses_putch(x, y, curses.ACS_ULCORNER, attr) 801 | 802 | # bottom left corner 803 | if self.rect.clip_point(x, by): 804 | self.screenbuf[x, by] = (curses.ACS_LLCORNER, color) 805 | self.curses_putch(x, by, curses.ACS_LLCORNER, attr) 806 | 807 | # top right corner 808 | if self.rect.clip_point(rx, y): 809 | self.screenbuf[rx, y] = (curses.ACS_URCORNER, color) 810 | self.curses_putch(rx, y, curses.ACS_URCORNER, attr) 811 | 812 | # bottom right corner 813 | if self.rect.clip_point(rx, by): 814 | self.screenbuf[rx, by] = (curses.ACS_LRCORNER, color) 815 | self.curses_putch(rx, by, curses.ACS_LRCORNER, attr) 816 | 817 | def color_putch(self, x, y, color=-1, alt=False): 818 | '''put color at x, y''' 819 | 820 | if not self.rect.clip_point(x, y): 821 | return 822 | 823 | if color == -1: 824 | color = self.color 825 | attr = self.curses_color 826 | else: 827 | attr = curses_color(color, alt=alt) 828 | 829 | # get the character and redraw with color 830 | offset = self.w * y + x 831 | ch, _ = self.screenbuf[offset] 832 | self.screenbuf[offset] = (ch, color) 833 | if isinstance(ch, str): 834 | ch = ord(ch) 835 | self.curses_putch(x, y, ch, attr) 836 | 837 | def color_hline(self, x, y, w, color=-1, alt=False): 838 | '''draw horizontal color line''' 839 | 840 | visible, x, y, w = self.rect.clip_hline(x, y, w) 841 | if not visible: 842 | return 843 | 844 | if color == -1: 845 | color = self.color 846 | attr = self.curses_color 847 | else: 848 | attr = curses_color(color, alt=alt) 849 | 850 | # get the character and redraw with color 851 | offset = self.w * y + x 852 | for i in range(0, w): 853 | ch, _ = self.screenbuf[offset] 854 | self.screenbuf[offset] = (ch, color) 855 | offset += 1 856 | if isinstance(ch, str): 857 | ch = ord(ch) 858 | self.curses_putch(x + i, y, ch, attr) 859 | 860 | def color_vline(self, x, y, h, color=-1, alt=False): 861 | '''draw vertical colored line''' 862 | 863 | visible, x, y, h = self.rect.clip_vline(x, y, h) 864 | if not visible: 865 | return 866 | 867 | if color == -1: 868 | color = self.color 869 | attr = self.curses_color 870 | else: 871 | attr = curses_color(color, alt=alt) 872 | 873 | # get the character and redraw with color 874 | offset = self.w * y + x 875 | for j in range(0, h): 876 | ch, _ = self.screenbuf[offset] 877 | self.screenbuf[offset] = (ch, color) 878 | offset += self.w 879 | if isinstance(ch, str): 880 | ch = ord(ch) 881 | self.curses_putch(x, y + j, ch, attr) 882 | 883 | def getrect(self, x, y, w, h): 884 | '''Returns ScreenBuf object with copy of x,y,w,h 885 | or None if outside clip area 886 | ''' 887 | 888 | visible, x, y, w, h = self.rect.clip_rect(x, y, w, h) 889 | if not visible: 890 | return None 891 | 892 | if HAS_COLORS: 893 | copy = ColorScreenBuf(w, h) 894 | else: 895 | copy = MonoScreenBuf(w, h) 896 | 897 | copy.copyrect(0, 0, self.screenbuf, x, y, w, h) 898 | return copy 899 | 900 | def putrect(self, x, y, buf): 901 | '''Put ScreenBuf buf at x, y''' 902 | 903 | if buf is None: 904 | return 905 | 906 | assert isinstance(buf, ScreenBuf) 907 | 908 | # we assume buf was created by getrect() 909 | # therefore we can safely assume it is already clipped right 910 | # but do clamp to (0,0) to get rid of negative coordinates 911 | x, y = self.rect.clamp(x, y) 912 | if not self.rect.clip_point(x, y): 913 | return 914 | 915 | self.screenbuf.copyrect(x, y, buf, 0, 0, buf.w, buf.h) 916 | # update the curses screen 917 | prev_color = None 918 | offset = self.w * y + x 919 | for j in range(0, buf.h): 920 | for i in range(0, buf.w): 921 | ch, color = self.screenbuf[offset] 922 | if isinstance(ch, str): 923 | ch = ord(ch) 924 | 925 | if color != prev_color: 926 | # only reset attr when the color did change 927 | prev_color = color 928 | attr = curses_color(color) 929 | 930 | offset += 1 931 | self.curses_putch(x + i, y + j, ch, attr) 932 | offset += self.w - buf.w 933 | 934 | def clear_screen(self): 935 | '''clear the screen''' 936 | 937 | self.fillrect(0, 0, self.w, self.h, video_color(WHITE, BLACK)) 938 | 939 | 940 | 941 | class ColorSet: 942 | '''collection of colors''' 943 | 944 | def __init__(self, fg=WHITE, bg=BLACK, bold=False): 945 | '''initialize''' 946 | 947 | self.text = video_color(fg, bg, bold) 948 | self.border = self.text 949 | self.title = self.text 950 | self.cursor = reverse_video(self.text) 951 | self.status = self.text 952 | self.scrollbar = self.text 953 | self.shadow = video_color(BLACK, BLACK, True) 954 | 955 | # not all views use these, but set them anyway 956 | self.button = self.text 957 | self.buttonhotkey = self.text 958 | self.activebutton = self.text 959 | self.activebuttonhotkey = self.text 960 | 961 | self.menu = self.text 962 | self.menuhotkey = self.text 963 | self.activemenu = self.text 964 | self.activemenuhotkey = self.text 965 | 966 | self.prompt = self.text 967 | self.invisibles = self.text 968 | 969 | 970 | 971 | class Window: 972 | '''represents a window''' 973 | 974 | OPEN = 1 975 | SHOWN = 2 976 | FOCUS = 4 977 | 978 | def __init__(self, x, y, w, h, colors, title=None, border=True, 979 | shadow=True): 980 | '''initialize''' 981 | 982 | self.frame = Rect(x, y, w, h) 983 | self.colors = colors 984 | self.title = title 985 | self.has_border = border 986 | self.has_shadow = shadow 987 | 988 | # bounds is the inner area; for view content 989 | if border: 990 | self.bounds = Rect(x + 1, y + 1, w - 2, h - 2) 991 | else: 992 | self.bounds = self.frame.copy() 993 | 994 | # rect is the outer area; larger because of shadow 995 | if self.has_shadow: 996 | self.rect = Rect(x, y, w + 2, h + 1) 997 | else: 998 | self.rect = self.frame.copy() 999 | 1000 | self.background = None 1001 | self.flags = 0 1002 | 1003 | def save_background(self): 1004 | '''save the background''' 1005 | 1006 | self.background = VIDEO.getrect(self.rect.x, self.rect.y, 1007 | self.rect.w, self.rect.h) 1008 | 1009 | def restore_background(self): 1010 | '''restore the background''' 1011 | 1012 | VIDEO.putrect(self.rect.x, self.rect.y, self.background) 1013 | 1014 | def open(self): 1015 | '''open the window''' 1016 | 1017 | if self.flags & Window.OPEN: 1018 | return 1019 | 1020 | self.flags |= Window.OPEN 1021 | self.save_background() 1022 | 1023 | def close(self): 1024 | '''close the window''' 1025 | 1026 | if not self.flags & Window.OPEN: 1027 | return 1028 | 1029 | self.hide() 1030 | self.flags &= ~Window.OPEN 1031 | 1032 | def show(self): 1033 | '''open the window''' 1034 | 1035 | self.open() 1036 | 1037 | if self.flags & Window.SHOWN: 1038 | return 1039 | 1040 | self.flags |= Window.SHOWN 1041 | self.front() 1042 | 1043 | def hide(self): 1044 | '''hide the window''' 1045 | 1046 | if not self.flags & Window.SHOWN: 1047 | return 1048 | 1049 | self.restore_background() 1050 | self.flags &= ~(Window.SHOWN | Window.FOCUS) 1051 | STACK.remove(self) 1052 | # we have a new top-level window 1053 | win = STACK.top() 1054 | if win is not None: 1055 | win.draw() 1056 | win.gain_focus() 1057 | 1058 | def gain_focus(self): 1059 | '''event: we got focus''' 1060 | 1061 | self.flags |= Window.FOCUS 1062 | self.draw_cursor() 1063 | 1064 | def lose_focus(self): 1065 | '''event: focus lost''' 1066 | 1067 | self.flags &= ~Window.FOCUS 1068 | self.draw_cursor() 1069 | 1070 | def front(self): 1071 | '''bring window to front''' 1072 | 1073 | win = STACK.top() 1074 | if win is not self: 1075 | if win is not None: 1076 | win.lose_focus() 1077 | 1078 | STACK.front(self) 1079 | self.draw() 1080 | self.gain_focus() 1081 | 1082 | def back(self): 1083 | '''bring window to back''' 1084 | 1085 | self.lose_focus() 1086 | STACK.back(self) 1087 | # we have a new top-level window 1088 | win = STACK.top() 1089 | if win is not None: 1090 | win.draw() 1091 | win.gain_focus() 1092 | 1093 | def resize_event(self): 1094 | '''the terminal was resized''' 1095 | 1096 | # override this method 1097 | # This method should only change coordinates; 1098 | # a redraw will be called automatically 1099 | #pass 1100 | 1101 | def draw(self): 1102 | '''draw the window''' 1103 | 1104 | if not self.flags & Window.SHOWN: 1105 | return 1106 | 1107 | if self.has_border: 1108 | VIDEO.fillrect(self.frame.x + 1, self.frame.y + 1, 1109 | self.frame.w - 2, self.frame.h - 2, 1110 | self.colors.text) 1111 | VIDEO.border(self.frame.x, self.frame.y, self.frame.w, 1112 | self.frame.h, self.colors.border) 1113 | else: 1114 | VIDEO.fillrect(self.frame.x, self.frame.y, self.frame.w, 1115 | self.frame.h, self.colors.text) 1116 | 1117 | # draw frame shadow 1118 | self.draw_shadow() 1119 | 1120 | # draw title 1121 | if self.title is not None: 1122 | title = ' ' + self.title + ' ' 1123 | x = (self.frame.w - len(title)) // 2 1124 | VIDEO.puts(self.frame.x + x, self.frame.y, title, 1125 | self.colors.title) 1126 | 1127 | def draw_shadow(self): 1128 | '''draw shadow for frame rect''' 1129 | 1130 | if not self.has_shadow: 1131 | return 1132 | 1133 | if not self.flags & Window.SHOWN: 1134 | return 1135 | 1136 | # right side shadow vlines 1137 | VIDEO.color_vline(self.frame.x + self.frame.w, self.frame.y + 1, 1138 | self.frame.h, self.colors.shadow) 1139 | VIDEO.color_vline(self.frame.x + self.frame.w + 1, self.frame.y + 1, 1140 | self.frame.h, self.colors.shadow) 1141 | # bottom shadow hline 1142 | VIDEO.color_hline(self.frame.x + 2, self.frame.y + self.frame.h, 1143 | self.frame.w, self.colors.shadow) 1144 | 1145 | def draw_cursor(self): 1146 | '''draw cursor''' 1147 | 1148 | # override this method 1149 | 1150 | # if self.flags & Window.FOCUS: 1151 | # ... 1152 | # else: 1153 | # ... 1154 | #pass 1155 | 1156 | def putch(self, x, y, ch, color=-1, alt=False): 1157 | '''put character in window''' 1158 | 1159 | if not self.bounds.clip_point(x, y): 1160 | return 1161 | 1162 | if color == -1: 1163 | color = self.colors.text 1164 | 1165 | VIDEO.putch(self.bounds.x + x, self.bounds.y + y, ch, color, alt) 1166 | 1167 | def puts(self, x, y, msg, color=-1, alt=False): 1168 | '''print message in window 1169 | Does not clear to end of line 1170 | ''' 1171 | 1172 | if not self.flags & Window.SHOWN: 1173 | return 1174 | 1175 | # do window clipping 1176 | visible, cx, cy, cw = self.bounds.clip_hline(x, y, len(msg)) 1177 | if not visible: 1178 | return 1179 | 1180 | # clip message 1181 | if x < 0: 1182 | msg = msg[-x:] 1183 | if not msg: 1184 | return 1185 | 1186 | if len(msg) > cw: 1187 | msg = msg[:cw] 1188 | if not msg: 1189 | return 1190 | 1191 | if color == -1: 1192 | color = self.colors.text 1193 | 1194 | VIDEO.puts(self.bounds.x + cx, self.bounds.y + cy, msg, color, alt) 1195 | 1196 | def cputs(self, x, y, msg, color=-1, alt=False): 1197 | '''print message in window 1198 | Clear to end of line 1199 | ''' 1200 | 1201 | if not self.flags & Window.SHOWN: 1202 | return 1203 | 1204 | # starts out the same as puts(), but then clears to EOL 1205 | 1206 | # do window clipping 1207 | visible, cx, cy, cw = self.bounds.clip_hline(x, y, len(msg)) 1208 | if not visible: 1209 | return 1210 | 1211 | # clip message 1212 | if x < 0: 1213 | msg = msg[-x:] 1214 | 1215 | if len(msg) > cw: 1216 | msg = msg[:cw] 1217 | 1218 | if color == -1: 1219 | color = self.colors.text 1220 | 1221 | if len(msg) > 0: 1222 | VIDEO.puts(self.bounds.x + cx, self.bounds.y + cy, msg, color, 1223 | alt) 1224 | 1225 | # clear to end of line 1226 | l = len(msg) 1227 | w_eol = self.bounds.w - l - cx 1228 | if w_eol > 0: 1229 | clear_eol = ' ' * w_eol 1230 | VIDEO.puts(self.bounds.x + cx + l, self.bounds.y + cy, 1231 | clear_eol, color, alt) 1232 | 1233 | def color_putch(self, x, y, color=-1, alt=False): 1234 | '''put color byte in window''' 1235 | 1236 | if not self.bounds.clip_point(x, y): 1237 | return 1238 | 1239 | if color == -1: 1240 | color = self.colors.text 1241 | 1242 | VIDEO.color_putch(self.bounds.x + x, self.bounds.y + y, color, alt) 1243 | 1244 | 1245 | 1246 | class TextWindow(Window): 1247 | '''a window for displaying text''' 1248 | 1249 | def __init__(self, x, y, w, h, colors, title=None, border=True, 1250 | text=None, tabsize=4, scrollbar=True, status=True): 1251 | '''initialize''' 1252 | 1253 | super().__init__(x, y, w, h, colors, title, border) 1254 | if text is None: 1255 | self.text = [] 1256 | else: 1257 | self.text = text 1258 | self.tabsize = tabsize 1259 | 1260 | self.top = 0 1261 | self.cursor = 0 1262 | self.xoffset = 0 1263 | 1264 | if status: 1265 | self.status = '' 1266 | else: 1267 | self.status = None 1268 | 1269 | if scrollbar: 1270 | self.scrollbar = True 1271 | self.scrollbar_y = 0 1272 | self.scrollbar_h = 0 1273 | self.init_scrollbar() 1274 | else: 1275 | self.scrollbar = None 1276 | 1277 | def load(self, filename): 1278 | '''load text file 1279 | Raises OSError on error 1280 | ''' 1281 | 1282 | with open(filename) as f: 1283 | self.text = f.readlines() 1284 | 1285 | # strip newlines 1286 | self.text = [x.rstrip() for x in self.text] 1287 | 1288 | if self.title is not None: 1289 | self.title = os.path.basename(filename) 1290 | 1291 | if self.scrollbar is not None: 1292 | self.init_scrollbar() 1293 | 1294 | # do a full draw because we loaded new text 1295 | self.draw() 1296 | 1297 | def draw(self): 1298 | '''draw the window''' 1299 | 1300 | if not self.flags & Window.SHOWN: 1301 | return 1302 | 1303 | super().draw() 1304 | self.draw_text() 1305 | self.draw_scrollbar() 1306 | self.draw_statusbar() 1307 | 1308 | def draw_text(self): 1309 | '''draws the text content''' 1310 | 1311 | y = 0 1312 | while y < self.bounds.h: 1313 | if y == self.cursor: 1314 | # draw_cursor() will be called by Window.show() 1315 | pass 1316 | else: 1317 | try: 1318 | self.printline(y) 1319 | except IndexError: 1320 | break 1321 | 1322 | y += 1 1323 | 1324 | def draw_cursor(self): 1325 | '''redraw the cursor line''' 1326 | 1327 | if self.flags & Window.FOCUS: 1328 | color = self.colors.cursor 1329 | alt = True 1330 | else: 1331 | color = -1 1332 | alt = False 1333 | self.printline(self.cursor, color, alt) 1334 | 1335 | self.update_statusbar(' {},{} '.format(self.top + self.cursor + 1, 1336 | self.xoffset + 1)) 1337 | 1338 | def clear_cursor(self): 1339 | '''erase the cursor''' 1340 | 1341 | self.printline(self.cursor) 1342 | 1343 | def printline(self, y, color=-1, alt=False): 1344 | '''print a single line''' 1345 | 1346 | try: 1347 | line = self.text[self.top + y] 1348 | except IndexError: 1349 | # draw empty line 1350 | self.cputs(0, y, '', color) 1351 | else: 1352 | # replace tabs by spaces 1353 | # This is because curses will display them too big 1354 | line = line.replace('\t', ' ' * self.tabsize) 1355 | # take x-scrolling into account 1356 | line = line[self.xoffset:] 1357 | self.cputs(0, y, line, color, alt) 1358 | 1359 | def init_scrollbar(self): 1360 | '''initalize scrollbar''' 1361 | 1362 | if self.has_border and len(self.text) > 0: 1363 | factor = float(self.bounds.h) // len(self.text) 1364 | self.scrollbar_h = int(factor * self.bounds.h + 0.5) 1365 | if self.scrollbar_h < 1: 1366 | self.scrollbar_h = 1 1367 | if self.scrollbar_h > self.bounds.h: 1368 | self.scrollbar_h = self.bounds.h 1369 | # self.update_scrollbar() 1370 | 1371 | def update_scrollbar(self): 1372 | '''update scrollbar position''' 1373 | 1374 | if (self.scrollbar is None or not self.has_border or 1375 | self.scrollbar_h <= 0 or not self.text): 1376 | return 1377 | 1378 | old_y = self.scrollbar_y 1379 | 1380 | factor = float(self.bounds.h) // len(self.text) 1381 | new_y = int((self.top + self.cursor) * factor + 0.5) 1382 | if old_y != new_y: 1383 | self.clear_scrollbar() 1384 | self.scrollbar_y = new_y 1385 | self.draw_scrollbar() 1386 | 1387 | def clear_scrollbar(self): 1388 | '''erase scrollbar''' 1389 | 1390 | if (self.scrollbar is None or not self.has_border or 1391 | self.scrollbar_h <= 0): 1392 | return 1393 | 1394 | y = self.scrollbar_y - self.scrollbar_h // 2 1395 | if y < 0: 1396 | y = 0 1397 | if y > self.bounds.h - self.scrollbar_h: 1398 | y = self.bounds.h - self.scrollbar_h 1399 | 1400 | VIDEO.vline(self.frame.x + self.frame.w - 1, self.bounds.y + y, 1401 | self.scrollbar_h, curses.ACS_VLINE, self.colors.border) 1402 | 1403 | def draw_scrollbar(self): 1404 | '''draw scrollbar''' 1405 | 1406 | if (self.scrollbar is None or not self.has_border or 1407 | self.scrollbar_h <= 0): 1408 | return 1409 | 1410 | y = self.scrollbar_y - self.scrollbar_h // 2 1411 | if y < 0: 1412 | y = 0 1413 | if y > self.bounds.h - self.scrollbar_h: 1414 | y = self.bounds.h - self.scrollbar_h 1415 | 1416 | VIDEO.vline(self.frame.x + self.frame.w - 1, self.bounds.y + y, 1417 | self.scrollbar_h, curses.ACS_CKBOARD, 1418 | self.colors.scrollbar) 1419 | 1420 | def update_statusbar(self, msg): 1421 | '''update the statusbar''' 1422 | 1423 | if self.status is None or msg == self.status: 1424 | return 1425 | 1426 | if len(msg) < len(self.status): 1427 | # clear the statusbar 1428 | w = len(self.status) - len(msg) 1429 | x = self.bounds.w - 1 - len(self.status) 1430 | if x < 0: 1431 | x = 0 1432 | w = self.bounds.w 1433 | VIDEO.hline(self.bounds.x + x, self.frame.y + self.frame.h - 1, w, 1434 | curses.ACS_HLINE, self.colors.border) 1435 | 1436 | self.status = msg 1437 | self.draw_statusbar() 1438 | 1439 | def draw_statusbar(self): 1440 | '''draw statusbar''' 1441 | 1442 | if self.status is None: 1443 | return 1444 | 1445 | x = self.bounds.w - 1 - len(self.status) 1446 | if x < 0: 1447 | x = 0 1448 | 1449 | msg = self.status 1450 | if len(msg) > self.bounds.w: 1451 | msg = msg[self.bounds.w:] 1452 | 1453 | VIDEO.puts(self.bounds.x + x, self.bounds.y + self.bounds.h, msg, 1454 | self.colors.status) 1455 | 1456 | def move_up(self): 1457 | '''move up''' 1458 | 1459 | if self.cursor > 0: 1460 | self.clear_cursor() 1461 | self.cursor -= 1 1462 | else: 1463 | self.scroll_up() 1464 | 1465 | self.update_scrollbar() 1466 | self.draw_cursor() 1467 | 1468 | def move_down(self): 1469 | '''move down''' 1470 | 1471 | if not self.text or self.cursor >= len(self.text) - 1: 1472 | return 1473 | 1474 | if self.cursor < self.bounds.h - 1: 1475 | self.clear_cursor() 1476 | self.cursor += 1 1477 | else: 1478 | self.scroll_down() 1479 | 1480 | self.update_scrollbar() 1481 | self.draw_cursor() 1482 | 1483 | def move_left(self): 1484 | '''move left''' 1485 | 1486 | if self.xoffset > 0: 1487 | self.xoffset -= 4 1488 | if self.xoffset < 0: 1489 | self.xoffset = 0 1490 | self.draw_text() 1491 | self.draw_cursor() 1492 | 1493 | def move_right(self): 1494 | '''move right''' 1495 | 1496 | max_xoffset = 500 1497 | if self.xoffset < max_xoffset: 1498 | self.xoffset += 4 1499 | self.draw_text() 1500 | self.draw_cursor() 1501 | 1502 | def scroll_up(self): 1503 | '''scroll up one line''' 1504 | 1505 | old_top = self.top 1506 | self.top -= 1 1507 | if self.top < 0: 1508 | self.top = 0 1509 | 1510 | if self.top != old_top: 1511 | self.draw_text() 1512 | 1513 | def scroll_down(self): 1514 | '''scroll down one line''' 1515 | 1516 | old_top = self.top 1517 | self.top += 1 1518 | if self.top > len(self.text) - self.bounds.h: 1519 | self.top = len(self.text) - self.bounds.h 1520 | if self.top < 0: 1521 | self.top = 0 1522 | 1523 | if self.top != old_top: 1524 | self.draw_text() 1525 | 1526 | def pageup(self): 1527 | '''scroll one page up''' 1528 | 1529 | old_top = self.top 1530 | old_cursor = new_cursor = self.cursor 1531 | 1532 | if old_cursor == self.bounds.h - 1: 1533 | new_cursor = 0 1534 | else: 1535 | self.top -= self.bounds.h - 1 1536 | if self.top < 0: 1537 | self.top = 0 1538 | new_cursor = 0 1539 | 1540 | if self.top != old_top: 1541 | self.cursor = new_cursor 1542 | self.draw_text() 1543 | elif old_cursor != new_cursor: 1544 | self.clear_cursor() 1545 | self.cursor = new_cursor 1546 | 1547 | self.update_scrollbar() 1548 | self.draw_cursor() 1549 | 1550 | def pagedown(self): 1551 | '''scroll one page down''' 1552 | 1553 | old_top = self.top 1554 | old_cursor = new_cursor = self.cursor 1555 | 1556 | if old_cursor == 0: 1557 | new_cursor = self.bounds.h - 1 1558 | else: 1559 | self.top += self.bounds.h - 1 1560 | if self.top > len(self.text) - self.bounds.h: 1561 | self.top = len(self.text) - self.bounds.h 1562 | if self.top < 0: 1563 | self.top = 0 1564 | new_cursor = self.bounds.h - 1 1565 | if new_cursor >= len(self.text): 1566 | new_cursor = len(self.text) - 1 1567 | if new_cursor < 0: 1568 | new_cursor = 0 1569 | 1570 | if self.top != old_top: 1571 | self.cursor = new_cursor 1572 | self.draw_text() 1573 | elif old_cursor != new_cursor: 1574 | self.clear_cursor() 1575 | self.cursor = new_cursor 1576 | 1577 | self.update_scrollbar() 1578 | self.draw_cursor() 1579 | 1580 | def goto_top(self): 1581 | '''go to top of document''' 1582 | 1583 | old_top = self.top 1584 | old_cursor = new_cursor = self.cursor 1585 | old_xoffset = self.xoffset 1586 | 1587 | self.top = self.xoffset = new_cursor = 0 1588 | if old_top != self.top or old_xoffset != self.xoffset: 1589 | self.cursor = new_cursor 1590 | self.draw_text() 1591 | elif old_cursor != new_cursor: 1592 | self.clear_cursor() 1593 | self.cursor = new_cursor 1594 | 1595 | self.update_scrollbar() 1596 | self.draw_cursor() 1597 | 1598 | def goto_bottom(self): 1599 | '''go to bottom of document''' 1600 | 1601 | old_top = self.top 1602 | old_cursor = new_cursor = self.cursor 1603 | old_xoffset = self.xoffset 1604 | 1605 | self.top = len(self.text) - self.bounds.h 1606 | if self.top < 0: 1607 | self.top = 0 1608 | 1609 | new_cursor = self.bounds.h - 1 1610 | if new_cursor >= len(self.text): 1611 | new_cursor = len(self.text) - 1 1612 | if new_cursor < 0: 1613 | new_cursor = 0 1614 | 1615 | self.xoffset = 0 1616 | 1617 | if self.top != old_top or old_xoffset != self.xoffset: 1618 | self.cursor = new_cursor 1619 | self.draw_text() 1620 | elif old_cursor != new_cursor: 1621 | self.clear_cursor() 1622 | self.cursor = new_cursor 1623 | 1624 | self.update_scrollbar() 1625 | self.draw_cursor() 1626 | 1627 | def runloop(self): 1628 | '''run main input loop for this view 1629 | Returns a new program state code 1630 | ''' 1631 | 1632 | while True: 1633 | key = getch() 1634 | 1635 | if key == KEY_ESC: 1636 | self.lose_focus() 1637 | return GOTO_MENUBAR 1638 | 1639 | if key == KEY_UP: 1640 | self.move_up() 1641 | 1642 | elif key == KEY_DOWN: 1643 | self.move_down() 1644 | 1645 | elif key == KEY_LEFT: 1646 | self.move_left() 1647 | 1648 | elif key == KEY_RIGHT: 1649 | self.move_right() 1650 | 1651 | elif key == KEY_PAGEUP or key == 'Ctrl-U': # pylint: disable=consider-using-in 1652 | self.pageup() 1653 | 1654 | elif key == KEY_PAGEDOWN or key == 'Ctrl-D': # pylint: disable=consider-using-in 1655 | self.pagedown() 1656 | 1657 | elif key == KEY_HOME: 1658 | self.goto_top() 1659 | 1660 | elif key == KEY_END: 1661 | self.goto_bottom() 1662 | 1663 | 1664 | 1665 | class Widget: 1666 | '''represents a widget''' 1667 | 1668 | def __init__(self, parent, x, y, colors): 1669 | '''initialize''' 1670 | 1671 | self.parent = parent 1672 | self.x = x 1673 | self.y = y 1674 | self.colors = colors 1675 | self.has_focus = False 1676 | 1677 | def draw(self): 1678 | '''draw widget''' 1679 | 1680 | # override this method 1681 | 1682 | def gain_focus(self): 1683 | '''we get focus''' 1684 | 1685 | self.has_focus = True 1686 | self.draw_cursor() 1687 | 1688 | def lose_focus(self): 1689 | '''we lose focus''' 1690 | 1691 | self.has_focus = False 1692 | self.draw_cursor() 1693 | 1694 | def draw_cursor(self): 1695 | '''draw cursor''' 1696 | 1697 | # override this method 1698 | 1699 | 1700 | 1701 | class Button(Widget): 1702 | '''represents a button''' 1703 | 1704 | def __init__(self, parent, x, y, colors, label): 1705 | '''initialize''' 1706 | 1707 | assert label is not None 1708 | 1709 | super().__init__(parent, x, y, colors) 1710 | 1711 | self.hotkey, self.hotkey_pos, self.label = label_hotkey(label) 1712 | 1713 | self.pushing = False 1714 | 1715 | def draw(self): 1716 | '''draw button''' 1717 | 1718 | self.draw_cursor() 1719 | 1720 | def draw_cursor(self): 1721 | '''draw button''' 1722 | 1723 | # the cursor _is_ the button 1724 | # and the button is the cursor 1725 | 1726 | add = 1 1727 | text = ' ' + self.label + ' ' 1728 | if len(text) <= 5: 1729 | # minimum width is 7 1730 | text = ' ' + text + ' ' 1731 | add += 1 1732 | 1733 | if self.has_focus: 1734 | text = '>' + text + '<' 1735 | color = self.colors.activebutton 1736 | alt = True 1737 | else: 1738 | if not HAS_COLORS: 1739 | text = '[' + text + ']' 1740 | else: 1741 | text = ' ' + text + ' ' 1742 | color = self.colors.button 1743 | alt = False 1744 | add += 1 1745 | 1746 | xpos = self.x 1747 | if self.pushing: 1748 | # clear on left side 1749 | self.parent.puts(xpos, self.y, ' ') 1750 | xpos += 1 1751 | else: 1752 | # clear on right side 1753 | self.parent.puts(xpos + button_width(self.label), self.y, ' ') 1754 | 1755 | self.parent.puts(xpos, self.y, text, color, alt) 1756 | 1757 | if self.hotkey_pos > -1: 1758 | # draw hotkey 1759 | if self.has_focus: 1760 | color = self.colors.activebuttonhotkey 1761 | else: 1762 | color = self.colors.buttonhotkey 1763 | 1764 | self.parent.puts(xpos + self.hotkey_pos + add, self.y, 1765 | self.hotkey, color, alt) 1766 | 1767 | def push(self): 1768 | '''push the button''' 1769 | 1770 | assert self.has_focus 1771 | 1772 | # animate button 1773 | self.pushing = True 1774 | self.draw() 1775 | STDSCR.refresh() 1776 | curses.doupdate() 1777 | time.sleep(0.1) 1778 | 1779 | self.pushing = False 1780 | self.draw() 1781 | STDSCR.refresh() 1782 | curses.doupdate() 1783 | time.sleep(0.1) 1784 | 1785 | 1786 | 1787 | class Alert(Window): 1788 | '''an alert box with buttons''' 1789 | 1790 | def __init__(self, colors, title, msg, buttons=None, default=0, 1791 | border=True, center_text=True): 1792 | '''initialize''' 1793 | 1794 | # determine width and height 1795 | w = 0 1796 | lines = msg.split('\n') 1797 | for line in lines: 1798 | if len(line) > w: 1799 | w = len(line) 1800 | w += 2 1801 | if buttons is not None: 1802 | bw = 0 1803 | for label in buttons: 1804 | bw += button_width(label) + 2 1805 | bw += 2 1806 | if bw > w: 1807 | w = bw 1808 | 1809 | h = len(lines) + 4 1810 | if border: 1811 | w += 2 1812 | h += 2 1813 | 1814 | if w > VIDEO.w: 1815 | w = VIDEO.w 1816 | 1817 | # center the box 1818 | x = center_x(w) 1819 | y = center_y(h) 1820 | 1821 | super().__init__(x, y, w, h, colors, title, border) 1822 | 1823 | self.text = lines 1824 | self.center_text = center_text 1825 | 1826 | # y position of the button bar 1827 | y = self.bounds.h - 2 1828 | assert y > 0 1829 | 1830 | self.hotkeys = [] 1831 | 1832 | if buttons is None: 1833 | # one OK button: center it 1834 | label = 'K' 1835 | x = center_x(button_width(label), self.bounds.w) 1836 | self.buttons = [Button(self, x, y, self.colors, label),] 1837 | else: 1838 | # make and position button widgets 1839 | self.buttons = [] 1840 | 1841 | # determine spacing 1842 | total_len = 0 1843 | for label in buttons: 1844 | total_len += button_width(label) 1845 | 1846 | # spacing is a floating point number 1847 | # but the button will have an integer position 1848 | spacing = (self.bounds.w - total_len) / (len(buttons) + 1.0) 1849 | if spacing < 1.0: 1850 | spacing = 1.0 1851 | 1852 | x = spacing 1853 | for label in buttons: 1854 | button = Button(self, int(x), y, self.colors, label) 1855 | self.buttons.append(button) 1856 | x += spacing + button_width(label) 1857 | 1858 | # save hotkey 1859 | hotkey, _, _ = label_hotkey(label) 1860 | self.hotkeys.append(hotkey) 1861 | 1862 | assert 0 <= default < len(self.buttons) 1863 | self.cursor = self.default = default 1864 | 1865 | def resize_event(self): 1866 | '''the terminal was resized''' 1867 | 1868 | w = self.frame.w 1869 | h = self.frame.h 1870 | x = center_x(w, VIDEO.w) 1871 | y = center_y(h, VIDEO.h) 1872 | 1873 | self.frame = Rect(x, y, w, h) 1874 | 1875 | # bounds is the inner area; for view content 1876 | if self.has_border: 1877 | self.bounds = Rect(x + 1, y + 1, w - 2, h - 2) 1878 | else: 1879 | self.bounds = self.frame.copy() 1880 | 1881 | # rect is the outer area; larger because of shadow 1882 | if self.has_shadow: 1883 | self.rect = Rect(x, y, w + 2, h + 1) 1884 | else: 1885 | self.rect = self.frame.copy() 1886 | 1887 | def draw(self): 1888 | '''draw the alert box''' 1889 | 1890 | super().draw() 1891 | 1892 | # draw the text 1893 | y = 1 1894 | for line in self.text: 1895 | if self.center_text: 1896 | x = center_x(len(line), self.bounds.w) 1897 | else: 1898 | x = 1 1899 | self.cputs(x, y, line) 1900 | y += 1 1901 | 1902 | # draw buttons 1903 | self.draw_buttons() 1904 | 1905 | def draw_buttons(self): 1906 | '''draw the buttons''' 1907 | 1908 | for button in self.buttons: 1909 | button.draw() 1910 | 1911 | def move_right(self): 1912 | '''select button to the right''' 1913 | 1914 | if len(self.buttons) <= 1: 1915 | return 1916 | 1917 | self.buttons[self.cursor].lose_focus() 1918 | self.cursor += 1 1919 | if self.cursor >= len(self.buttons): 1920 | self.cursor = 0 1921 | self.buttons[self.cursor].gain_focus() 1922 | 1923 | def move_left(self): 1924 | '''select button to the left''' 1925 | 1926 | if len(self.buttons) <= 1: 1927 | return 1928 | 1929 | self.buttons[self.cursor].lose_focus() 1930 | self.cursor -= 1 1931 | if self.cursor < 0: 1932 | self.cursor = len(self.buttons) - 1 1933 | self.buttons[self.cursor].gain_focus() 1934 | 1935 | def push(self): 1936 | '''push selected button''' 1937 | 1938 | self.buttons[self.cursor].push() 1939 | 1940 | def push_hotkey(self, key): 1941 | '''push hotkey 1942 | Returns True if key indeed pushed a button 1943 | ''' 1944 | 1945 | if not self.hotkeys: 1946 | return False 1947 | 1948 | if len(key) == 1: 1949 | key = key.upper() 1950 | 1951 | idx = 0 1952 | for hotkey in self.hotkeys: 1953 | if hotkey == key: 1954 | if self.cursor != idx: 1955 | self.buttons[self.cursor].lose_focus() 1956 | self.cursor = idx 1957 | self.buttons[self.cursor].gain_focus() 1958 | 1959 | self.push() 1960 | return True 1961 | 1962 | idx += 1 1963 | 1964 | return False 1965 | 1966 | def runloop(self): 1967 | '''run the alert dialog 1968 | Returns button choice or -1 on escape 1969 | ''' 1970 | 1971 | # always open with the default button active 1972 | self.cursor = self.default 1973 | self.buttons[self.cursor].gain_focus() 1974 | 1975 | while True: 1976 | key = getch() 1977 | 1978 | if key == KEY_ESC: 1979 | self.close() 1980 | return RETURN_TO_PREVIOUS 1981 | 1982 | if key == KEY_LEFT or key == KEY_BTAB: # pylint: disable=consider-using-in 1983 | self.move_left() 1984 | 1985 | elif key == KEY_RIGHT or key == KEY_TAB: # pylint: disable=consider-using-in 1986 | self.move_right() 1987 | 1988 | elif key == KEY_RETURN or key == ' ': # pylint: disable=consider-using-in 1989 | self.push() 1990 | self.close() 1991 | return self.cursor 1992 | 1993 | elif self.push_hotkey(key): 1994 | self.close() 1995 | return self.cursor 1996 | 1997 | 1998 | 1999 | class MenuItem: 2000 | '''a single menu item''' 2001 | 2002 | def __init__(self, label): 2003 | '''initialize''' 2004 | 2005 | self.hotkey, self.hotkey_pos, self.text = label_hotkey(label) 2006 | 2007 | 2008 | 2009 | class Menu(Window): 2010 | '''a (dropdown) menu''' 2011 | 2012 | def __init__(self, x, y, colors, border=True, items=None, closekey=None): 2013 | '''initialize''' 2014 | 2015 | # should really have a list of items 2016 | assert items is not None 2017 | 2018 | # determine width and height 2019 | w = 0 2020 | for item in items: 2021 | l = label_length(item) + 2 2022 | if border: 2023 | l += 2 2024 | if l > w: 2025 | w = l 2026 | h = len(items) 2027 | if border: 2028 | h += 2 2029 | 2030 | super().__init__(x, y, w, h, colors, None, border) 2031 | 2032 | # make list of MenuItems 2033 | self.items = [MenuItem(item) for item in items] 2034 | self.closekey = closekey 2035 | self.cursor = 0 2036 | 2037 | def draw(self): 2038 | '''draw the window''' 2039 | 2040 | super().draw() 2041 | self.draw_items() 2042 | 2043 | def draw_items(self): 2044 | '''draw the items''' 2045 | 2046 | y = 0 2047 | for item in self.items: 2048 | if y == self.cursor: 2049 | # draw_cursor() will be called by Window.show() 2050 | pass 2051 | else: 2052 | # normal entry 2053 | if item.text == '--': 2054 | # separator line 2055 | VIDEO.hsplit(self.frame.x, self.bounds.y + y, 2056 | self.frame.w, curses.ACS_HLINE, 2057 | self.colors.border) 2058 | else: 2059 | self.cputs(1, y, item.text, self.colors.menu) 2060 | if item.hotkey is not None: 2061 | # draw hotkey 2062 | self.putch(1 + item.hotkey_pos, y, item.hotkey, 2063 | self.colors.menuhotkey) 2064 | y += 1 2065 | 2066 | def draw_cursor(self): 2067 | '''draw highlighted cursor line''' 2068 | 2069 | if self.flags & Window.FOCUS: 2070 | attr = self.colors.activemenu 2071 | attr_hotkey = self.colors.activemenuhotkey 2072 | else: 2073 | attr = self.colors.menu 2074 | attr_hotkey = self.colors.menuhotkey 2075 | 2076 | item = self.items[self.cursor] 2077 | self.cputs(0, self.cursor, ' ' + item.text, attr, alt=True) 2078 | if item.hotkey is not None: 2079 | # draw hotkey 2080 | self.putch(1 + item.hotkey_pos, self.cursor, item.hotkey, 2081 | attr_hotkey, alt=True) 2082 | 2083 | def clear_cursor(self): 2084 | '''erase the cursor''' 2085 | 2086 | item = self.items[self.cursor] 2087 | self.cputs(0, self.cursor, ' ' + item.text, self.colors.menu) 2088 | if item.hotkey is not None: 2089 | # draw hotkey 2090 | self.putch(1 + item.hotkey_pos, self.cursor, item.hotkey, 2091 | self.colors.menuhotkey) 2092 | 2093 | def selection(self): 2094 | '''Returns plaintext of currently selected item''' 2095 | 2096 | item = self.items[self.cursor] 2097 | return item.text 2098 | 2099 | def move_up(self): 2100 | '''move up''' 2101 | 2102 | self.clear_cursor() 2103 | self.cursor -= 1 2104 | if self.cursor < 0: 2105 | self.cursor += len(self.items) 2106 | 2107 | if self.items[self.cursor].text == '--': 2108 | # skip over separator line 2109 | self.cursor -= 1 2110 | assert self.cursor >= 0 2111 | assert self.items[self.cursor].text != '--' 2112 | 2113 | self.draw_cursor() 2114 | 2115 | def move_down(self): 2116 | '''move down''' 2117 | 2118 | self.clear_cursor() 2119 | self.cursor += 1 2120 | if self.cursor >= len(self.items): 2121 | self.cursor = 0 2122 | 2123 | if self.items[self.cursor].text == '--': 2124 | # skip over separator line 2125 | self.cursor += 1 2126 | assert self.cursor < len(self.items) 2127 | assert self.items[self.cursor].text != '--' 2128 | 2129 | self.draw_cursor() 2130 | 2131 | def goto_top(self): 2132 | '''go to top of menu''' 2133 | 2134 | if self.cursor == 0: 2135 | return 2136 | 2137 | self.clear_cursor() 2138 | self.cursor = 0 2139 | self.draw_cursor() 2140 | 2141 | def goto_bottom(self): 2142 | '''go to bottom of menu''' 2143 | 2144 | if self.cursor >= len(self.items) - 1: 2145 | return 2146 | 2147 | self.clear_cursor() 2148 | self.cursor = len(self.items) - 1 2149 | self.draw_cursor() 2150 | 2151 | def push_hotkey(self, key): 2152 | '''Returns True if the hotkey was pressed''' 2153 | 2154 | key = key.upper() 2155 | y = 0 2156 | for item in self.items: 2157 | if item.hotkey == key: 2158 | if self.cursor != y: 2159 | self.clear_cursor() 2160 | self.cursor = y 2161 | self.draw_cursor() 2162 | # give visual feedback 2163 | STDSCR.refresh() 2164 | curses.doupdate() 2165 | time.sleep(0.1) 2166 | 2167 | return True 2168 | 2169 | y += 1 2170 | 2171 | return False 2172 | 2173 | def runloop(self): 2174 | '''run a menu''' 2175 | 2176 | self.show() 2177 | 2178 | while True: 2179 | key = getch() 2180 | 2181 | if key == KEY_ESC: 2182 | self.close() 2183 | return RETURN_TO_PREVIOUS 2184 | 2185 | if key == self.closekey or key.upper() == self.closekey: 2186 | self.close() 2187 | return MENU_CLOSE 2188 | 2189 | if key == KEY_LEFT or key == KEY_BTAB: # pylint: disable=consider-using-in 2190 | self.close() 2191 | return MENU_LEFT 2192 | 2193 | if key == KEY_RIGHT or key == KEY_TAB: # pylint: disable=consider-using-in 2194 | self.close() 2195 | return MENU_RIGHT 2196 | 2197 | if key == KEY_UP: 2198 | self.move_up() 2199 | 2200 | elif key == KEY_DOWN: 2201 | self.move_down() 2202 | 2203 | elif key == KEY_PAGEUP or key == KEY_HOME: # pylint: disable=consider-using-in 2204 | self.goto_top() 2205 | 2206 | elif key == KEY_PAGEDOWN or key == KEY_END: # pylint: disable=consider-using-in 2207 | self.goto_bottom() 2208 | 2209 | elif key == KEY_RETURN or key == ' ': # pylint: disable=consider-using-in 2210 | self.close() 2211 | return self.cursor 2212 | 2213 | elif self.push_hotkey(key): 2214 | self.close() 2215 | return self.cursor 2216 | 2217 | 2218 | 2219 | class MenuBar(Window): 2220 | '''represents a menu bar''' 2221 | 2222 | def __init__(self, colors, menus, border=True): 2223 | '''initialize 2224 | menus is a list of tuples: ('header', ['item #1', 'item #2', 'etc.']) 2225 | ''' 2226 | 2227 | super().__init__(0, 0, VIDEO.w, 1, colors, border=False) 2228 | 2229 | # make list of headers and menus 2230 | self.headers = [MenuItem(m[0]) for m in menus] 2231 | 2232 | # make list of x positions for each header 2233 | self.pos = [] 2234 | x = 2 2235 | for header in self.headers: 2236 | self.pos.append(x) 2237 | x += len(header.text) + 2 2238 | 2239 | self.cursor = 0 2240 | 2241 | # make list of menus 2242 | self.menus = [] 2243 | x = 0 2244 | for m in menus: 2245 | items = m[1] 2246 | menu = Menu(self.pos[x] - 1, self.frame.y + 1, colors, border, 2247 | items, self.headers[x].hotkey) 2248 | self.menus.append(menu) 2249 | x += 1 2250 | 2251 | # last chosen menu entry 2252 | self.choice = -1 2253 | 2254 | def resize_event(self): 2255 | '''the terminal was resized''' 2256 | 2257 | self.frame.w = self.bounds.w = self.rect.w = VIDEO.w 2258 | 2259 | def draw(self): 2260 | '''draw menu bar''' 2261 | 2262 | VIDEO.hline(0, 0, VIDEO.w, ' ', self.colors.menu) 2263 | x = 0 2264 | for header in self.headers: 2265 | if x == self.cursor: 2266 | # cursor will be drawn via Window.show() 2267 | pass 2268 | else: 2269 | self.puts(self.pos[x], 0, header.text, self.colors.menu) 2270 | if header.hotkey is not None: 2271 | # draw hotkey 2272 | self.putch(self.pos[x] + header.hotkey_pos, 0, 2273 | header.hotkey, self.colors.menuhotkey) 2274 | x += 1 2275 | 2276 | def draw_cursor(self): 2277 | '''draw the cursor (highlighted when in focus)''' 2278 | 2279 | if self.flags & Window.FOCUS: 2280 | color = self.colors.activemenu 2281 | color_hotkey = self.colors.activemenuhotkey 2282 | alt = True 2283 | else: 2284 | color = self.colors.menu 2285 | color_hotkey = self.colors.menuhotkey 2286 | alt = False 2287 | 2288 | header = self.headers[self.cursor] 2289 | self.puts(self.pos[self.cursor] - 1, 0, ' ' + header.text + ' ', 2290 | color, alt) 2291 | if header.hotkey is not None: 2292 | # draw hotkey 2293 | self.putch(self.pos[self.cursor] + header.hotkey_pos, 0, 2294 | header.hotkey, color_hotkey, alt) 2295 | 2296 | def clear_cursor(self): 2297 | '''erase cursor''' 2298 | 2299 | color = self.colors.menu 2300 | color_hotkey = self.colors.menuhotkey 2301 | 2302 | header = self.headers[self.cursor] 2303 | self.puts(self.pos[self.cursor] - 1, 0, ' ' + header.text + ' ', 2304 | color) 2305 | if header.hotkey is not None: 2306 | # draw hotkey 2307 | self.putch(self.pos[self.cursor] + header.hotkey_pos, 0, 2308 | header.hotkey, color_hotkey) 2309 | 2310 | def selection(self): 2311 | '''Returns plaintext of selected item, or None if none''' 2312 | 2313 | if self.choice == -1: 2314 | return None 2315 | 2316 | menu = self.menus[self.cursor] 2317 | return menu.items[self.choice].text 2318 | 2319 | def position(self): 2320 | '''Returns tuple: (header index, item index) 2321 | If item index == -1, no choice was made 2322 | ''' 2323 | 2324 | return self.cursor, self.choice 2325 | 2326 | def move_left(self): 2327 | '''move left''' 2328 | 2329 | self.clear_cursor() 2330 | self.cursor -= 1 2331 | if self.cursor < 0: 2332 | self.cursor += len(self.headers) 2333 | self.draw_cursor() 2334 | 2335 | def move_right(self): 2336 | '''move right''' 2337 | 2338 | self.clear_cursor() 2339 | self.cursor += 1 2340 | if self.cursor >= len(self.headers): 2341 | self.cursor = 0 2342 | self.draw_cursor() 2343 | 2344 | def push_hotkey(self, key): 2345 | '''Returns True if the hotkey was pressed''' 2346 | 2347 | key = key.upper() 2348 | x = 0 2349 | for header in self.headers: 2350 | if header.hotkey == key: 2351 | if self.cursor != x: 2352 | self.clear_cursor() 2353 | self.cursor = x 2354 | self.draw_cursor() 2355 | # give visual feedback 2356 | STDSCR.refresh() 2357 | curses.doupdate() 2358 | time.sleep(0.1) 2359 | 2360 | return True 2361 | 2362 | x += 1 2363 | 2364 | return False 2365 | 2366 | def runloop(self): 2367 | '''run the menu bar''' 2368 | 2369 | while True: 2370 | key = getch() 2371 | 2372 | if key == KEY_ESC: 2373 | self.choice = RETURN_TO_PREVIOUS 2374 | self.back() 2375 | return RETURN_TO_PREVIOUS 2376 | 2377 | if key == KEY_LEFT: 2378 | self.move_left() 2379 | 2380 | elif key == KEY_RIGHT: 2381 | self.move_right() 2382 | 2383 | elif (key == KEY_RETURN or key == ' ' or key == KEY_DOWN or 2384 | self.push_hotkey(key)): 2385 | # activate the menu 2386 | while True: 2387 | self.menus[self.cursor].show() 2388 | 2389 | # showing the menu makes the menubar lose focus 2390 | # redraw the menubar cursor however 2391 | # so the menubar cursor stays active 2392 | # while the menu is open 2393 | self.flags |= Window.FOCUS 2394 | self.draw_cursor() 2395 | 2396 | choice = self.menus[self.cursor].runloop() 2397 | if choice == RETURN_TO_PREVIOUS: 2398 | # escape: closed menu 2399 | self.choice = RETURN_TO_PREVIOUS 2400 | self.back() 2401 | return RETURN_TO_PREVIOUS 2402 | 2403 | if choice == MENU_CLOSE: 2404 | # close menu; return to menubar 2405 | break 2406 | 2407 | if choice == MENU_LEFT: 2408 | # navigate left and open menu 2409 | self.move_left() 2410 | 2411 | elif choice == MENU_RIGHT: 2412 | # navigate right and open menu 2413 | self.move_right() 2414 | 2415 | else: 2416 | self.back() 2417 | self.choice = choice 2418 | return choice 2419 | 2420 | 2421 | 2422 | class TextField(Widget): 2423 | '''single line of text input''' 2424 | 2425 | MAX_HISTORY = 50 2426 | 2427 | def __init__(self, parent, x, y, w, colors, history=True, 2428 | inputfilter=None): 2429 | '''initialize''' 2430 | 2431 | super().__init__(parent, x, y, colors) 2432 | 2433 | self.w = w 2434 | self.text = '' 2435 | self.cursor = 0 2436 | if history: 2437 | self.history = [] 2438 | self.history_cursor = 0 2439 | else: 2440 | self.history = None 2441 | self.inputfilter = inputfilter 2442 | 2443 | def draw(self): 2444 | '''draw the TextField''' 2445 | 2446 | w = len(self.text) 2447 | VIDEO.puts(self.x, self.y, self.text, self.colors.text) 2448 | # clear to EOL 2449 | VIDEO.hline(self.x + w, self.y, self.w - w, ' ', self.colors.text) 2450 | 2451 | self.draw_cursor() 2452 | 2453 | def draw_cursor(self): 2454 | '''draw the cursor''' 2455 | 2456 | if self.has_focus: 2457 | # draw cursor 2458 | if self.cursor < len(self.text): 2459 | ch = self.text[self.cursor] 2460 | else: 2461 | ch = ' ' 2462 | VIDEO.putch(self.x + self.cursor, self.y, ch, self.colors.cursor, 2463 | alt=True) 2464 | 2465 | def clear(self): 2466 | '''clears the TextField onscreen (not the TextField content)''' 2467 | 2468 | VIDEO.hline(self.x, self.y, self.w, ' ', self.colors.text) 2469 | 2470 | def add_history(self): 2471 | '''add entered text to history''' 2472 | 2473 | if self.history is None or not self.text: 2474 | return 2475 | 2476 | try: 2477 | idx = self.history.index(self.text) 2478 | except ValueError: 2479 | # not yet in history 2480 | self.history.append(self.text) 2481 | 2482 | if len(self.history) > TextField.MAX_HISTORY: 2483 | # discard oldest entry 2484 | self.history.pop(0) 2485 | 2486 | else: 2487 | # make most recent item 2488 | self.history.pop(idx) 2489 | self.history.append(self.text) 2490 | 2491 | self.history_cursor = 0 2492 | 2493 | def recall_up(self): 2494 | '''go back in history''' 2495 | 2496 | if self.history is None or not self.history: 2497 | return 2498 | 2499 | self.history_cursor -= 1 2500 | if self.history_cursor < 0: 2501 | self.history_cursor = len(self.history) - 1 2502 | 2503 | self.text = self.history[self.history_cursor] 2504 | self.cursor = len(self.text) 2505 | 2506 | self.draw() 2507 | self.draw_cursor() 2508 | 2509 | def recall_down(self): 2510 | '''go forward in history''' 2511 | 2512 | if (self.history is None or 2513 | self.history_cursor >= len(self.history) or 2514 | not self.history): 2515 | return 2516 | 2517 | if self.history_cursor < len(self.history): 2518 | self.history_cursor += 1 2519 | 2520 | if self.history_cursor < len(self.history): 2521 | self.text = self.history[self.history_cursor] 2522 | else: 2523 | self.text = '' 2524 | 2525 | self.cursor = len(self.text) 2526 | 2527 | self.draw() 2528 | self.draw_cursor() 2529 | 2530 | def runloop(self): 2531 | '''run the TextField''' 2532 | 2533 | # reset the text 2534 | self.text = '' 2535 | self.cursor = 0 2536 | self.draw() 2537 | 2538 | self.gain_focus() 2539 | 2540 | while True: 2541 | key = getch() 2542 | 2543 | if key == KEY_ESC: 2544 | self.text = '' 2545 | self.cursor = 0 2546 | self.lose_focus() 2547 | self.clear() 2548 | return RETURN_TO_PREVIOUS 2549 | 2550 | if key == KEY_BTAB: 2551 | self.lose_focus() 2552 | self.clear() 2553 | return BACK 2554 | 2555 | if key == KEY_TAB: 2556 | self.lose_focus() 2557 | self.clear() 2558 | return NEXT 2559 | 2560 | if key == KEY_RETURN: 2561 | self.add_history() 2562 | self.lose_focus() 2563 | self.clear() 2564 | return ENTER 2565 | 2566 | if key == KEY_BS: 2567 | if self.cursor > 0: 2568 | self.text = (self.text[:self.cursor - 1] + 2569 | self.text[self.cursor:]) 2570 | self.cursor -= 1 2571 | self.draw() 2572 | 2573 | elif key == KEY_DEL: 2574 | if self.cursor < len(self.text): 2575 | self.text = (self.text[:self.cursor] + 2576 | self.text[self.cursor + 1:]) 2577 | self.draw() 2578 | 2579 | elif key == KEY_LEFT: 2580 | if self.cursor > 0: 2581 | self.cursor -= 1 2582 | self.draw() 2583 | 2584 | elif key == KEY_RIGHT: 2585 | if self.cursor < len(self.text): 2586 | self.cursor += 1 2587 | self.draw() 2588 | 2589 | elif key == KEY_HOME: 2590 | if self.cursor > 0: 2591 | self.cursor = 0 2592 | self.draw() 2593 | 2594 | elif key == KEY_END: 2595 | if self.cursor != len(self.text): 2596 | self.cursor = len(self.text) 2597 | self.draw() 2598 | 2599 | elif key == KEY_UP: 2600 | self.recall_up() 2601 | 2602 | elif key == KEY_DOWN: 2603 | self.recall_down() 2604 | 2605 | elif len(key) == 1 and len(self.text) < self.w: 2606 | if self.inputfilter is not None: 2607 | ch = self.inputfilter(key) 2608 | else: 2609 | ch = self.default_inputfilter(key) 2610 | 2611 | if ch is not None: 2612 | self.text = (self.text[:self.cursor] + ch + 2613 | self.text[self.cursor:]) 2614 | self.cursor += 1 2615 | self.draw() 2616 | 2617 | def default_inputfilter(self, key): 2618 | '''Returns key if valid input 2619 | or None if invalid 2620 | ''' 2621 | 2622 | val = ord(key) 2623 | if ord(' ') <= val <= ord('~'): 2624 | return key 2625 | 2626 | return None 2627 | 2628 | 2629 | 2630 | class CmdLine(Window): 2631 | '''command line: single line with prompt''' 2632 | 2633 | def __init__(self, x, y, w, colors, prompt=None): 2634 | '''initialize''' 2635 | 2636 | super().__init__(x, y, w, 1, colors, title=None, border=False) 2637 | x = self.bounds.x 2638 | w = self.bounds.w 2639 | self.prompt = prompt 2640 | if self.prompt is not None: 2641 | x += len(self.prompt) 2642 | w -= len(self.prompt) 2643 | if w < 1: 2644 | w = 1 2645 | 2646 | self.textfield = TextField(self, x, self.bounds.y, w, colors) 2647 | 2648 | def draw(self): 2649 | '''draw the command line''' 2650 | 2651 | if self.prompt is not None: 2652 | self.puts(0, 0, self.prompt, self.colors.prompt) 2653 | 2654 | self.textfield.draw() 2655 | 2656 | def draw_cursor(self): 2657 | '''draw the cursor''' 2658 | 2659 | self.textfield.draw_cursor() 2660 | 2661 | def runloop(self): 2662 | '''run the command line window''' 2663 | 2664 | ret = self.textfield.runloop() 2665 | self.close() 2666 | return ret 2667 | 2668 | 2669 | 2670 | class WindowStack: 2671 | '''represents a stack of Windows''' 2672 | 2673 | def __init__(self): 2674 | '''initialize''' 2675 | 2676 | self.stack = [] 2677 | 2678 | def remove(self, win): 2679 | '''Remove window from stack''' 2680 | 2681 | assert isinstance(win, Window) 2682 | try: 2683 | self.stack.remove(win) 2684 | except ValueError: 2685 | # win was not on stack 2686 | pass 2687 | 2688 | def front(self, win): 2689 | '''Move window to front''' 2690 | 2691 | self.remove(win) 2692 | self.stack.append(win) 2693 | 2694 | def back(self, win): 2695 | '''Move window back''' 2696 | 2697 | self.remove(win) 2698 | self.stack.insert(0, win) 2699 | 2700 | def top(self): 2701 | '''Returns the top of stack''' 2702 | 2703 | if not self.stack: 2704 | return None 2705 | 2706 | return self.stack[-1] 2707 | 2708 | 2709 | 2710 | def video_color(fg, bg=None, bold=False): 2711 | '''Returns combined (ScreenBuf) color code''' 2712 | 2713 | if bg is None: 2714 | # passed in only a combined color code 2715 | return fg 2716 | 2717 | assert 0 <= fg < BOLD 2718 | assert 0 <= bg < BOLD 2719 | 2720 | if bold: 2721 | return (bg << 4) | BOLD | fg 2722 | 2723 | return (bg << 4) | fg 2724 | 2725 | 2726 | def reverse_video(color): 2727 | '''Returns reverse of combined color code''' 2728 | 2729 | bg = color >> 4 2730 | fg = color & 7 2731 | # in general looks nicer without bold 2732 | # bold = color & BOLD 2733 | return (fg << 4) | bg 2734 | 2735 | 2736 | def curses_color(fg, bg=None, bold=False, alt=False): 2737 | '''Returns curses colorpair index''' 2738 | 2739 | global CURSES_COLORPAIR_IDX 2740 | 2741 | if not HAS_COLORS: 2742 | if alt: 2743 | return curses.A_REVERSE 2744 | return 0 2745 | 2746 | if bg is None: 2747 | # passed in only a combined color code 2748 | color = fg 2749 | fg = color & 7 2750 | bg = color >> 4 2751 | bold = (color & BOLD) == BOLD 2752 | 2753 | assert 0 <= fg < BOLD 2754 | assert 0 <= bg < BOLD 2755 | 2756 | idx = '{:02x}'.format((bg << 4) | fg) 2757 | if idx not in CURSES_COLORPAIRS: 2758 | # make new curses color pair 2759 | assert 0 <= CURSES_COLORPAIR_IDX < curses.COLOR_PAIRS - 1 2760 | CURSES_COLORPAIR_IDX += 1 2761 | fg = CURSES_COLORS[fg] 2762 | bg = CURSES_COLORS[bg] 2763 | curses.init_pair(CURSES_COLORPAIR_IDX, fg, bg) 2764 | CURSES_COLORPAIRS[idx] = CURSES_COLORPAIR_IDX 2765 | 2766 | color = curses.color_pair(CURSES_COLORPAIRS[idx]) 2767 | if bold: 2768 | return color | curses.A_BOLD 2769 | #else 2770 | return color 2771 | 2772 | 2773 | def label_hotkey(label): 2774 | '''Returns triple: (hotkey, hotkey position, plaintext) of the label 2775 | or None if there is none 2776 | 2777 | Mind that hotkeys are uppercase, or may also be "Ctrl-key" 2778 | ''' 2779 | 2780 | m = REGEX_HOTKEY.match(label) 2781 | if m is None: 2782 | return (None, -1, label) 2783 | 2784 | hotkey = m.groups()[0] 2785 | if len(hotkey) == 1: 2786 | hotkey = hotkey.upper() 2787 | 2788 | hotkey_pos = label.find('<') 2789 | if hotkey_pos > -1: 2790 | # strip out hooks 2791 | plaintext = label.replace('<', '').replace('>', '') 2792 | else: 2793 | plaintext = label 2794 | 2795 | return (hotkey, hotkey_pos, plaintext) 2796 | 2797 | 2798 | def label_length(label): 2799 | '''Returns visual label length''' 2800 | 2801 | m = REGEX_HOTKEY.match(label) 2802 | if m is None: 2803 | return len(label) 2804 | #else 2805 | return len(label) - 2 2806 | 2807 | 2808 | def button_width(label): 2809 | '''Returns visual size of a button''' 2810 | 2811 | if isinstance(label, Button): 2812 | label = label.label 2813 | 2814 | assert isinstance(label, str) 2815 | 2816 | w = label_length(label) 2817 | if w <= 3: 2818 | w += 2 2819 | return w + 4 2820 | 2821 | 2822 | def center_x(width, area=0): 2823 | '''Return centered x coordinate 2824 | If area is not given, center on screen 2825 | ''' 2826 | 2827 | if area == 0: 2828 | area = VIDEO.w 2829 | 2830 | x = (area - width) * 0.5 2831 | 2832 | # round up for funny looking non-centered objects 2833 | return int(x + 0.5) 2834 | 2835 | 2836 | def center_y(height, area=0): 2837 | '''Return centered y coordinate 2838 | If area is not given, put it in top half of screen 2839 | ''' 2840 | 2841 | if area == 0: 2842 | y = (VIDEO.h - height) * 0.35 2843 | else: 2844 | y = (area - height) * 0.5 2845 | 2846 | return int(y + 0.5) 2847 | 2848 | 2849 | def linemode(mode): 2850 | '''set linemode''' 2851 | 2852 | global LINEMODE, CURSES_LINES 2853 | 2854 | if CURSES_LINES is None: 2855 | # save original curses line characters 2856 | CURSES_LINES = (curses.ACS_HLINE, curses.ACS_VLINE, 2857 | curses.ACS_ULCORNER, curses.ACS_URCORNER, 2858 | curses.ACS_LLCORNER, curses.ACS_LRCORNER, 2859 | curses.ACS_LTEE, curses.ACS_RTEE, 2860 | curses.ACS_TTEE, curses.ACS_BTEE) 2861 | else: 2862 | # restore original curses line characters 2863 | curses.ACS_HLINE = CURSES_LINES[0] 2864 | curses.ACS_VLINE = CURSES_LINES[1] 2865 | curses.ACS_ULCORNER = CURSES_LINES[2] 2866 | curses.ACS_URCORNER = CURSES_LINES[3] 2867 | curses.ACS_LLCORNER = CURSES_LINES[4] 2868 | curses.ACS_LRCORNER = CURSES_LINES[5] 2869 | curses.ACS_LTEE = CURSES_LINES[6] 2870 | curses.ACS_RTEE = CURSES_LINES[7] 2871 | curses.ACS_TTEE = CURSES_LINES[8] 2872 | curses.ACS_BTEE = CURSES_LINES[9] 2873 | 2874 | LINEMODE = mode 2875 | 2876 | # redefine character set 2877 | if LINEMODE & LM_ASCII: 2878 | curses.ACS_HLINE = ord('-') 2879 | curses.ACS_VLINE = ord('|') 2880 | curses.ACS_ULCORNER = ord('+') 2881 | curses.ACS_URCORNER = ord('+') 2882 | curses.ACS_LLCORNER = ord('+') 2883 | curses.ACS_LRCORNER = ord('+') 2884 | curses.ACS_LTEE = ord('+') 2885 | curses.ACS_RTEE = ord('+') 2886 | curses.ACS_TTEE = ord('+') 2887 | curses.ACS_BTEE = ord('+') 2888 | 2889 | if not LINEMODE & LM_VLINE: 2890 | curses.ACS_VLINE = ord(' ') 2891 | curses.ACS_ULCORNER = curses.ACS_HLINE 2892 | curses.ACS_URCORNER = curses.ACS_HLINE 2893 | curses.ACS_LLCORNER = curses.ACS_HLINE 2894 | curses.ACS_LRCORNER = curses.ACS_HLINE 2895 | curses.ACS_LTEE = curses.ACS_HLINE 2896 | curses.ACS_RTEE = curses.ACS_HLINE 2897 | curses.ACS_TTEE = curses.ACS_HLINE 2898 | curses.ACS_BTEE = curses.ACS_HLINE 2899 | 2900 | if not LINEMODE & LM_HLINE: 2901 | curses.ACS_HLINE = ord(' ') 2902 | curses.ACS_ULCORNER = curses.ACS_VLINE 2903 | curses.ACS_URCORNER = curses.ACS_VLINE 2904 | curses.ACS_LLCORNER = curses.ACS_VLINE 2905 | curses.ACS_LRCORNER = curses.ACS_VLINE 2906 | curses.ACS_LTEE = curses.ACS_VLINE 2907 | curses.ACS_RTEE = curses.ACS_VLINE 2908 | curses.ACS_TTEE = curses.ACS_VLINE 2909 | curses.ACS_BTEE = curses.ACS_VLINE 2910 | 2911 | 2912 | def init_curses(): 2913 | '''initialize curses''' 2914 | 2915 | global STDSCR, CURSES_COLORS, HAS_COLORS 2916 | 2917 | os.environ['ESCDELAY'] = '25' 2918 | 2919 | STDSCR = curses.initscr() 2920 | curses.savetty() 2921 | 2922 | if WANT_COLORS: 2923 | HAS_COLORS = curses.has_colors() 2924 | else: 2925 | HAS_COLORS = False 2926 | 2927 | if HAS_COLORS: 2928 | curses.start_color() 2929 | 2930 | curses.noecho() 2931 | STDSCR.keypad(1) 2932 | curses.raw() 2933 | 2934 | # disable cursor 2935 | # may fail on some kind of terminal 2936 | try: 2937 | curses.curs_set(0) 2938 | except curses.error: 2939 | pass 2940 | 2941 | curses.nonl() 2942 | curses.noqiflush() 2943 | 2944 | # do not scroll the screen, ever 2945 | # even though these settings seem to have no effect (?) 2946 | STDSCR.scrollok(False) 2947 | STDSCR.idlok(False) 2948 | STDSCR.idcok(False) 2949 | 2950 | # init colors in same order as the color 'enums' 2951 | # Sadly, curses.CODES do not exist until initscr() is done 2952 | CURSES_COLORS = (curses.COLOR_BLACK, 2953 | curses.COLOR_BLUE, 2954 | curses.COLOR_GREEN, 2955 | curses.COLOR_CYAN, 2956 | curses.COLOR_RED, 2957 | curses.COLOR_MAGENTA, 2958 | curses.COLOR_YELLOW, 2959 | curses.COLOR_WHITE) 2960 | 2961 | # odd ... screen must be refreshed at least once, 2962 | # or things won't work as expected 2963 | STDSCR.refresh() 2964 | 2965 | 2966 | def terminate(): 2967 | '''end the curses window mode''' 2968 | 2969 | if STDSCR is not None: 2970 | # enable cursor 2971 | # may fail on some kind of terminal 2972 | try: 2973 | curses.curs_set(1) 2974 | except curses.error: 2975 | pass 2976 | 2977 | # set cursor at bottom of screen 2978 | STDSCR.move(VIDEO.h - 1, 0) 2979 | 2980 | curses.nocbreak() 2981 | STDSCR.keypad(0) 2982 | curses.echo() 2983 | curses.resetty() 2984 | curses.endwin() 2985 | 2986 | print() 2987 | dump_debug() 2988 | 2989 | sys.stdout.flush() 2990 | sys.stderr.flush() 2991 | 2992 | 2993 | def redraw_screen(): 2994 | '''redraw the entire screen''' 2995 | 2996 | VIDEO.clear_screen() 2997 | 2998 | for win in STACK.stack: 2999 | if not win.flags & Window.SHOWN: 3000 | continue 3001 | 3002 | win.draw() 3003 | win.draw_cursor() 3004 | 3005 | STDSCR.refresh() 3006 | curses.doupdate() 3007 | 3008 | 3009 | def resize_event(): 3010 | '''the terminal was resized''' 3011 | 3012 | global VIDEO 3013 | 3014 | # start over 3015 | VIDEO = Video() 3016 | VIDEO.clear_screen() 3017 | 3018 | for win in STACK.stack: 3019 | win.resize_event() 3020 | win.save_background() 3021 | 3022 | if not win.flags & Window.SHOWN: 3023 | continue 3024 | 3025 | win.draw() 3026 | win.draw_cursor() 3027 | 3028 | redraw_screen() 3029 | 3030 | 3031 | def getch(): 3032 | '''get keyboard input 3033 | Returns key as a string value 3034 | ''' 3035 | 3036 | # move cursor to bottom right corner 3037 | STDSCR.move(VIDEO.h - 1, VIDEO.w - 1) 3038 | # STDSCR.leaveok(0) # leaveok() doesn't work; broken? 3039 | 3040 | # update the screen 3041 | curses.doupdate() 3042 | 3043 | while True: 3044 | key = STDSCR.getch() 3045 | 3046 | ## DEBUG 3047 | if key == 17: 3048 | # Ctrl-Q is hardwired to bail out 3049 | terminate() 3050 | sys.exit(0) 3051 | 3052 | elif key == 18: 3053 | # Ctrl-R redraws the screen 3054 | redraw_screen() 3055 | 3056 | elif key == curses.KEY_RESIZE: 3057 | # terminal was resized 3058 | resize_event() 3059 | 3060 | else: 3061 | # got a user key 3062 | break 3063 | 3064 | if ord(' ') <= key <= ord('~'): 3065 | # ascii keys are returned as string 3066 | return chr(key) 3067 | 3068 | skey = '0x{:02x}'.format(key) 3069 | if skey in KEY_TABLE: 3070 | return KEY_TABLE[skey] 3071 | 3072 | if 1 <= key <= 26: 3073 | # Ctrl-A to Ctrl-Z 3074 | return 'Ctrl-' + chr(ord('@') + key) 3075 | 3076 | return skey 3077 | 3078 | 3079 | def init(): 3080 | '''initialize module''' 3081 | 3082 | global VIDEO, STACK 3083 | 3084 | VIDEO = Video() 3085 | STACK = WindowStack() 3086 | 3087 | 3088 | def unit_test(): 3089 | '''test this module''' 3090 | 3091 | init() 3092 | 3093 | pinky = VIDEO.set_color(YELLOW, MAGENTA) 3094 | VIDEO.set_color(YELLOW, GREEN) 3095 | 3096 | x = VIDEO.w // 2 - 1 3097 | y = VIDEO.h // 2 3098 | 3099 | VIDEO.putch(x, y, 'W') 3100 | VIDEO.putch(x + 1, y, 'J', pinky) 3101 | 3102 | menu_colors = ColorSet(BLACK, WHITE) 3103 | menu_colors.menuhotkey = video_color(RED, WHITE) 3104 | menu_colors.activemenu = video_color(BLACK, GREEN) 3105 | menu_colors.activemenuhotkey = video_color(RED, GREEN) 3106 | 3107 | menubar = MenuBar(menu_colors, 3108 | [('<=>', ['bout', 3109 | '--', 3110 | 'uit Ctrl-Q']), 3111 | ('ile', ['ew Ctrl-N', 3112 | 'oad Ctrl-L', 3113 | 'ave Ctrl-S', 3114 | '--', 3115 | '

rint Ctrl-P']), 3116 | ('dit', ['ndo Ctrl-Z', 3117 | 'Cut Ctrl-X', 3118 | 'opy Ctrl-C', 3119 | '

aste Ctrl-V', 3120 | '--', 3121 | 'ind Ctrl-F', 3122 | 'eplace Ctrl-G']), 3123 | ('ptions', ['Option <1>', 3124 | 'Option <2>', 3125 | 'Option <3>']), 3126 | ('indow', ['inimize', 3127 | 'oom', 3128 | 'ycle through windows', 3129 | 'Bring to ront']), 3130 | ('elp', ['ntroduction', 3131 | 'earch', 3132 | 'nline help']) 3133 | ]) 3134 | menubar.show() 3135 | 3136 | bgcolors = ColorSet(YELLOW, RED, True) 3137 | bgwin = Window(15, 6, 50, 16, bgcolors, title='Back') 3138 | bgwin.show() 3139 | bgwin.puts(0, 0, 'This is the back window') 3140 | 3141 | wincolors = ColorSet(WHITE, BLUE, True) 3142 | wincolors.border = video_color(CYAN, BLUE, True) 3143 | wincolors.title = video_color(YELLOW, BLUE, True) 3144 | wincolors.cursor = video_color(WHITE, BLACK, True) 3145 | wincolors.status = video_color(BLACK, WHITE, False) 3146 | wincolors.scrollbar = wincolors.border 3147 | 3148 | win = TextWindow(0, 1, 60, 20, wincolors, title='Hello') 3149 | win.load('textmode.py') 3150 | win.show() 3151 | 3152 | alert_colors = ColorSet(BLACK, WHITE) 3153 | alert_colors.title = video_color(RED, WHITE) 3154 | alert_colors.button = video_color(WHITE, BLUE, bold=True) 3155 | alert_colors.buttonhotkey = video_color(YELLOW, BLUE, bold=True) 3156 | alert_colors.activebutton = video_color(WHITE, GREEN, bold=True) 3157 | alert_colors.activebuttonhotkey = video_color(YELLOW, GREEN, bold=True) 3158 | 3159 | alert = Alert(alert_colors, title='Alert', msg='Failed to load file', 3160 | buttons=['ancel', 'K'], default=1) 3161 | alert.show() 3162 | 3163 | 3164 | alert.runloop() 3165 | 3166 | colors = ColorSet(WHITE, BLACK) 3167 | colors.cursor = video_color(WHITE, GREEN, bold=True) 3168 | cmdline = CmdLine(0, VIDEO.h - 1, VIDEO.w, colors, ':') 3169 | cmdline.show() 3170 | 3171 | # main loop 3172 | while True: 3173 | view = STACK.top() 3174 | if view is None: 3175 | break 3176 | 3177 | event = view.runloop() 3178 | if event == RETURN_TO_PREVIOUS: 3179 | continue 3180 | 3181 | if event == GOTO_MENUBAR: 3182 | # activate menubar by making it front 3183 | menubar.front() 3184 | 3185 | terminate() 3186 | 3187 | 3188 | 3189 | if __name__ == '__main__': 3190 | try: 3191 | unit_test() 3192 | except: # pylint: disable=bare-except 3193 | terminate() 3194 | traceback.print_exc() 3195 | input('hit return') 3196 | 3197 | 3198 | # EOB 3199 | -------------------------------------------------------------------------------- /images/hexview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/walterdejong/hexview/fe555d50a812a04c6cd42ea387b7dd3b5d42dbd9/images/hexview.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | # 3 | # setup.py WJ116 4 | # for hexview 5 | # 6 | # Copyright 2016 by Walter de Jong 7 | # 8 | # Permission is hereby granted, free of charge, to any person obtaining a copy 9 | # of this software and associated documentation files (the "Software"), to deal 10 | # in the Software without restriction, including without limitation the rights 11 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | # copies of the Software, and to permit persons to whom the Software is 13 | # furnished to do so, subject to the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be included in 16 | # all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 24 | # THE SOFTWARE. 25 | 26 | '''hex file viewer setup''' 27 | 28 | from distutils.core import setup 29 | 30 | from hexviewlib.hexview import VERSION 31 | 32 | 33 | setup(name='hexview', 34 | version=VERSION, 35 | description='interactive hex viewer', 36 | author='Walter de Jong', 37 | author_email='walter@heiho.net', 38 | url='https://github.com/walterdejong/hexview', 39 | license='MIT', 40 | classifiers=['Development Status :: 4 - Beta', 41 | 'Environment :: Console :: Curses', 42 | 'Intended Audience :: Developers', 43 | 'Intended Audience :: System Administrators', 44 | 'License :: OSI Approved :: MIT License', 45 | 'Natural Language :: English', 46 | 'Operating System :: POSIX', 47 | 'Operating System :: MacOS :: MacOS X', 48 | 'Programming Language :: Python :: 2.6', 49 | 'Topic :: Software Development', 50 | 'Topic :: System :: Recovery Tools', 51 | 'Topic :: Utilities'], 52 | packages=['hexviewlib',], 53 | package_dir={'hexviewlib': 'hexviewlib'}, 54 | scripts=['hexview',], 55 | data_files=[('share/doc/hexview', ['LICENSE', 'README.md']), 56 | ('share/doc/hexview/master/images', ['images/hexview.png',])] 57 | ) 58 | 59 | # EOB 60 | --------------------------------------------------------------------------------