Passed
Pull Request — develop (#301)
by
unknown
01:43
created

Application.remove()   A

Complexity

Conditions 4

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
c 1
b 0
f 0
dl 0
loc 19
rs 9.2
ccs 0
cts 0
cp 0
crap 20
1
#!/usr/bin/env python
2
3 1
"""Graphical interface for Doorstop."""
4
5 1
import os
6 1
import sys
7
from unittest.mock import Mock
8
try:  # pragma: no cover (manual test)
9
    import tkinter as tk
10
    from tkinter import ttk
11
    from tkinter import filedialog
12
    from tkinter import messagebox as tkMessageBox
13
except ImportError as _exc:  # pragma: no cover (manual test)
14
    sys.stderr.write("WARNING: {}\n".format(_exc))
15 1
    tk = Mock()
16 1
    ttk = Mock()
17 1
18 1
import argparse
19 1
import functools
20
from itertools import chain
21 1
import logging
22 1
import tempfile
23 1
import webbrowser
24 1
25 1
from typing import Any
26
from typing import Optional
27 1
from typing import Sequence
28
from typing import Union
29
30 1
from doorstop.gui import widget
31
from doorstop.gui import utilTkinter
32 1
33
from doorstop import common
34
from doorstop.common import HelpFormatter, WarningFormatter, DoorstopError
35 1
from doorstop.core import exporter, publisher
36 1
from doorstop import settings
37 1
from doorstop.core.types import UID
38
from doorstop.core.types import Level
39 1
from doorstop.core.document import Document
40 1
from doorstop.core.tree import Tree
41
42
from doorstop.gui.action import Action_ChangeProjectPath
43 1
from doorstop.gui.action import Action_SaveProject
44
from doorstop.gui.action import Action_CloseProject
45
from doorstop.gui.action import Action_ChangeCWD
46
from doorstop.gui.action import Action_ChangeSelectedDocument
47 1
from doorstop.gui.action import Action_ChangeSelectedItem
48
from doorstop.gui.action import Action_ChangeItemText
49
from doorstop.gui.action import Action_ChangeItemReference
50 1
from doorstop.gui.action import Action_ChangeItemActive
51
from doorstop.gui.action import Action_ChangeItemDerived
52
from doorstop.gui.action import Action_ChangeItemNormative
53 1
from doorstop.gui.action import Action_ChangeItemHeading
54 1
from doorstop.gui.action import Action_ChangeLinkInception
55 1
from doorstop.gui.action import Action_ChangeItemAddLink
56 1
from doorstop.gui.action import Action_ChangeSelectedLink
57 1
from doorstop.gui.action import Action_ChangeItemRemoveLink
58 1
from doorstop.gui.action import Action_ChangeExtendedName
59 1
from doorstop.gui.action import Action_ChangeExtendedValue
60
from doorstop.gui.action import Action_AddNewItemNextToSelection
61 1
from doorstop.gui.action import Action_RemoveSelectedItem
62 1
from doorstop.gui.action import Action_SelectedItem_Level_Indent
63
from doorstop.gui.action import Action_SelectedItem_Level_Dedent
64
from doorstop.gui.action import Action_SelectedItem_Level_Increment
65 1
from doorstop.gui.action import Action_SelectedItem_Level_Decrement
66
from doorstop.gui.action import Action_Import
67
68 1
from doorstop.gui.reducer import Reducer_GUI
69 1
from doorstop.gui.store import Store
70 1
from doorstop.gui.state import State
71 1
72 1
log = common.logger(__name__)
73 1
74 1
75 1
def main(args: Sequence[str] = tuple()) -> int:
76
    """Process command-line arguments and run the program."""
77 1
    from doorstop import GUI, VERSION
78 1
79 1
    # Shared options
80
    debug = argparse.ArgumentParser(add_help=False)
81
    debug.add_argument('-V', '--version', action='version', version=VERSION)
82 1
    debug.add_argument('-v', '--verbose', action='count', default=0,
83 1
                       help="enable verbose logging")
84 1
    shared = {'formatter_class': HelpFormatter, 'parents': [debug]}
85
    parser = argparse.ArgumentParser(prog=GUI, description=__doc__, **shared)
86
87 1
    # Build main parser
88
    parser.add_argument('-j', '--project', metavar="PATH",
89
                        help="path to the root of the project")
90
91
    # Parse arguments
92
    args = parser.parse_args(args=args)
93
94
    # Configure logging
95 1
    _configure_logging(args.verbose)
96
97 1
    # Run the program
98 1
    try:
99
        success = run(args, os.getcwd(), parser.error)
100
    except KeyboardInterrupt:
101
        log.debug("program interrupted")
102
        success = False
103
    if success:
104
        log.debug("program exited")
105
        return 0
106
    else:
107
        log.debug("program exited with error")
108
        return 1
109
110
111
def _configure_logging(verbosity: int = 0) -> None:
112
    """Configure logging using the provided verbosity level (0+)."""
113
    # Configure the logging level and format
114
    if verbosity == 0:
115
        level = settings.VERBOSE_LOGGING_LEVEL
116
        default_format = settings.VERBOSE_LOGGING_FORMAT
117
        verbose_format = settings.VERBOSE_LOGGING_FORMAT
118
    elif verbosity == 1:
119
        level = settings.VERBOSE2_LOGGING_LEVEL
120
        default_format = settings.VERBOSE_LOGGING_FORMAT
121
        verbose_format = settings.VERBOSE_LOGGING_FORMAT
122
    else:
123
        level = settings.VERBOSE2_LOGGING_LEVEL
124
        default_format = settings.TIMED_LOGGING_FORMAT
125
        verbose_format = settings.TIMED_LOGGING_FORMAT
126
127
    # Set a custom formatter
128
    logging.basicConfig(level=level)
129
    formatter = WarningFormatter(default_format, verbose_format)
130
    logging.root.handlers[0].setFormatter(formatter)
131
132
133
def run(args: argparse.Namespace, cwd: str, error):
134
    """Start the GUI.
135
136
    :param args: Namespace of CLI arguments (from this module or the CLI)
137
    :param cwd: current working directory
138
    :param error: function to call for CLI errors
139
140
    """
141
    from doorstop import __project__, __version__
142
    # Exit if tkinter is not available
143
    if isinstance(tk, Mock) or isinstance(ttk, Mock):
144
        return error("tkinter is not available")
145
146
    else:  # pragma: no cover (manual test)
147
148
        store = Store(Reducer_GUI(), State())
149
150
        # Load values provided by parameters
151
        store.dispatch(Action_ChangeCWD(cwd))
152
        store.dispatch(Action_ChangeProjectPath(args.project))
153
154
        root = widget.Tk()
155
156
        if True:  # Load the icon
157
            from sys import platform as _platform
158
            if _platform in ("linux", "linux2"):
159
                # linux
160
                from doorstop.gui import resources
161
                root.tk.call('wm', 'iconphoto', root._w, tk.PhotoImage(data=resources.b64_doorstopicon_png))  # pylint: disable=W0212
162
            elif _platform == "darwin":
163
                # MAC OS X
164
                pass  # TODO
165
            elif _platform in ("win32", "win64"):
166
                # Windows
167
                from doorstop.gui import resources
168
                import base64
169
                try:
170
                    with tempfile.TemporaryFile(mode='w+b', suffix=".ico", delete=False) as theTempIconFile:
171
                        theTempIconFile.write(base64.b64decode(resources.b64_doorstopicon_ico))
172
                        theTempIconFile.flush()
173
                    root.iconbitmap(theTempIconFile.name)
174
                finally:
175
                    try:
176
                        os.unlink(theTempIconFile.name)
177
                    except Exception:  # pylint: disable=W0703
178
                        pass
179
180
        if True:  # Set the application title
181
            def refreshTitle(store: Optional[Store]) -> None:
182
                project_path = ""
183
                pending_change = False
184
                if store:
185
                    state = store.state
186
                    if state is not None:
187
                        project_path = state.project_path
188
                        pending_change = state.session_pending_change
189
                root.title("{} ({}){}{}".format(__project__, __version__, "*" if pending_change else "", (" - " + os.path.realpath(project_path)) if project_path else ""))
190
            store.add_observer(lambda store: refreshTitle(store))
191
192
        app = Application(root, store)
193
194
        root.update()
195
        root.minsize(root.winfo_width(), root.winfo_height())
196
        app.mainloop()
197
198
        return True
199
200
201
def _log(func):  # pragma: no cover (manual test)
202
    """Log name and arguments."""
203
    @functools.wraps(func)
204
    def wrapped(*args, **kwargs):
205
        sargs = "{}, {}".format(', '.join(repr(a) for a in args),
206
                                ', '.join("{}={}".format(k, repr(v))
207
                                          for k, v in kwargs.items()))
208
        msg = "log: {}: {}".format(func.__name__, sargs.strip(", "))
209
        log.debug(msg.strip())
210
        return func(*args, **kwargs)
211
    return wrapped
212
213
214
class Application(ttk.Frame):  # pragma: no cover (manual test), pylint: disable=R0901,R0902
215
    """Graphical application for Doorstop."""
216
217
    @staticmethod
218
    def init_main_frame(root: tk.Frame, store: Store) -> ttk.Frame:
219
        """Initialize and return the main frame."""
220
        # Shared arguments
221
        width_text = 30
222
        width_uid = 10
223
        height_text = 10
224
        height_ext = 5
225
226
        # Shared keyword arguments
227
        kw_f = {'padding': 5}  # constructor arguments for frames
228
        kw_gp = {'padx': 2, 'pady': 2}  # grid arguments for padded widgets
229
        kw_gs = {'sticky': tk.NSEW}  # grid arguments for sticky widgets
230
        kw_gsp = dict(chain(kw_gs.items(), kw_gp.items()))  # grid arguments for sticky padded widgets
231
232
        # Configure grid
233
        result_frame = ttk.Frame(root, **kw_f)
234
        result_frame.rowconfigure(0, weight=0)
235
        result_frame.columnconfigure(0, weight=1)
236
        result_frame.columnconfigure(1, weight=1)
237
        result_frame.columnconfigure(2, weight=1)
238
239
        def frame_document(root) -> ttk.Frame:
240
            """Frame for current document's outline."""
241
            # Configure grid
242
            frame = ttk.Frame(root, **kw_f)
243
            frame.rowconfigure(0, weight=0)  # Label
244
            frame.rowconfigure(1, weight=0)  # Combobox
245
            frame.rowconfigure(2, weight=1)  # TreeView
246
            frame.rowconfigure(3, weight=0)  # Button
247
            frame.columnconfigure(0, weight=0)  # Slider
248
            frame.columnconfigure(1, weight=1)
249
            frame.columnconfigure(2, weight=1)
250
            frame.columnconfigure(3, weight=1)
251
            frame.columnconfigure(4, weight=1)
252
            frame.columnconfigure(5, weight=1)
253
            frame.columnconfigure(6, weight=1)
254
255
            @_log
256
            def treeview_outline_treeviewselect(event):
257
                """Handle selecting an item in the tree view."""
258
                store.dispatch(Action_ChangeSelectedItem(event.widget.selection()))
259
260
            # Place widgets
261
            widget.Label(frame, text="Document:").grid(row=0, column=0, columnspan=7, sticky=tk.W, **kw_gp)
262
263
            combobox_documents = widget.Combobox(frame, state="readonly")
264
            combobox_documents.grid(row=1, column=0, columnspan=7, **kw_gsp)
265
266
            # Display the information
267
            def refreshDocumentComboboxContent(store: Optional[Store]) -> None:
268
                state = None
269
                project_tree = None
270
                if store:
271
                    state = store.state
272
                    if state is not None:
273
                        project_tree = state.project_tree
274
                documents = [document for document in project_tree] if project_tree else []
275
                combobox_documents['values'] = ["{} ({})".format(document.prefix, document.relpath) for document in documents]
276
277
                def handle_document_combobox_selectionchange(*event: Any) -> None:
278
                    if store:
279
                        store.dispatch(Action_ChangeSelectedDocument(documents[combobox_documents.current()].prefix))
280
                combobox_documents.bind("<<ComboboxSelected>>", handle_document_combobox_selectionchange)
281
282
                # Set the document Selection
283
                selected_document_prefix = state.session_selected_document if state else None
284
                for index, item in enumerate(documents):
285
                    if selected_document_prefix == item.prefix:
286
                        combobox_documents.current(index)
287
                        break
288
                else:
289
                    combobox_documents.set("")
290
291
            store.add_observer(lambda store: refreshDocumentComboboxContent(store))
292
293
            c_columnId = ("Id", "Text Preview")
294
            treeview_outline = widget.TreeView(frame, columns=c_columnId)  # pylint: disable=W0201
295
            treeview_outline.heading("#0", text="Level")
296
            for col in c_columnId:
297
                treeview_outline.heading(col, text=col)
298
            treeview_outline.column("#0", minwidth=80, stretch=tk.NO)
299
            treeview_outline.column("Id", minwidth=120, stretch=tk.NO)
300
            treeview_outline.column("Text Preview", minwidth=50, stretch=tk.YES)
301
302
            def refresh_document_outline(store: Optional[Store]) -> None:  # Refresh the document outline
303
                state = store.state if store else None
304
305
                # Record the currently opened items.
306
                c_openItem = []
307
                for c_currUID in utilTkinter.getAllChildren(treeview_outline):
308
                    if treeview_outline.item(c_currUID)["open"]:
309
                        c_openItem.append(c_currUID)
310
311
                # Clear the widgets
312
                treeview_outline.delete(*treeview_outline.get_children())
313
314
                # Display the items in the document
315
                c_levelsItem = [""]
316
                project_tree = None if state is None else state.project_tree
317
                the_document = None
318
                if project_tree is not None:
319
                    assert state is not None
320
                    try:
321
                        the_document = project_tree.find_document(state.session_selected_document)
322
                    except DoorstopError:
323
                        pass  # The document is not found.
324
                for item in [] if the_document is None else the_document.items:
325
                    theParent = next(iter(reversed([x for x in c_levelsItem[:item.depth]])), "")
326
327
                    while len(c_levelsItem) < item.depth:
328
                        c_levelsItem.append(item.uid)
329
                    c_levelsItem = c_levelsItem[:item.depth]
330
                    for _ in range(item.depth):
331
                        c_levelsItem.append(item.uid)
332
333
                    # Add the item to the document outline
334
                    def makeSuperscript(aLevel: Level) -> str:
335
                        return str(aLevel).strip().translate({48: 0x2070, 49: 0x00B9, 50: 0x00B2, 51: 0x00B3, 52: 0x2074, 53: 0x2075, 54: 0x2076, 55: 0x2077, 56: 0x2078, 57: 0x2079})
336
                    try:
337
                        the_outline_text = item.text.splitlines()[0]
338
                    except IndexError:
339
                        the_outline_text = ""
340
                    treeview_outline.insert(theParent, tk.END, item.uid, text=makeSuperscript(item.level) if not item.active else item.level, values=(item.uid, the_outline_text), open=item.uid in c_openItem)
341
342
                # Set tree view selection
343
                c_selectedItem = state.session_selected_item if state else []
344
                if c_selectedItem:
345
                    assert state is not None
346
                    # Restore selection
347
                    session_selected_item_principal = state.session_selected_item_principal
348
                    treeview_outline.selection_set(c_selectedItem)
349
                    treeview_outline.focus(session_selected_item_principal)
350
                    treeview_outline.see(session_selected_item_principal)
351
352
            store.add_observer(lambda store: refresh_document_outline(store))
353
354
            # Add a Vertical scrollbar to the Treeview Outline
355
            treeview_outline_verticalScrollBar = widget.ScrollbarV(frame, command=treeview_outline.yview)
356
            treeview_outline_verticalScrollBar.grid(row=2, column=0, columnspan=1, **kw_gs)
357
            treeview_outline.configure(yscrollcommand=treeview_outline_verticalScrollBar.set)
358
            treeview_outline.bind("<<TreeviewSelect>>", treeview_outline_treeviewselect)
359
            treeview_outline.bind("<Delete>", lambda event: store.dispatch(Action_RemoveSelectedItem()))
360
            treeview_outline.grid(row=2, column=1, columnspan=6, **kw_gsp)
361
362
            if True:  # Level edit buttons
363
                btn_Level_Dedent = widget.Button(frame, text="<", width=0, command=lambda: store.dispatch(Action_SelectedItem_Level_Dedent()))
364
                btn_Level_Dedent.grid(row=3, column=1, sticky=tk.EW, padx=(2, 0))
365
                btn_Level_Increment = widget.Button(frame, text="v", width=0, command=lambda: store.dispatch(Action_SelectedItem_Level_Increment()))
366
                btn_Level_Increment.grid(row=3, column=2, sticky=tk.EW)
367
                btn_Level_Decrement = widget.Button(frame, text="^", width=0, command=lambda: store.dispatch(Action_SelectedItem_Level_Decrement()))
368
                btn_Level_Decrement.grid(row=3, column=3, sticky=tk.EW)
369
                btn_Level_Indent = widget.Button(frame, text=">", width=0, command=lambda: store.dispatch(Action_SelectedItem_Level_Indent()))
370
                btn_Level_Indent.grid(row=3, column=4, sticky=tk.EW, padx=(0, 2))
371
372
                def refresh_btn_Level(store: Optional[Store]) -> None:  # Refresh the buttons level
373
                    state = store.state if store is not None else None
374
                    btn_Level_Dedent.config(state=tk.DISABLED if ((state is None) or (state.session_selected_item_principal is None)) else tk.NORMAL)
375
                    btn_Level_Increment.config(state=tk.DISABLED if ((state is None) or (state.session_selected_item_principal is None)) else tk.NORMAL)
376
                    btn_Level_Decrement.config(state=tk.DISABLED if ((state is None) or (state.session_selected_item_principal is None)) else tk.NORMAL)
377
                    btn_Level_Indent.config(state=tk.DISABLED if ((state is None) or (state.session_selected_item_principal is None)) else tk.NORMAL)
378
379
                store.add_observer(lambda store: refresh_btn_Level(store))
380
381
            if True:  # Button add item
382
                def add_new_item() -> None:
383
                    """Add a new item to the document."""
384
                    store.dispatch(Action_AddNewItemNextToSelection())
385
                btn_add_item = widget.Button(frame, text="Add Item", command=add_new_item)
386
                btn_add_item.grid(row=3, column=5, sticky=tk.W, **kw_gp)
387
388
                def refresh_btn_add_item(store: Optional[Store]) -> None:
389
                    state = store.state if store else None
390
                    btn_add_item.config(state=tk.DISABLED if ((state is None) or (state.session_selected_document is None)) else tk.NORMAL)
391
392
                store.add_observer(lambda store: refresh_btn_add_item(store))
393
394
            if True:  # Button remove item
395
                def remove_selected_item() -> None:
396
                    """Remove selected item to the document."""
397
                    store.dispatch(Action_RemoveSelectedItem())
398
                btn_remove_item = widget.Button(frame, text="Remove Selected Item", command=remove_selected_item)
399
                btn_remove_item.grid(row=3, column=6, sticky=tk.E, **kw_gp)
400
401
                def refresh_btn_remove_item(store: Optional[Store]) -> None:
402
                    state = store.state if store is not None else None
403
                    btn_remove_item.config(state=tk.DISABLED if ((state is None) or (state.session_selected_item_principal is None)) else tk.NORMAL)
404
405
                store.add_observer(lambda store: refresh_btn_remove_item(store))
406
407
            return frame
408
409
        def frame_item(root) -> ttk.Frame:
410
            """Frame for the currently selected item."""
411
            # Configure grid
412
            frame = ttk.Frame(root, **kw_f)
413
            frame.rowconfigure(0, weight=0)
414
            frame.rowconfigure(1, weight=4)
415
            frame.rowconfigure(2, weight=0)
416
            frame.rowconfigure(3, weight=1)
417
            frame.rowconfigure(4, weight=1)
418
            frame.rowconfigure(5, weight=1)
419
            frame.rowconfigure(6, weight=1)
420
            frame.rowconfigure(7, weight=0)
421
            frame.rowconfigure(8, weight=0)
422
            frame.rowconfigure(9, weight=0)
423
            frame.rowconfigure(10, weight=0)
424
            frame.rowconfigure(11, weight=4)
425
            frame.columnconfigure(0, weight=0, pad=kw_f['padding'] * 2)
426
            frame.columnconfigure(1, weight=1)
427
            frame.columnconfigure(2, weight=1)
428
429
            @_log
430
            def text_item_focusout(event: Any) -> None:
431
                """Handle updated item text."""
432
                state = store.state
433
                if state is not None:
434
                    thewidget = event.widget
435
                    value = thewidget.get('1.0', tk.END)
436
                    item_uid = state.session_selected_item_principal
437
                    if item_uid:
438
                        store.dispatch(Action_ChangeItemText(item_uid, value))
439
440
            @_log
441
            def text_item_reference_focusout(event: Any) -> None:
442
                """Handle updated item reference text."""
443
                if store is not None:
444
                    state = store.state
445
                    if state is not None:
446
                        thewidget = event.widget
447
                        value = thewidget.get()
448
                        item_uid = state.session_selected_item_principal
449
                        if item_uid:
450
                            store.dispatch(Action_ChangeItemReference(item_uid, value))
451
452
            @_log
453
            def text_extendedvalue_focusout(event) -> None:
454
                """Handle updated extended attributes."""
455
                if store is not None:
456
                    state = store.state
457
                    if state is not None:
458
                        thewidget = event.widget
459
                        value = thewidget.get('1.0', tk.END)
460
                        item_uid = state.session_selected_item_principal
461
                        if item_uid:
462
                            store.dispatch(Action_ChangeExtendedValue(item_uid, state.session_extended_name, value))
463
464
            if True:  # Selected Item label
465
                lbl_selected_item = widget.Label(frame, text="No item selected")
466
                lbl_selected_item.grid(row=0, column=0, columnspan=3, sticky=tk.W, **kw_gp)
467
468
                def refreshSelectedItemLabel(store: Optional[Store]) -> None:
469
                    state = store.state if store is not None else None
470
                    session_selected_item_principal = state.session_selected_item_principal if state is not None else None
471
                    if session_selected_item_principal is None:
472
                        lbl_selected_item.config(text="No item selected")
473
                    else:
474
                        lbl_selected_item.config(text="Selected Item: " + str(session_selected_item_principal))
475
                store.add_observer(lambda store: refreshSelectedItemLabel(store))
476
477
            if True:  # Item text
478
                text_item = widget.Text(frame, width=width_text, height=height_text, wrap=tk.WORD)
479
                text_item.bind('<FocusOut>', text_item_focusout)
480
                text_item.grid(row=1, column=0, columnspan=3, **kw_gsp)
481
482
                def refreshItemText(store: Optional[Store]) -> None:
483
                    state = store.state if store else None
484
                    session_selected_item_principal = state.session_selected_item_principal if state else None
485
                    project_tree = state.project_tree if state else None
486
                    try:
487
                        item = None if session_selected_item_principal is None else project_tree.find_item(session_selected_item_principal) if project_tree is not None else None
488
                    except DoorstopError:
489
                        item = None
490
                    text_item.replace('1.0', tk.END, "" if item is None else item.text)
491
                    text_item.config(state=tk.DISABLED if session_selected_item_principal is None else tk.NORMAL)
492
                store.add_observer(lambda store: refreshItemText(store))
493
494
            widget.Label(frame, text="Properties:").grid(row=2, column=0, sticky=tk.W, **kw_gp)
495
            widget.Label(frame, text="Links:").grid(row=2, column=1, columnspan=2, sticky=tk.W, **kw_gp)
496
497
            if True:  # CheckBox active
498
                checkbox_active_var = tk.BooleanVar()
499
500
                def doChangeActive() -> None:
501
                    state = store.state if store else None
502
                    session_selected_item_principal = state.session_selected_item_principal if state else None
503
                    project_tree = state.project_tree if state else None
504
                    try:
505
                        item = project_tree.find_item(session_selected_item_principal) if project_tree else None
506
                    except DoorstopError:
507
                        item = None
508
                    if item:
509
                        store.dispatch(Action_ChangeItemActive(item, checkbox_active_var.get()))
510
                checkbox_active = widget.Checkbutton(frame, command=doChangeActive, variable=checkbox_active_var, text="Active")
511
                checkbox_active.grid(row=3, column=0, sticky=tk.W, **kw_gp)
512
513
                def refreshCheckButtonActive(store: Optional[Store]) -> None:
514
                    state = store.state if store else None
515
                    session_selected_item_principal = state.session_selected_item_principal if state else None
516
                    project_tree = state.project_tree if state else None
517
                    try:
518
                        item = project_tree.find_item(session_selected_item_principal) if project_tree else None
519
                    except DoorstopError:
520
                        item = None
521
                    checkbox_active_var.set(bool(item is not None and item.active))
522
                    checkbox_active.config(state=tk.DISABLED if item is None else tk.NORMAL)
523
                store.add_observer(lambda store: refreshCheckButtonActive(store))
524
525
            if True:  # CheckBox derived
526
                checkbox_derived_var = tk.BooleanVar()
527
528
                def doChangeDerived() -> None:
529
                    state = store.state if store else None
530
                    session_selected_item_principal = state.session_selected_item_principal if state else None
531
                    project_tree = state.project_tree if state else None
532
                    try:
533
                        item = project_tree.find_item(session_selected_item_principal) if project_tree else None
534
                    except DoorstopError:
535
                        item = None
536
                    if item:
537
                        store.dispatch(Action_ChangeItemDerived(item, checkbox_derived_var.get()))
538
539
                checkbox_derived = widget.Checkbutton(frame, command=doChangeDerived, variable=checkbox_derived_var, text="Derived")
540
                checkbox_derived.grid(row=4, column=0, sticky=tk.W, **kw_gp)
541
542
                def refreshCheckButtonDerived(store: Optional[Store]) -> None:
543
                    state = store.state if store else None
544
                    session_selected_item_principal = state.session_selected_item_principal if state else None
545
                    project_tree = state.project_tree if state else None
546
                    try:
547
                        item = project_tree.find_item(session_selected_item_principal) if project_tree else None
548
                    except DoorstopError:
549
                        item = None
550
                    checkbox_derived_var.set(bool(item is not None and item.derived))
551
                    checkbox_derived.config(state=tk.DISABLED if item is None else tk.NORMAL)
552
                store.add_observer(lambda store: refreshCheckButtonDerived(store))
553
554
            if True:  # CheckBox normative
555
                checkbox_normative_var = tk.BooleanVar()
556
557
                def doChangeNormative() -> None:
558
                    state = store.state if store else None
559
                    session_selected_item_principal = state.session_selected_item_principal if state else None
560
                    project_tree = state.project_tree if state else None
561
                    try:
562
                        item = project_tree.find_item(session_selected_item_principal) if project_tree else None
563
                    except DoorstopError:
564
                        item = None
565
                    if item:
566
                        store.dispatch(Action_ChangeItemNormative(item, checkbox_normative_var.get()))
567
                checkbox_normative = widget.Checkbutton(frame, command=doChangeNormative, variable=checkbox_normative_var, text="Normative")
568
                checkbox_normative.grid(row=5, column=0, sticky=tk.W, **kw_gp)
569
570
                def refreshCheckButtonNormative(store: Optional[Store]) -> None:
571
                    state = store.state if store else None
572
                    session_selected_item_principal = state.session_selected_item_principal if state else None
573
                    project_tree = state.project_tree if state else None
574
                    try:
575
                        item = project_tree.find_item(session_selected_item_principal) if project_tree else None
576
                    except DoorstopError:
577
                        item = None
578
                    checkbox_normative_var.set(bool(item is not None and item.normative))
579
                    checkbox_normative.config(state=tk.DISABLED if item is None else tk.NORMAL)
580
                store.add_observer(lambda store: refreshCheckButtonNormative(store))
581
582
            if True:  # CheckBox heading
583
                checkbox_heading_var = tk.BooleanVar()
584
585
                def doChangeHeading() -> None:
586
                    state = store.state if store else None
587
                    session_selected_item_principal = state.session_selected_item_principal if state else None
588
                    project_tree = state.project_tree if state else None
589
                    try:
590
                        item = project_tree.find_item(session_selected_item_principal) if project_tree else None
591
                    except DoorstopError:
592
                        item = None
593
                    if item:
594
                        store.dispatch(Action_ChangeItemHeading(item, checkbox_heading_var.get()))
595
596
                checkbox_heading = widget.Checkbutton(frame, command=doChangeHeading, variable=checkbox_heading_var, text="Heading")
597
                checkbox_heading.grid(row=6, column=0, sticky=tk.W, **kw_gp)
598
599
                def refreshCheckButtonHeading(store: Optional[Store]) -> None:
600
                    state = store.state if store else None
601
                    session_selected_item_principal = state.session_selected_item_principal if state else None
602
                    project_tree = state.project_tree if state else None
603
                    try:
604
                        item = project_tree.find_item(session_selected_item_principal) if project_tree else None
605
                    except DoorstopError:
606
                        item = None
607
                    checkbox_heading_var.set(bool(item is not None and item.heading))
608
                    checkbox_heading.config(state=tk.DISABLED if item is None else tk.NORMAL)
609
                store.add_observer(lambda store: refreshCheckButtonHeading(store))
610
611
            if True:  # Listbox Links
612
                listbox_links = widget.Listbox(frame, width=width_uid, height=6, selectmode=tk.EXTENDED, exportselection=tk.OFF)
613
                listbox_links.grid(row=3, column=1, rowspan=4, **kw_gsp)
614
615
                def refreshListBoxLinks(store: Optional[Store]) -> None:
616
                    previous_active_index = listbox_links.index(tk.ACTIVE)
617
                    previous_active_item = listbox_links.get(previous_active_index)
618
619
                    state = None if store is None else store.state
620
                    session_selected_item_principal = None if state is None else state.session_selected_item_principal
621
                    session_selected_link = [] if state is None else state.session_selected_link
622
                    project_tree = None if state is None else state.project_tree
623
                    try:
624
                        item = None if project_tree is None else project_tree.find_item(session_selected_item_principal)
625
                    except DoorstopError:
626
                        item = None
627
                    listbox_links.delete(0, tk.END)
628
                    if item is not None:
629
                        assert state is not None
630
                        v_new_index = -1
631
                        next_active = None
632
                        for uid in sorted([x for x in item.links if (("" == state.session_link_inception) or (state.session_link_inception in str(x)))], key=lambda x: str(x), reverse=False):
633
                            v_new_index += 1
634
                            listbox_links.insert(tk.END, uid)
635
                            if str(uid) in session_selected_link:
636
                                listbox_links.selection_set(listbox_links.index(tk.END) - 1)
637
                            if str(uid) == str(previous_active_item):
638
                                next_active = v_new_index
639
640
                        if next_active is None:
641
                            next_active = min(previous_active_index, listbox_links.size() - 1)
642
                        assert next_active is not None
643
                        if 0 <= next_active:
644
                            listbox_links.activate(next_active)
645
                            listbox_links.see(next_active)
646
647
                store.add_observer(lambda store: refreshListBoxLinks(store))
648
649
                def handle_document_listbox_selectionchange(evt) -> None:
650
                    w = evt.widget
651
                    the_selection = w.curselection()
652
653
                    a = frozenset([w.get(int(i)) for i in the_selection])
654
                    b = frozenset([w.get(int(i)) for i in range(0, w.size()) if i not in [int(j) for j in the_selection]])
655
                    store.dispatch(Action_ChangeSelectedLink(selected_link=a, unselected_link=b))
656
657
                listbox_links.bind('<<ListboxSelect>>', handle_document_listbox_selectionchange)
658
659
            if True:  # Entry link inception
660
661
                entry_link_inception = widget.Entry(frame, width=width_uid)
662
663
                def doChangeInceptionLink():
664
                    store.dispatch(Action_ChangeLinkInception(entry_link_inception.get()))
665
                entry_link_inception.bind("<KeyRelease>", lambda event: doChangeInceptionLink())
666
                entry_link_inception.grid(row=3, column=2, sticky=tk.EW + tk.N, **kw_gp)
667
668
                def refreshEntryLinkInception(store: Optional[Store]) -> None:
669
                    state = None if store is None else store.state
670
                    the_link_inception = "" if state is None else state.session_link_inception
671
                    if the_link_inception != entry_link_inception.get():
672
                        entry_link_inception.delete('0', tk.END)
673
                        entry_link_inception.insert('0', the_link_inception)
674
                store.add_observer(lambda store: refreshEntryLinkInception(store))
675
676
            if True:  # Link item button
677
                @_log
678
                def do_link() -> None:
679
                    """Add the specified link to the current item."""
680
                    state = store.state
681
                    if state is not None:
682
                        the_item_uid = state.session_selected_item_principal
683
                        if the_item_uid is not None:
684
                            the_new_link = state.session_link_inception
685
                            if the_new_link is not None:
686
                                store.dispatch(Action_ChangeItemAddLink(the_item_uid, UID(the_new_link)))
687
688
                btn_link_item = widget.Button(frame, text="<< Link Item", command=do_link)
689
                btn_link_item.grid(row=4, column=2, **kw_gp)
690
691
                def refreshLinkButton(store: Optional[Store]) -> None:
692
                    state = store.state if store is not None else None
693
                    if state is not None:
694
                        session_link_inception = state.session_link_inception
695
                        if "" != session_link_inception:
696
697
                            session_selected_item_principal = state.session_selected_item_principal if state else None
698
                            project_tree = state.project_tree if state else None
699
                            try:
700
                                item = project_tree.find_item(session_selected_item_principal) if project_tree is not None else None
701
                            except DoorstopError:
702
                                item = None
703
                            if item is not None:
704
                                if session_link_inception in [str(x) for x in item.links]:
705
                                    btn_link_item.config(state=tk.DISABLED)
706
                                else:
707
                                    btn_link_item.config(state=tk.NORMAL)
708
                            else:
709
                                btn_link_item.config(state=tk.DISABLED)
710
                        else:
711
                            btn_link_item.config(state=tk.DISABLED)
712
                store.add_observer(lambda store: refreshLinkButton(store))
713
714
            if True:  # Unlink item button
715
716
                @_log
717
                def unlink() -> None:
718
                    """Remove the currently selected link from the current item."""
719
                    state = store.state
720
                    if state is not None:
721
                        uid = state.session_selected_item_principal
722
                        if uid is not None:
723
                            store.dispatch(Action_ChangeItemRemoveLink(uid, state.session_selected_link))
724
725
                btn_unlink_item = widget.Button(frame, text=">> Unlink Item", command=unlink)
726
                btn_unlink_item.grid(row=6, column=2, **kw_gp)
727
728
                def refreshUnlinkButton(store: Optional[Store]) -> None:
729
                    state = store.state if store is not None else None
730
                    if state is not None:
731
                        session_selected_link = state.session_selected_link
732
                        btn_unlink_item.config(state=tk.NORMAL if session_selected_link else tk.DISABLED)
733
                store.add_observer(lambda store: refreshUnlinkButton(store))
734
735
            widget.Label(frame, text="External Reference:").grid(row=7, column=0, columnspan=3, sticky=tk.W, **kw_gp)
736
737
            if True:  # Item External Reference
738
                text_item_reference = widget.Entry(frame, width=width_text)
739
                text_item_reference.bind('<FocusOut>', text_item_reference_focusout)
740
                text_item_reference.grid(row=8, column=0, columnspan=3, **kw_gsp)
741
742
                def refreshItemReference(store: Optional[Store]) -> None:
743
                    state = store.state if store else None
744
                    session_selected_item_principal = state.session_selected_item_principal if state else None
745
                    project_tree = state.project_tree if state else None
746
                    try:
747
                        item = None if session_selected_item_principal is None else project_tree.find_item(session_selected_item_principal) if project_tree is not None else None
748
                    except DoorstopError:
749
                        item = None
750
                    text_item_reference.delete(0, tk.END)
751
                    text_item_reference.insert(0, "" if item is None else item.ref)
752
                    text_item_reference.config(state=tk.DISABLED if session_selected_item_principal is None else tk.NORMAL)
753
                store.add_observer(lambda store: refreshItemReference(store))
754
755
            widget.Label(frame, text="Extended Attributes:").grid(row=9, column=0, columnspan=3, sticky=tk.W, **kw_gp)
756
757
            if True:  # Combobox Extended attribute Name
758
                combobox_extended = widget.Combobox(frame)
759
                combobox_extended.grid(row=10, column=0, columnspan=3, **kw_gsp)
760
761
                def refreshComboboxExtendedName(store: Optional[Store]) -> None:
762
                    state = store.state if store else None
763
                    session_selected_item_principal = state.session_selected_item_principal if state else None
764
                    project_tree = state.project_tree if state is not None else None
765
                    try:
766
                        item = None if session_selected_item_principal is None else project_tree.find_item(session_selected_item_principal) if project_tree else None
767
                    except DoorstopError:
768
                        item = None
769
770
                    values = None if item is None else item.extended
771
                    combobox_extended['values'] = values or []
772
                    combobox_extended.delete(0, tk.END)
773
                    if state is not None and state.session_extended_name:
774
                        combobox_extended.insert(0, state.session_extended_name)
775
                    combobox_extended.config(state=tk.DISABLED if session_selected_item_principal is None else tk.NORMAL)
776
777
                store.add_observer(lambda store: refreshComboboxExtendedName(store))
778
779
                def handle_extended_name_combobox_selectionchange(*event: Any) -> None:
780
                    if store:
781
                        store.dispatch(Action_ChangeExtendedName(combobox_extended.get()))
782
                combobox_extended.bind("<<ComboboxSelected>>", handle_extended_name_combobox_selectionchange)
783
                combobox_extended.bind("<KeyRelease>", handle_extended_name_combobox_selectionchange)
784
785
            if True:  # Textbox Extended attribute Value
786
                text_extendedvalue = widget.Text(frame, width=width_text, height=height_ext, wrap=tk.WORD)
787
                text_extendedvalue.grid(row=11, column=0, columnspan=3, **kw_gsp)
788
789
                def refreshTextboxExtendedValue(store: Optional[Store]) -> None:
790
                    state = store.state if store is not None else None
791
                    session_selected_item_principal = state.session_selected_item_principal if state is not None else None
792
                    project_tree = state.project_tree if state is not None else None
793
                    try:
794
                        item = None if session_selected_item_principal is None else project_tree.find_item(session_selected_item_principal) if project_tree else None
795
                    except DoorstopError:
796
                        item = None
797
798
                    value = "" if ((item is None) or (state is None)) else item.get(state.session_extended_name)
799
                    text_extendedvalue.delete(1.0, tk.END)
800
                    if state is not None and state.session_extended_name:
801
                        if value:
802
                            text_extendedvalue.insert(tk.END, value)
803
                    text_extendedvalue.config(state=tk.DISABLED if session_selected_item_principal is None else tk.NORMAL)
804
805
                store.add_observer(lambda store: refreshTextboxExtendedValue(store))
806
807
                text_extendedvalue.bind('<FocusOut>', text_extendedvalue_focusout)
808
809
            return frame
810
811
        def frame_family(root) -> ttk.Frame:
812
            """Frame for the parent and child document items."""
813
            # Configure grid
814
            frame = ttk.Frame(root, **kw_f)
815
            frame.rowconfigure(0, weight=0)
816
            frame.rowconfigure(1, weight=1)
817
            frame.rowconfigure(2, weight=0)
818
            frame.rowconfigure(3, weight=1)
819
            frame.columnconfigure(0, weight=1)
820
821
            @_log
822
            def followlink(uid: UID) -> None:
823
                """Display a given uid."""
824
                # Load the good document.
825
                store.dispatch(Action_ChangeSelectedDocument(uid.prefix))
826
827
                # load the good Item
828
                store.dispatch(Action_ChangeSelectedItem((uid,)))
829
830
            # Place widgets Text Parents
831
            widget.Label(frame, text="Linked To:").grid(row=0, column=0, sticky=tk.W, **kw_gp)
832
            text_parents = widget.noUserInput_init(widget.Text(frame, width=width_text, wrap=tk.WORD))
833
            text_parents_hyperlink = utilTkinter.HyperlinkManager(text_parents)  # pylint: disable=W0201
834
            text_parents.tag_configure("refLink", foreground="blue")
835
            text_parents.grid(row=1, column=0, **kw_gsp)
836
837
            def refresh_text_parents(store: Optional[Store]) -> None:
838
                # Display the items this item links to
839
                state = None if store is None else store.state
840
                if state is not None:
841
                    widget.noUserInput_delete(text_parents, '1.0', tk.END)
842
                    text_parents_hyperlink.reset()
843
                    if state.session_selected_item_principal is not None:
844
                        project_tree = state.project_tree
845
                        if project_tree is not None:
846
                            for uid in project_tree.find_item(state.session_selected_item_principal).links:
847
                                try:
848
                                    item = project_tree.find_item(uid)
849
                                except DoorstopError:
850
                                    text = "???"
851
                                else:
852
                                    text = item.text or item.ref or '???'
853
                                    uid = item.uid
854
855
                                widget.noUserInput_insert(text_parents, tk.END, "{t}".format(t=text))
856
                                widget.noUserInput_insert(text_parents, tk.END, " [")
857
                                widget.noUserInput_insert(text_parents, tk.END, uid, text_parents_hyperlink.add(lambda c_theURL: followlink(c_theURL), uid, ["refLink"]))  # pylint: disable=W0108
858
                                widget.noUserInput_insert(text_parents, tk.END, "]\n\n")
859
            store.add_observer(lambda store: refresh_text_parents(store))
860
861
            widget.Label(frame, text="Linked From:").grid(row=2, column=0, sticky=tk.W, **kw_gp)
862
            text_children = widget.noUserInput_init(widget.Text(frame, width=width_text, wrap=tk.WORD))
863
            text_children_hyperlink = utilTkinter.HyperlinkManager(text_children)  # pylint: disable=W0201
864
            text_children.tag_configure("refLink", foreground="blue")
865
            text_children.grid(row=3, column=0, **kw_gsp)
866
867
            def refresh_text_children(store: Optional[Store]) -> None:
868
                # Display the items this item links to
869
                state = None if store is None else store.state
870
                if state is not None:
871
                    # Display the items this item has links from
872
                    widget.noUserInput_delete(text_children, '1.0', 'end')
873
                    text_children_hyperlink.reset()
874
                    if state.session_selected_item_principal is not None:
875
                        project_tree = state.project_tree
876
                        if project_tree is not None:
877
                            parent_item = project_tree.find_item(state.session_selected_item_principal)
878
                            if parent_item is not None:
879
                                for uid in parent_item.find_child_links():
880
                                    item = project_tree.find_item(uid)
881
                                    text = item.text or item.ref or '???'
882
                                    uid = item.uid
883
884
                                    widget.noUserInput_insert(text_children, tk.END, "{t}".format(t=text))
885
                                    widget.noUserInput_insert(text_children, tk.END, " [")
886
                                    widget.noUserInput_insert(text_children, tk.END, uid, text_children_hyperlink.add(lambda c_theURL: followlink(c_theURL), uid, ["refLink"]))  # pylint: disable=W0108
887
                                    widget.noUserInput_insert(text_children, tk.END, "]\n\n")
888
            store.add_observer(lambda store: refresh_text_children(store))
889
890
            return frame
891
892
        # Place widgets
893
        frame_document(result_frame).grid(row=0, column=0, columnspan=1, **kw_gs)
894
        frame_item(result_frame).grid(row=0, column=1, columnspan=1, **kw_gs)
895
        frame_family(result_frame).grid(row=0, column=2, columnspan=1, **kw_gs)
896
897
        return result_frame
898
899
    def __init__(self, parent: tk.Frame, store: Store) -> None:
900
        ttk.Frame.__init__(self, parent)
901
902
        def do_close_project() -> bool:
903
            current_state = store.state
904
            if current_state and current_state.session_pending_change:
905
                result = tkMessageBox.askyesnocancel("Pending changes", "There are unsaved changes, do you want to save them?", icon='warning', default=tkMessageBox.YES, parent=self)
906
                if result is None:  # Cancel
907
                    return False
908
                elif result:  # Yes
909
                    store.dispatch(Action_SaveProject())
910
                else:  # NO
911
                    pass
912
            store.dispatch(Action_CloseProject())
913
            return True
914
915
        def do_open_project() -> bool:
916
            requested_path = filedialog.askdirectory()
917
            if requested_path:
918
                if do_close_project():
919
                    store.dispatch(Action_ChangeProjectPath(requested_path))
920
                    return True
921
            return False
922
923
        def do_load_project() -> bool:
924
            if store is None: return True
925
            state = store.state
926
            if state is None: return True
927
            if do_close_project():
928
                store.dispatch(Action_ChangeProjectPath(state.project_path))
929
                return True
930
            return False
931
932
        def do_quit() -> bool:
933
            if do_close_project():
934
                parent.quit()
935
                return True
936
            return False
937
938
        def do_save_all_project() -> None:
939
            store.dispatch(Action_SaveProject())
940
941
        def do_import() -> None:
942
            state = store.state
943
            if state is None: return
944
945
            initial = None if state is None else state.project_path
946
            if initial is None:
947
                initial = ""
948
949
            source = filedialog.askopenfilename(initialdir=initial, title="Select file", filetypes=(
950
                ("Comma-Separated Values", "*.csv"),
951
                ("Tab-Separated Values", "*.tsv"),
952
                ("YAML", "*.yml"),
953
                ("Microsoft Office Excel", ".xlsx")
954
            ))
955
            if not source: return
956
            store.dispatch(Action_Import(state.session_selected_document, source))
957
958
        def do_export(element: Optional[Union[Document, Tree]]) -> None:
959
            if element is None: return
960
961
            state = store.state
962
            initial = None if state is None else state.project_path
963
            if initial is None:
964
                initial = ""
965
966
            destination = filedialog.asksaveasfilename(initialdir=initial, title="Export to", filetypes=(
967
                ("Comma-Separated Values", "*.csv"),
968
                ("Tab-Separated Values", "*.tsv"),
969
                ("YAML", "*.yml"),
970
                ("Microsoft Office Excel", ".xlsx")
971
            ))
972
            if not destination: return
973
974
            ext = destination[destination.rfind("."):]
975
            try:
976
                exporter.check(ext)
977
            except DoorstopError:
978
                return
979
            if isinstance(element, Tree):
980
                destination = destination[:destination.rfind(".")]
981
            path = exporter.export(element, destination, ext, auto=True)
982
            del path
983
984
        def do_export_tree() -> None:
985
            state = store.state
986
            if state is None: return
987
            project_tree = state.project_tree
988
            do_export(project_tree)
989
990
        def do_export_document() -> None:
991
            state = store.state
992
            if state is None: return
993
            project_tree = state.project_tree
994
            if project_tree is None: return
995
            the_document = None
996
            try:
997
                the_document = project_tree.find_document(state.session_selected_document)
998
            except DoorstopError:
999
                pass  # The document is not found.
1000
1001
            if the_document is None: return
1002
            do_export(the_document)
1003
1004
        def do_publish_tree_preview() -> None:
1005
            state = store.state
1006
            if state is None: return
1007
            project_tree = state.project_tree
1008
            if project_tree is None: return
1009
1010
            def __show_generate_preview() -> None:
1011
                with tempfile.TemporaryDirectory() as tmpdirname:
1012
                    path = publisher.publish(project_tree, tmpdirname, ".html", template="sidebar")
1013
                    if path:
1014
                        webbrowser.open_new(os.path.join(path, "index.html"))
1015
                        import time
1016
                        time.sleep(5 * 60)  # 5 minutes preview
1017
1018
            import threading
1019
            threading.Thread(target=__show_generate_preview, name="PublishPreview", args=(), kwargs={}, daemon=True).start()
1020
1021
        def do_publish_tree() -> None:
1022
            state = store.state
1023
            if state is None: return
1024
            project_tree = state.project_tree
1025
            if project_tree is None: return
1026
1027
            initial = None if state is None else state.project_path
1028
            if initial is None:
1029
                initial = ""
1030
1031
            destination = filedialog.askdirectory()
1032
            if not destination: return
1033
1034
            ext = ".html"
1035
            try:
1036
                publisher.check(ext)
1037
            except DoorstopError:
1038
                return
1039
            path = publisher.publish(project_tree, destination, ext, template="sidebar")
1040
            del path
1041
1042
        def do_publish_document() -> None:
1043
            state = store.state
1044
            if state is None: return
1045
            project_tree = state.project_tree
1046
            if project_tree is None: return
1047
            the_document = None
1048
            try:
1049
                the_document = project_tree.find_document(state.session_selected_document)
1050
            except DoorstopError:
1051
                pass  # The document is not found.
1052
            if the_document is None: return
1053
1054
            initial = None if state is None else state.project_path
1055
            if initial is None:
1056
                initial = ""
1057
1058
            destination = filedialog.asksaveasfilename(initialdir=initial, title="Publish to", filetypes=(
1059
                ("Text", "*.txt"),
1060
                ("Markdown", "*.md"),
1061
                ("HTML", "*.html"),
1062
            ))
1063
            if not destination: return
1064
1065
            ext = destination[destination.rfind("."):]
1066
            if ext in [None, ""]: return
1067
            try:
1068
                publisher.check(ext)
1069
            except DoorstopError:
1070
                return
1071
            path = publisher.publish(the_document, destination, ext, template="sidebar")
1072
            del path
1073
1074
        def do_show_help() -> None:
1075
            webbrowser.open_new("https://doorstop.readthedocs.io/en/latest/gui/coming-soon/")
1076
1077
        if True:  # Set the windows behavior.
1078
            parent.protocol("WM_DELETE_WINDOW", lambda *args, **kw: do_quit())
1079
            parent.bind_all("<Control-o>", lambda *args, **kw: do_open_project())
1080
            parent.bind_all("<Control-s>", lambda *args, **kw: do_save_all_project())
1081
            parent.bind_all("<Key-F1>", lambda *args, **kw: do_show_help())
1082
            parent.bind_all("<Key-F5>", lambda *args, **kw: do_load_project())
1083
            parent.bind_all("<Control-minus>", lambda *args, **kw: widget.adjustFontSize(-1))
1084
            parent.bind_all("<Control-equal>", lambda *args, **kw: widget.adjustFontSize(1))
1085
            parent.bind_all("<Control-0>", lambda *args, **kw: widget.resetFontSize())
1086
1087
        if True:  # Set the menu
1088
1089
            if True:  # File menu
1090
                menubar = widget.Menu(parent)
1091
                filemenu = widget.Menu(menubar, tearoff=0)
1092
                filemenu.add_command(label="Open Project…", command=do_open_project, accelerator="Ctrl+o")
1093
                filemenu.add_command(label="Reload Project", command=do_load_project, accelerator="F5")
1094
                filemenu.add_command(label="Save All", command=do_save_all_project, accelerator="Ctrl+s")
1095
                filemenu.add_command(label="Close Project", command=do_close_project)
1096
                filemenu.add_separator()
1097
                filemenu.add_command(label="Export Tree…", command=do_export_tree)
1098
                filemenu.add_command(label="Export Document…", command=do_export_document)
1099
                filemenu.add_command(label="Import Into Document…", command=do_import)
1100
                filemenu.add_separator()
1101
                filemenu.add_command(label="Publish Tree…", command=do_publish_tree)
1102
                filemenu.add_command(label="Publish Tree (Preview 5 minutes)", command=do_publish_tree_preview)
1103
                filemenu.add_command(label="Publish Document…", command=do_publish_document)
1104
                filemenu.add_separator()
1105
                filemenu.add_command(label="Exit", command=do_quit, accelerator="Alt+F4")
1106
                menubar.add_cascade(label="File", menu=filemenu)
1107
1108
            if True:  # View menu
1109
                viewmenu = widget.Menu(menubar, tearoff=0)
1110
                viewmenu.add_command(label="Reduce font size", command=lambda: widget.adjustFontSize(-1), accelerator="Ctrl+-")
1111
                viewmenu.add_command(label="Increase font size", command=lambda: widget.adjustFontSize(1), accelerator="Ctrl++")
1112
                viewmenu.add_command(label="Reset font size", command=lambda: widget.resetFontSize(), accelerator="Ctrl+0")
1113
                menubar.add_cascade(label="View", menu=viewmenu)
1114
1115
            if True:  # Help menu
1116
                helpmenu = widget.Menu(menubar, tearoff=0)
1117
                helpmenu.add_command(label="Doorstop GUI Help", command=lambda: do_show_help(), accelerator="F1")
1118
                helpmenu.add_command(label="Report Issue ☹", command=lambda: webbrowser.open_new("https://github.com/jacebrowning/doorstop/issues"))
1119
                helpmenu.add_command(label="Contribute ☺", command=lambda: webbrowser.open_new("https://github.com/jacebrowning/doorstop"))
1120
                menubar.add_cascade(label="Help", menu=helpmenu)
1121
1122
            parent.config(menu=menubar)
1123
1124
            def refreshMenu(store: Optional[Store]) -> None:
1125
                project_path = None
1126
                state = None
1127
                if store:
1128
                    state = store.state
1129
                    if state is not None:
1130
                        project_path = state.project_path
1131
                        project_tree = state.project_tree
1132
                        project_document = state.session_selected_document
1133
                filemenu.entryconfig("Reload Project", state=tk.NORMAL if project_path else tk.DISABLED)
1134
                filemenu.entryconfig("Save All", state=tk.NORMAL if project_tree else tk.DISABLED)
1135
                filemenu.entryconfig("Close Project", state=tk.NORMAL if project_path else tk.DISABLED)
1136
                filemenu.entryconfig("Export Tree…", state=tk.NORMAL if project_tree else tk.DISABLED)
1137
                filemenu.entryconfig("Export Document…", state=tk.NORMAL if project_document else tk.DISABLED)
1138
                filemenu.entryconfig("Import Into Document…", state=tk.NORMAL if project_document else tk.DISABLED)
1139
                filemenu.entryconfig("Publish Tree…", state=tk.NORMAL if project_tree else tk.DISABLED)
1140
                filemenu.entryconfig("Publish Tree (Preview 5 minutes)", state=tk.NORMAL if project_tree else tk.DISABLED)
1141
                filemenu.entryconfig("Publish Document…", state=tk.NORMAL if project_document else tk.DISABLED)
1142
            store.add_observer(lambda store: refreshMenu(store))
1143
1144
        # Initialize the GUI
1145
        frame = Application.init_main_frame(parent, store)
1146
        frame.pack(fill=tk.BOTH, expand=1)
1147
1148
1149
if "__main__" == __name__:  # pragma: no cover (manual test)
1150
    sys.exit(main(sys.argv[1:]))
1151