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