1#! /usr/bin/env python3 2# -*- coding: utf-8 -*- 3 4# romiq.kh@gmail.com, 2015 5 6import math 7import traceback 8from html.parser import HTMLParser 9 10import tkinter 11from tkinter import ttk, font, filedialog, messagebox 12from idlelib.redirector import WidgetRedirector 13 14# Image processing 15try: 16 from PIL import Image 17except ImportError: 18 Image = None 19 20try: 21 from PIL import ImageTk 22except ImportError: 23 ImageTk = None 24 25def hlesc(value): 26 if value is None: 27 return "None" 28 return value.replace("\\", "\\\\").replace("<", "\\<").replace(">", "\\>") 29 30def cesc(value): 31 return value.replace("\\", "\\\\").replace("\"", "\\\"") 32 33def fmt_hl(loc, desc): 34 return "<a href=\"{}\">{}</a>".format(loc, desc) 35 36def fmt_hl_len(loc, desc, ln): 37 sz = max(ln - len(desc), 0) 38 return " "*sz + fmt_hl(loc, desc) 39 40def fmt_arg(value): 41 if value < 10: 42 return "{}".format(value) 43 elif value == 0xffff: 44 return "-1" 45 else: 46 return "0x{:X}".format(value) 47 48def fmt_dec(value, add = 0): 49 return "{{:{}}}".format(fmt_dec_len(value, add)) 50 51def fmt_dec_len(value, add = 0): 52 if value == 0: 53 d = 1 54 else: 55 d = int(math.log10(value)) + 1 56 d += add 57 return d 58 59# thanx to http://effbot.org/zone/tkinter-text-hyperlink.htm 60class HyperlinkManager(HTMLParser): 61 62 def __init__(self, text): 63 self.text = text 64 self.text.tag_config("hyper", foreground = "blue", underline = 1) 65 self.text.tag_bind("hyper", "<Enter>", self._enter) 66 self.text.tag_bind("hyper", "<Leave>", self._leave) 67 self.text.tag_bind("hyper", "<Button-1>", self._click) 68 bold_font = font.Font(text, self.text.cget("font")) 69 bold_font.configure(weight = "bold") 70 self.text.tag_config("bold", font = bold_font) 71 italic_font = font.Font(text, self.text.cget("font")) 72 italic_font.configure(slant = "italic") 73 self.text.tag_config("italic", font = italic_font) 74 self.text.tag_config("underline", underline = 1) 75 self.parser = HTMLParser() 76 self.parser.handle_starttag = self.handle_starttag 77 self.parser.handle_endtag = self.handle_endtag 78 self.parser.handle_data = self.handle_data 79 self.reset() 80 81 def reset(self): 82 self.links = {} 83 self.colors = [] 84 self.bgs = [] 85 self.colorbgs = [] 86 87 def add(self, action): 88 # add an action to the manager. returns tags to use in 89 # associated text widget 90 tag = "hyper-{}".format(len(self.links)) 91 self.links[tag] = action 92 return "hyper", tag 93 94 def color(self, color): 95 tag = "color-{}".format(color) 96 if tag not in self.colors: 97 self.colors.append(tag) 98 self.text.tag_config(tag, foreground = color) 99 self.text.tag_raise("hyper") 100 return (tag,) 101 102 def bg(self, color): 103 tag = "bg-{}".format(color) 104 if tag not in self.bgs: 105 self.bgs.append(tag) 106 self.text.tag_config(tag, background = color) 107 self.text.tag_raise("hyper") 108 return (tag,) 109 110 def colorbg(self, color, bg): 111 tag = "colorbg-{}|{}".format(color, bg) 112 if tag not in self.colorbgs: 113 self.colorbgs.append(tag) 114 self.text.tag_config(tag, foreground = color, background = bg) 115 self.text.tag_raise("hyper") 116 return (tag,) 117 118 def _enter(self, event): 119 self.text.config(cursor = "hand2") 120 121 def _leave(self, event): 122 self.text.config(cursor = "") 123 124 def _click(self, event): 125 for tag in self.text.tag_names(tkinter.CURRENT): 126 if tag[:6] == "hyper-": 127 self.links[tag]() 128 return 129 130 def handle_starttag(self, tag, attrs): 131 tagmap = {"b": "bold", "i": "italic", "u": "underline"} 132 if tag in tagmap: 133 self.parser_tags.append([tagmap[tag]]) 134 elif tag == "a": 135 ref = "" 136 for k, v in attrs: 137 if k == "href": 138 ref = v 139 self.parser_tags.append(self.add(self.parser_handler(ref))) 140 elif tag == "font": 141 color = "" 142 bg = "" 143 for k, v in attrs: 144 if k == "color": 145 color = v 146 elif k == "bg": 147 bg = v 148 if color and bg: 149 self.parser_tags.append(self.colorbg(color, bg)) 150 elif bg: 151 self.parser_tags.append(self.bg(bg)) 152 else: 153 self.parser_tags.append(self.color(color)) 154 155 def handle_endtag(self, tag): 156 self.parser_tags = self.parser_tags[:-1] 157 158 def handle_data(self, data): 159 self.parser_widget.insert(tkinter.INSERT, data, \ 160 tuple(reversed([x for x in self.parser_tags for x in x]))) 161 162 def add_markup(self, text, widget, handler): 163 self.parser_tags = [] 164 self.parser_widget = widget 165 self.parser_handler = handler 166 self.parser.reset() 167 self.parser.feed(text) 168 return 169 170 171# thanx http://tkinter.unpythonic.net/wiki/ReadOnlyText 172class ReadOnlyText(tkinter.Text): 173 174 def __init__(self, *args, **kwargs): 175 tkinter.Text.__init__(self, *args, **kwargs) 176 self.redirector = WidgetRedirector(self) 177 self.insert = \ 178 self.redirector.register("insert", lambda *args, **kw: "break") 179 self.delete = \ 180 self.redirector.register("delete", lambda *args, **kw: "break") 181 182 183class TkBrowser(tkinter.Frame): 184 185 def __init__(self, master): 186 tkinter.Frame.__init__(self, master) 187 self.pack(fill = tkinter.BOTH, expand = 1) 188 self.pad = None 189 190 # gui 191 self.path_handler = {} 192 self.curr_main = -1 # 0 - frame, 1 - canvas 193 self.curr_path = [] 194 self.curr_help = "" 195 self.last_path = [None] 196 self.curr_gui = [] 197 self.curr_state = {} # local state for location group 198 self.curr_markup = "" # current unparsed markup data (unclosed tags, etc) 199 self.curr_lb_acts = None 200 self.curr_lb_idx = None 201 self.hist = [] 202 self.histf = [] 203 self.gl_state = {} # global state until program exit 204 self.start_act = [] 205 self.init_gui() # init custom gui data 206 207 # canvas 208 self.need_update = False 209 self.canv_view_fact = 1 210 self.main_image = tkinter.PhotoImage(width = 1, height = 1) 211 # add on_load handler 212 self.after_idle(self.on_first_display) 213 214 def init_gui(self): 215 pass 216 217 def update_after(self): 218 if not self.need_update: 219 self.after_idle(self.on_idle) 220 self.need_update = True 221 222 def on_idle(self): 223 self.need_update = False 224 self.update_canvas() 225 226 def on_first_display(self): 227 fnt = font.Font() 228 try: 229 self.pad = fnt.measure(":") 230 except: 231 self.pad = 5 232 self.create_widgets() 233 self.create_menu() 234 235 def on_help(self): 236 pass 237 238 def on_back(self): 239 if len(self.hist) > 1: 240 np = self.hist[-2:-1][0] 241 self.histf = self.hist[-1:] + self.histf 242 self.hist = self.hist[:-1] 243 self.open_path(np[0], False) 244 245 def on_forward(self): 246 if len(self.histf) > 0: 247 np = self.histf[0] 248 self.histf = self.histf[1:] 249 self.hist.append(np) 250 self.open_path(np[0], False) 251 252 def create_widgets(self): 253 ttk.Style().configure("Tool.TButton", width = -1) # minimal width 254 ttk.Style().configure("TLabel", padding = self.pad) 255 ttk.Style().configure('Info.TFrame', background = 'white', \ 256 foreground = "black") 257 258 # toolbar 259 self.toolbar = ttk.Frame(self) 260 self.toolbar.pack(fill = tkinter.BOTH) 261 btns = [ 262 ["Outline", lambda: self.open_path("")], 263 ["Help", self.on_help], 264 [None, None], 265 ["<-", self.on_back], 266 ["->", self.on_forward], 267 ] 268 for text, cmd in btns: 269 if text is None: 270 frm = ttk.Frame(self.toolbar, width = self.pad, 271 height = self.pad) 272 frm.pack(side = tkinter.LEFT) 273 continue 274 btn = ttk.Button(self.toolbar, text = text, \ 275 style = "Tool.TButton", command = cmd) 276 btn.pack(side = tkinter.LEFT) 277 frm = ttk.Frame(self.toolbar, width = self.pad, height = self.pad) 278 frm.pack(side = tkinter.LEFT) 279 280 # main panel 281 self.pan_main = ttk.PanedWindow(self, orient = tkinter.HORIZONTAL) 282 self.pan_main.pack(fill = tkinter.BOTH, expand = 1) 283 284 # leftpanel 285 self.frm_left = ttk.Frame(self.pan_main) 286 self.pan_main.add(self.frm_left) 287 # main view 288 self.frm_view = ttk.Frame(self.pan_main) 289 self.pan_main.add(self.frm_view) 290 self.frm_view.grid_rowconfigure(0, weight = 1) 291 self.frm_view.grid_columnconfigure(0, weight = 1) 292 self.scr_view_x = ttk.Scrollbar(self.frm_view, 293 orient = tkinter.HORIZONTAL) 294 self.scr_view_x.grid(row = 1, column = 0, \ 295 sticky = tkinter.E + tkinter.W) 296 self.scr_view_y = ttk.Scrollbar(self.frm_view) 297 self.scr_view_y.grid(row = 0, column = 1, sticky = \ 298 tkinter.N + tkinter.S) 299 # canvas 300 self.canv_view = tkinter.Canvas(self.frm_view, height = 150, 301 bd = 0, highlightthickness = 0, 302 scrollregion = (0, 0, 50, 50), 303 ) 304 # don't forget 305 # canvas.config(scrollregion=(left, top, right, bottom)) 306 self.canv_view.bind('<Configure>', self.on_resize_view) 307 self.canv_view.bind('<ButtonPress-1>', self.on_mouse_view) 308 309 # text 310 self.text_view = ReadOnlyText(self.frm_view, 311 highlightthickness = 0, 312 ) 313 self.text_hl = HyperlinkManager(self.text_view) 314 self.text_view.bind('<Configure>', self.on_resize_view) 315 316 def create_menu(self): 317 self.menubar = tkinter.Menu(self.master) 318 self.master.configure(menu = self.menubar) 319 320 def on_exit(self): 321 self.master.destroy() 322 323 def on_mouse_view(self, event): 324 self.update_after() 325 326 def on_resize_view(self, event): 327 self.update_after() 328 329 def parse_path(self, loc): 330 if isinstance(loc, str): 331 path = [] 332 if loc[:1] == "/": 333 loc = loc[1:] 334 if loc != "": 335 for item in loc.split("/"): 336 try: 337 path.append(int(item, 10)) 338 except: 339 path.append(item) 340 else: 341 path = loc 342 path = tuple(path) 343 while path[-1:] == ("",): 344 path = path[:-1] 345 return path 346 347 def desc_path(self, loc): 348 path = self.parse_path(loc) 349 if len(path) > 0: 350 if path[0] in self.path_handler: 351 desc = self.path_handler[path[0]][1] 352 if callable(desc): 353 return desc(path) 354 elif desc: 355 return desc 356 return self.desc_default(path) 357 358 def update_canvas(self): 359 if self.curr_main == 0: 360 return 361 # draw grahics 362 c = self.canv_view 363 c.delete(tkinter.ALL) 364 365 w = self.canv_view.winfo_width() 366 h = self.canv_view.winfo_height() 367 if (w == 0) or (h == 0): 368 return 369 370 scale = 0 371 372 # Preview image 373 if not isinstance(self.main_image, tkinter.PhotoImage): 374 mw, mh = self.main_image.size 375 if scale == 0: # Fit 376 try: 377 psc = w / h 378 isc = mw / mh 379 if psc < isc: 380 fact = w / mw 381 else: 382 fact = h / mh 383 except: 384 fact = 1.0 385 else: 386 fact = scale 387 pw = int(mw * fact) 388 ph = int(mh * fact) 389 img = self.main_image.resize((pw, ph), Image.ANTIALIAS) 390 self.canv_image = ImageTk.PhotoImage(img) 391 else: 392 mw = self.main_image.width() 393 mh = self.main_image.height() 394 if scale == 0: # Fit 395 try: 396 psc = w / h 397 isc = mw / mh 398 if psc < isc: 399 if w > mw: 400 fact = w // mw 401 else: 402 fact = -mw // w 403 else: 404 if h > mh: 405 fact = h // mh 406 else: 407 fact = -mh // h 408 except: 409 fact = 1 410 else: 411 fact = scale 412 self.canv_image = self.main_image.copy() 413 if fact > 0: 414 self.canv_image = self.canv_image.zoom(fact) 415 else: 416 self.canv_image = self.canv_image.subsample(-fact) 417 self.canv_image_fact = fact 418 419 # place on canvas 420 if fact > 0: 421 pw = mw * fact 422 ph = mh * fact 423 else: 424 pw = mw // -fact 425 ph = mh // -fact 426 427 cw = max(pw, w) 428 ch = max(ph, h) 429 c.config(scrollregion = (0, 0, cw - 2, ch - 2)) 430 #print("Place c %d %d, p %d %d" % (cw, ch, w, h)) 431 c.create_image(cw // 2, ch // 2, image = self.canv_image) 432 433 def make_image(self, imgobj): 434 if imgobj.image is not None: 435 return imgobj.image 436 width = imgobj.width 437 height = imgobj.height 438 data = imgobj.rgb 439 # create P6 440 phdr = ("P6\n{} {}\n255\n".format(width, height)) 441 rawlen = width * height * 3 # RGB 442 #phdr = ("P5\n{} {}\n255\n".format(width, height)) 443 #rawlen = width * height 444 phdr = phdr.encode("UTF-8") 445 446 if len(data) > rawlen: 447 # truncate 448 pdata = data[:rawlen] 449 if len(data) < rawlen: 450 # fill gap 451 gap = bytearray() 452 data += b"\xff" * (rawlen - len(data)) 453 p = bytearray(phdr) 454 # fix UTF-8 issue 455 for ch in data: 456 if ch > 0x7f: 457 p += bytes((0b11000000 |\ 458 ch >> 6, 0b10000000 |\ 459 (ch & 0b00111111))) 460 else: 461 p += bytes((ch,)) 462 image = tkinter.PhotoImage(width = width, height = height, \ 463 data = bytes(p)) 464 return image 465 466 def update_gui(self, text = "<Undefined>"): 467 self.last_path = self.curr_path 468 # cleanup 469 for item in self.curr_gui: 470 item() 471 self.curr_gui = [] 472 self.curr_state = {} # save state across moves 473 # left listbox 474 lab = tkinter.Label(self.frm_left, text = text) 475 lab.pack() 476 frm_lb = ttk.Frame(self.frm_left) 477 frm_lb.pack(fill = tkinter.BOTH, expand = 1) 478 frm_lb.grid_rowconfigure(0, weight = 1) 479 frm_lb.grid_columnconfigure(0, weight = 1) 480 scr_lb_x = ttk.Scrollbar(frm_lb, orient = tkinter.HORIZONTAL) 481 scr_lb_x.grid(row = 1, column = 0, sticky = tkinter.E + tkinter.W) 482 scr_lb_y = ttk.Scrollbar(frm_lb) 483 scr_lb_y.grid(row = 0, column = 1, sticky = tkinter.N + tkinter.S) 484 frmlbpad = ttk.Frame(frm_lb, borderwidth = self.pad) 485 lb = tkinter.Listbox(frm_lb, 486 highlightthickness = 0, 487 xscrollcommand = scr_lb_x.set, 488 yscrollcommand = scr_lb_y.set) 489 lb.grid(row = 0, column = 0, \ 490 sticky = tkinter.N + tkinter.S + tkinter.E + tkinter.W) 491 scr_lb_x.config(command = lb.xview) 492 scr_lb_y.config(command = lb.yview) 493 self.curr_gui.append(lambda:lb.grid_remove()) 494 self.curr_gui.append(lambda:lab.pack_forget()) 495 self.curr_gui.append(lambda:frm_lb.pack_forget()) 496 lb.bind("<Double-Button-1>", self.on_left_listbox) 497 lb.bind("<Return>", self.on_left_listbox) 498 # actions on listbox 499 self.curr_lb = lb 500 self.curr_lb_acts = [] 501 self.curr_lb_idx = {} 502 503 def switch_view(self, main): 504 # main view 505 if main == self.curr_main: return 506 last = self.curr_main 507 self.curr_main = main 508 rw = None 509 rh = None 510 if main == 0: 511 self.canv_view.delete(tkinter.ALL) 512 self.canv_view.grid_forget() 513 self.text_view.grid(row = 0, column = 0, \ 514 sticky = tkinter.N + tkinter.S + tkinter.E + tkinter.W) 515 self.text_view.configure( 516 xscrollcommand = self.scr_view_x.set, 517 yscrollcommand = self.scr_view_y.set 518 ) 519 self.scr_view_x.config(command = self.text_view.xview) 520 self.scr_view_y.config(command = self.text_view.yview) 521 else: 522 if last == 0: 523 rw = self.text_view.winfo_width() 524 rh = self.text_view.winfo_height() 525 self.canv_view.delete(tkinter.ALL) 526 self.text_view.grid_forget() 527 self.canv_view.grid(row = 0, column = 0, \ 528 sticky = tkinter.N + tkinter.S + tkinter.E + tkinter.W) 529 self.canv_view.configure( 530 xscrollcommand = self.scr_view_x.set, 531 yscrollcommand = self.scr_view_y.set 532 ) 533 self.scr_view_x.config(command = self.canv_view.xview) 534 self.scr_view_y.config(command = self.canv_view.yview) 535 if rh: 536 print(rh) 537 self.canv_view.height = rh 538 print(self.canv_view.winfo_height()) 539 540 def clear_info(self): 541 self.text_view.delete(0.0, tkinter.END) 542 543 def add_text(self, text): 544 self.end_markup() 545 self.text_view.insert(tkinter.INSERT, text) 546 547 def add_info(self, text): 548 self.curr_markup += text 549 550 def end_markup(self): 551 if not self.curr_markup: return 552 def make_cb(path): 553 def cb(): 554 if path[:5] == "http:" or path[:6] == "https:": 555 return self.open_http(path) 556 return self.open_path(path) 557 return cb 558 self.text_hl.add_markup(self.curr_markup, self.text_view, make_cb) 559 self.curr_markup = "" 560 561 def insert_lb_act(self, name, act, key = None): 562 if key is not None: 563 self.curr_lb_idx[key] = len(self.curr_lb_acts) 564 self.curr_lb_acts.append((name, act)) 565 if name == "-" and act is None: 566 self.curr_lb.insert(tkinter.END, "") 567 else: 568 self.curr_lb.insert(tkinter.END, " " + name) 569 570 def select_lb_item(self, key): 571 idx = self.curr_lb_idx.get(key, None) 572 need = (idx is not None) 573 idxs = "{}".format(idx) 574 for sel in self.curr_lb.curselection(): 575 if sel == idxs: 576 need = False 577 else: 578 self.curr_lb.selection_clear(sel) 579 if need: 580 self.curr_lb.selection_set(idxs) 581 if idx is not None: 582 self.curr_lb.see(idxs) 583 584 def on_left_listbox(self, event): 585 def currsel(): 586 try: 587 num = self.curr_lb.curselection()[0] 588 num = int(num) 589 except: 590 return None 591 return num 592 593 if self.curr_lb_acts: 594 act = self.curr_lb_acts[currsel()] 595 if act[1] is not None: 596 self.open_path(act[1]) 597 598 def add_toolbtn(self, text, cmd): 599 if text is None: 600 frm = ttk.Frame(self.toolbar, width = self.pad, height = self.pad) 601 frm.pack(side = tkinter.LEFT) 602 self.curr_gui.append(lambda:frm.pack_forget()) 603 return 604 btn = ttk.Button(self.toolbar, text = text, \ 605 style = "Tool.TButton", command = cmd) 606 btn.pack(side = tkinter.LEFT) 607 self.curr_gui.append(lambda:btn.pack_forget()) 608 return btn 609 610 def add_toollabel(self, text): 611 lab = ttk.Label(self.toolbar, text = text) 612 lab.pack(side = tkinter.LEFT) 613 self.curr_gui.append(lambda:lab.pack_forget()) 614 return lab 615 616 def add_toolgrp(self, label, glkey, items, cbupd): 617 def makecb(v, g): 618 def btncb(): 619 self.gl_state[g] = v 620 cbupd() 621 return btncb 622 if label: 623 self.add_toollabel(label) 624 kl = list(items.keys()) 625 kl.sort() 626 res = [] 627 for k in kl: 628 b = self.add_toolbtn(items[k], makecb(k, glkey)) 629 res.append([b, k]) 630 return res 631 632 def upd_toolgrp(self, btns, state): 633 for btn, idx in btns: 634 if idx != state and state != -1: 635 btn.config(state = tkinter.NORMAL) 636 else: 637 btn.config(state = tkinter.DISABLED) 638 639 def clear_hist(self): 640 self.hist = self.hist[-1:] 641 self.histf = [] 642 643 def open_http(self, path): 644 messagebox.showinfo(parent = self, title = "URL", message = path) 645 646 def open_path(self, loc, withhist = True): 647 path = self.parse_path(loc) 648 if withhist: 649 self.hist.append([path]) 650 self.histf = [] 651 print("DEBUG: Open", path) 652 self.curr_path = path 653 if len(path) > 0: 654 self.curr_help = path[0] 655 else: 656 self.curr_help = "" 657 try: 658 if len(path) > 0 and path[0] in self.path_handler: 659 res = self.path_handler[path[0]][0](path) 660 else: 661 res = self.path_default(path) 662 except Exception: 663 self.switch_view(0) 664 self.add_text("\n" + "="*20 + "\n" + traceback.format_exc()) 665 res = True 666 self.end_markup() 667 return res 668 669 def path_default(self, path): 670 self.switch_view(0) 671 self.clear_info() 672 self.add_info("Open path\n\n" + str(path)) 673 return True 674