mdbub 0.3.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mdbub/__init__.py +63 -0
- mdbub/cli.py +122 -0
- mdbub/commands/__init__.py +0 -0
- mdbub/commands/about.py +99 -0
- mdbub/commands/export.py +9 -0
- mdbub/commands/print_kv.py +59 -0
- mdbub/commands/print_links.py +24 -0
- mdbub/commands/print_tags.py +40 -0
- mdbub/commands/quick.py +1471 -0
- mdbub/commands/quickmode_config.py +141 -0
- mdbub/commands/tag_utils.py +0 -0
- mdbub/commands/version.py +67 -0
- mdbub/commands/view.py +9 -0
- mdbub/core/__init__.py +0 -0
- mdbub/core/mindmap.py +241 -0
- mdbub/core/mindmap_utils.py +33 -0
- mdbub/symbols.py +68 -0
- mdbub-0.3.7.dist-info/LICENSE +201 -0
- mdbub-0.3.7.dist-info/METADATA +182 -0
- mdbub-0.3.7.dist-info/RECORD +22 -0
- mdbub-0.3.7.dist-info/WHEEL +4 -0
- mdbub-0.3.7.dist-info/entry_points.txt +4 -0
mdbub/commands/quick.py
ADDED
@@ -0,0 +1,1471 @@
|
|
1
|
+
"""Quick mode for mdbub - A lightning-fast, keyboard-driven mindmap editor."""
|
2
|
+
|
3
|
+
import fcntl
|
4
|
+
import logging
|
5
|
+
import os
|
6
|
+
import select
|
7
|
+
import signal
|
8
|
+
import sys
|
9
|
+
import termios
|
10
|
+
import threading
|
11
|
+
import time
|
12
|
+
import tty
|
13
|
+
from dataclasses import dataclass, field
|
14
|
+
from typing import Any, Dict, List, Optional, TextIO, Tuple
|
15
|
+
|
16
|
+
from rich.console import Console
|
17
|
+
from rich.text import Text
|
18
|
+
|
19
|
+
# UI Config
|
20
|
+
from mdbub.commands.quickmode_config import (
|
21
|
+
load_quickmode_config,
|
22
|
+
load_session,
|
23
|
+
save_session,
|
24
|
+
)
|
25
|
+
from mdbub.core.mindmap import MindMapNode as Node
|
26
|
+
from mdbub.core.mindmap import mindmap_to_markdown, parse_markdown_to_mindmap
|
27
|
+
|
28
|
+
CONFIG: Dict[str, Any] = load_quickmode_config()
|
29
|
+
|
30
|
+
# Load all color constants directly from _FG/_BG keys (no legacy support)
|
31
|
+
COLOR_PRINTS_ACCENT_FG = CONFIG["COLOR_PRINTS_ACCENT_FG"]
|
32
|
+
COLOR_PRINTS_ACCENT_BG = CONFIG["COLOR_PRINTS_ACCENT_BG"]
|
33
|
+
COLOR_PRINTS_HIGHLIGHT_FG = CONFIG["COLOR_PRINTS_HIGHLIGHT_FG"]
|
34
|
+
COLOR_PRINTS_HIGHLIGHT_BG = CONFIG["COLOR_PRINTS_HIGHLIGHT_BG"]
|
35
|
+
COLOR_PRINTS_TEXT_FG = CONFIG["COLOR_PRINTS_TEXT_FG"]
|
36
|
+
COLOR_PRINTS_TEXT_BG = CONFIG["COLOR_PRINTS_TEXT_BG"]
|
37
|
+
COLOR_PRINTS_DIM_FG = CONFIG["COLOR_PRINTS_DIM_FG"]
|
38
|
+
COLOR_PRINTS_DIM_BG = CONFIG["COLOR_PRINTS_DIM_BG"]
|
39
|
+
COLOR_PRINTS_WARNING_FG = CONFIG["COLOR_PRINTS_WARNING_FG"]
|
40
|
+
COLOR_PRINTS_WARNING_BG = CONFIG["COLOR_PRINTS_WARNING_BG"]
|
41
|
+
COLOR_PRINTS_SUCCESS_FG = CONFIG["COLOR_PRINTS_SUCCESS_FG"]
|
42
|
+
COLOR_PRINTS_SUCCESS_BG = CONFIG["COLOR_PRINTS_SUCCESS_BG"]
|
43
|
+
COLOR_PRINTS_ERROR_FG = CONFIG["COLOR_PRINTS_ERROR_FG"]
|
44
|
+
COLOR_PRINTS_ERROR_BG = CONFIG["COLOR_PRINTS_ERROR_BG"]
|
45
|
+
COLOR_PRINTS_STATUS_BAR_FG = CONFIG["COLOR_PRINTS_STATUS_BAR_FG"]
|
46
|
+
COLOR_PRINTS_STATUS_BAR_BG = CONFIG["COLOR_PRINTS_STATUS_BAR_BG"]
|
47
|
+
COLOR_PRINTS_BREADCRUMB_BAR_FG = CONFIG["COLOR_PRINTS_BREADCRUMB_BAR_FG"]
|
48
|
+
COLOR_PRINTS_BREADCRUMB_BAR_BG = CONFIG["COLOR_PRINTS_BREADCRUMB_BAR_BG"]
|
49
|
+
COLOR_PRINTS_CHILD_HIGHLIGHT_FG = CONFIG["COLOR_PRINTS_CHILD_HIGHLIGHT_FG"]
|
50
|
+
COLOR_PRINTS_CHILD_HIGHLIGHT_BG = CONFIG["COLOR_PRINTS_CHILD_HIGHLIGHT_BG"]
|
51
|
+
COLOR_PRINTS_CLEAR = CONFIG["COLOR_PRINTS_CLEAR"] # Clear screen
|
52
|
+
COLOR_PRINTS_RESET = CONFIG["COLOR_PRINTS_RESET"] # Reset
|
53
|
+
COLOR_PRINTS_BLINK = CONFIG["COLOR_PRINTS_BLINK"] # Rich blink style (not ANSI)
|
54
|
+
STATUS_MESSAGE_TIMEOUT_SHORT = float(CONFIG["STATUS_MESSAGE_TIMEOUT_SHORT"])
|
55
|
+
STATUS_MESSAGE_TIMEOUT = float(CONFIG["STATUS_MESSAGE_TIMEOUT"])
|
56
|
+
STATUS_MESSAGE_TIMEOUT_LONG = float(CONFIG["STATUS_MESSAGE_TIMEOUT_LONG"])
|
57
|
+
MAX_VISIBLE_CHILDREN = int(CONFIG["MAX_VISIBLE_CHILDREN"])
|
58
|
+
MAX_NODE_LABEL_VIZ_LENGTH = int(CONFIG["MAX_NODE_LABEL_VIZ_LENGTH"])
|
59
|
+
MAX_BREADCRUMB_NODE_VIZ_LENGTH = int(CONFIG["MAX_BREADCRUMB_NODE_VIZ_LENGTH"])
|
60
|
+
MAX_CHILDNODE_VIZ_LENGTH = int(CONFIG["MAX_CHILDNODE_VIZ_LENGTH"])
|
61
|
+
COLOR_BREADCRUMBS = str(CONFIG["COLOR_BREADCRUMBS"])
|
62
|
+
COLOR_BREADCRUMBS_ROOT = str(CONFIG["COLOR_BREADCRUMBS_ROOT"])
|
63
|
+
COLOR_BREADCRUMBS_CURRENT = str(CONFIG["COLOR_BREADCRUMBS_CURRENT"])
|
64
|
+
COLOR_CURRENT_NODE = str(CONFIG["COLOR_CURRENT_NODE"])
|
65
|
+
COLOR_SELECTED_CHILD = str(CONFIG["COLOR_SELECTED_CHILD"])
|
66
|
+
COLOR_CHILD = str(CONFIG["COLOR_CHILD"])
|
67
|
+
COLOR_PAGINATION = str(CONFIG["COLOR_PAGINATION"])
|
68
|
+
COLOR_POSITION = str(CONFIG["COLOR_POSITION"])
|
69
|
+
COLOR_STATUS = str(CONFIG["COLOR_STATUS"])
|
70
|
+
COLOR_HOTKEYS = str(CONFIG["COLOR_HOTKEYS"])
|
71
|
+
COLOR_ERROR = str(CONFIG["COLOR_ERROR"])
|
72
|
+
COLOR_SUCCESS = str(CONFIG["COLOR_SUCCESS"])
|
73
|
+
SYMBOL_BULLET = str(CONFIG["SYMBOL_BULLET"])
|
74
|
+
SYMBOL_BRANCH = str(CONFIG["SYMBOL_BRANCH"])
|
75
|
+
SYMBOL_ROOT = str(CONFIG["SYMBOL_ROOT"])
|
76
|
+
SYMBOL_MORE_LEFT = str(CONFIG["SYMBOL_MORE_LEFT"])
|
77
|
+
SYMBOL_MORE_RIGHT = str(CONFIG["SYMBOL_MORE_RIGHT"])
|
78
|
+
SYMBOL_CHILDNODE_OPENWRAP = str(CONFIG["SYMBOL_CHILDNODE_OPENWRAP"])
|
79
|
+
SYMBOL_CHILDNODE_CLOSEWRAP = str(CONFIG["SYMBOL_CHILDNODE_CLOSEWRAP"])
|
80
|
+
SYMBOL_BREADCRUMBNODE_OPENWRAP = str(CONFIG["SYMBOL_BREADCRUMBNODE_OPENWRAP"])
|
81
|
+
SYMBOL_BREADCRUMBNODE_CLOSEWRAP = str(CONFIG["SYMBOL_BREADCRUMBNODE_CLOSEWRAP"])
|
82
|
+
|
83
|
+
# UI states
|
84
|
+
STATE_READY = "Ready"
|
85
|
+
STATE_EDITING = "Editing"
|
86
|
+
STATE_SAVING = "Saving..."
|
87
|
+
STATE_SAVED = "Saved"
|
88
|
+
STATE_SEARCHING = "Searching"
|
89
|
+
STATE_DELETE = "Confirm Delete"
|
90
|
+
|
91
|
+
|
92
|
+
class QuickModeUI:
|
93
|
+
"""Handles rendering of the Quick Mode interface."""
|
94
|
+
|
95
|
+
def __init__(self, state: "QuickModeState"):
|
96
|
+
self.state = state
|
97
|
+
self.console = Console()
|
98
|
+
self.term_size = self.console.size
|
99
|
+
self.live = None
|
100
|
+
|
101
|
+
def get_term_size(self) -> Tuple[int, int]:
|
102
|
+
"""Get current terminal dimensions."""
|
103
|
+
# Cast to Tuple[int, int] for mypy
|
104
|
+
return tuple(self.console.size) # type: ignore
|
105
|
+
|
106
|
+
def render_breadcrumbs(self) -> Text:
|
107
|
+
"""Render the breadcrumb navigation showing the path to current node."""
|
108
|
+
result = Text("")
|
109
|
+
|
110
|
+
# At the root node, just show the root symbol
|
111
|
+
if not self.state.path:
|
112
|
+
result.append(SYMBOL_ROOT, style=COLOR_BREADCRUMBS_ROOT)
|
113
|
+
return result
|
114
|
+
|
115
|
+
# Build the full path including the root node
|
116
|
+
node = self.state.mindmap
|
117
|
+
path_segments = [node.label] # Start with root node label
|
118
|
+
|
119
|
+
# Follow the path to collect node labels
|
120
|
+
for i, idx in enumerate(self.state.path):
|
121
|
+
if idx < len(node.children):
|
122
|
+
node = node.children[idx]
|
123
|
+
path_segments.append(node.label)
|
124
|
+
|
125
|
+
# Helper function to truncate long node names
|
126
|
+
def truncate_node_name(name: str) -> str:
|
127
|
+
if len(name) > MAX_BREADCRUMB_NODE_VIZ_LENGTH:
|
128
|
+
return name[: MAX_BREADCRUMB_NODE_VIZ_LENGTH - 3] + "..."
|
129
|
+
return name
|
130
|
+
|
131
|
+
# Start the breadcrumb with the root node label (truncated if needed)
|
132
|
+
root_name = truncate_node_name(path_segments[0])
|
133
|
+
result.append(
|
134
|
+
f"{SYMBOL_BREADCRUMBNODE_OPENWRAP}{root_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}",
|
135
|
+
style=COLOR_BREADCRUMBS_ROOT,
|
136
|
+
) # Root node label
|
137
|
+
|
138
|
+
# Handle the path display
|
139
|
+
if len(path_segments) > 3: # Root + at least 2 levels deep
|
140
|
+
# Handle truncation for long paths
|
141
|
+
result.append(" > ", style=COLOR_BREADCRUMBS)
|
142
|
+
# Truncate first level node name if needed
|
143
|
+
first_level_name = truncate_node_name(path_segments[1])
|
144
|
+
result.append(
|
145
|
+
f"{SYMBOL_BREADCRUMBNODE_OPENWRAP}{first_level_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}",
|
146
|
+
style=COLOR_BREADCRUMBS,
|
147
|
+
) # First level
|
148
|
+
|
149
|
+
if len(path_segments) > 4: # More than 3 levels deep
|
150
|
+
# Add '... >' for each hidden level
|
151
|
+
hidden_levels = (
|
152
|
+
len(path_segments) - 4
|
153
|
+
) # -4 because we show root, first, parent and current
|
154
|
+
result.append(" >", style=COLOR_BREADCRUMBS)
|
155
|
+
|
156
|
+
# Add '... >' for each hidden level
|
157
|
+
for _ in range(hidden_levels):
|
158
|
+
result.append("... >", style=COLOR_BREADCRUMBS)
|
159
|
+
result.append(" ", style=COLOR_BREADCRUMBS)
|
160
|
+
|
161
|
+
# Remove extra space at the end (already added inside the loop)
|
162
|
+
if hidden_levels > 0:
|
163
|
+
result.pop()
|
164
|
+
else:
|
165
|
+
result.append(" > ", style=COLOR_BREADCRUMBS)
|
166
|
+
|
167
|
+
# Truncate parent node name if needed
|
168
|
+
parent_name = truncate_node_name(path_segments[-2])
|
169
|
+
result.append(
|
170
|
+
f"{SYMBOL_BREADCRUMBNODE_OPENWRAP}{parent_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}",
|
171
|
+
style=COLOR_BREADCRUMBS_CURRENT,
|
172
|
+
) # Parent of current
|
173
|
+
elif len(path_segments) > 2: # Root + 1 level deep
|
174
|
+
# Show path directly
|
175
|
+
for i, segment in enumerate(path_segments[:-1]): # Exclude current node
|
176
|
+
if i > 0: # Skip root node label (already added)
|
177
|
+
result.append(" > ", style=COLOR_BREADCRUMBS)
|
178
|
+
# Truncate segment name if needed
|
179
|
+
segment_name = truncate_node_name(segment)
|
180
|
+
result.append(
|
181
|
+
f"{SYMBOL_BREADCRUMBNODE_OPENWRAP}{segment_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}",
|
182
|
+
style=COLOR_BREADCRUMBS,
|
183
|
+
)
|
184
|
+
|
185
|
+
return result
|
186
|
+
|
187
|
+
def render_current_node(self) -> Text:
|
188
|
+
"""Render the current node with bullet and edit cursor if needed."""
|
189
|
+
text = Text()
|
190
|
+
|
191
|
+
if self.state.editing:
|
192
|
+
# Show input buffer with cursor
|
193
|
+
text.append(self.state.input_buffer, style=COLOR_CURRENT_NODE)
|
194
|
+
text.append("|", style="blink") # Cursor
|
195
|
+
else:
|
196
|
+
# Show node label
|
197
|
+
text.append(self.state.current_node.label, style=COLOR_CURRENT_NODE)
|
198
|
+
|
199
|
+
return text
|
200
|
+
|
201
|
+
def render_children(self) -> Text:
|
202
|
+
"""Render the children of the current node in a horizontal layout."""
|
203
|
+
text = Text()
|
204
|
+
current_node = self.state.current_node
|
205
|
+
|
206
|
+
if not current_node.children:
|
207
|
+
text.append("No children", style=COLOR_CHILD)
|
208
|
+
return text
|
209
|
+
|
210
|
+
# Calculate pagination
|
211
|
+
total_children = len(current_node.children)
|
212
|
+
selected_idx = self.state.selected_index
|
213
|
+
|
214
|
+
# Calculate window start and end
|
215
|
+
window_size = min(MAX_VISIBLE_CHILDREN, total_children)
|
216
|
+
window_half = window_size // 2
|
217
|
+
|
218
|
+
# Ensure selected item is in the middle when possible
|
219
|
+
window_start = max(
|
220
|
+
0, min(selected_idx - window_half, total_children - window_size)
|
221
|
+
)
|
222
|
+
window_end = min(window_start + window_size, total_children)
|
223
|
+
|
224
|
+
# Add left pagination indicator if needed
|
225
|
+
if window_start > 0:
|
226
|
+
text.append("< ", style=COLOR_PAGINATION)
|
227
|
+
|
228
|
+
# Helper function to truncate long node names
|
229
|
+
def truncate_child_name(name: str) -> str:
|
230
|
+
if len(name) > MAX_CHILDNODE_VIZ_LENGTH:
|
231
|
+
return name[: MAX_CHILDNODE_VIZ_LENGTH - 3] + "..."
|
232
|
+
return name
|
233
|
+
|
234
|
+
# Add children
|
235
|
+
for i in range(window_start, window_end):
|
236
|
+
child = current_node.children[i]
|
237
|
+
# Truncate child name if needed
|
238
|
+
child_name = truncate_child_name(child.label)
|
239
|
+
style = COLOR_SELECTED_CHILD if i == selected_idx else COLOR_CHILD
|
240
|
+
text.append(
|
241
|
+
f"{SYMBOL_CHILDNODE_OPENWRAP}{child_name}{SYMBOL_CHILDNODE_CLOSEWRAP} ",
|
242
|
+
style=style,
|
243
|
+
)
|
244
|
+
|
245
|
+
# Add right pagination indicator if needed
|
246
|
+
if window_end < total_children:
|
247
|
+
text.append("> ", style=COLOR_PAGINATION)
|
248
|
+
|
249
|
+
# Add position indicator if there are multiple pages
|
250
|
+
if total_children > window_size:
|
251
|
+
text.append(f"({selected_idx + 1}/{total_children})", style=COLOR_POSITION)
|
252
|
+
|
253
|
+
return text
|
254
|
+
|
255
|
+
def render_status_bar(self) -> Text:
|
256
|
+
"""Render the status bar with state and hotkeys."""
|
257
|
+
text = Text()
|
258
|
+
|
259
|
+
# Show all status messages
|
260
|
+
if self.state.message[0]:
|
261
|
+
status_text = self.state.message[0]
|
262
|
+
status_color = self.state.message[1]
|
263
|
+
text.append(f"{status_text} ", style=status_color)
|
264
|
+
elif self.state.editing:
|
265
|
+
text.append("Editing ", style="yellow")
|
266
|
+
text.append("↵:save esc:cancel", style=COLOR_HOTKEYS)
|
267
|
+
return text
|
268
|
+
|
269
|
+
# Mini status hotkeys
|
270
|
+
# TODO: Replace with hotkey map
|
271
|
+
if not self.state.editing:
|
272
|
+
text.append(
|
273
|
+
"^c/^d:quit ↑↓←→:nav tab:add-child enter:add-sibling del:delete",
|
274
|
+
style=COLOR_HOTKEYS,
|
275
|
+
)
|
276
|
+
|
277
|
+
return text
|
278
|
+
|
279
|
+
def render(self) -> Text:
|
280
|
+
"""Render the complete UI."""
|
281
|
+
# Check for expired temporary messages before rendering
|
282
|
+
self.state.check_expired_message()
|
283
|
+
|
284
|
+
content = Text()
|
285
|
+
|
286
|
+
# Add breadcrumbs and current node on the same line
|
287
|
+
breadcrumbs = self.render_breadcrumbs()
|
288
|
+
content.append(breadcrumbs)
|
289
|
+
content.append(" ")
|
290
|
+
content.append(self.render_current_node())
|
291
|
+
|
292
|
+
# Add children on the next line
|
293
|
+
content.append("\n")
|
294
|
+
content.append(self.render_children())
|
295
|
+
|
296
|
+
# Add status bar on the next line only if there's something to show
|
297
|
+
status = self.render_status_bar()
|
298
|
+
if status.plain:
|
299
|
+
content.append("\n")
|
300
|
+
content.append(status)
|
301
|
+
|
302
|
+
return content
|
303
|
+
|
304
|
+
|
305
|
+
@dataclass
|
306
|
+
class QuickModeState:
|
307
|
+
"""Manages the state of the quick mode interface."""
|
308
|
+
|
309
|
+
mindmap: Node
|
310
|
+
path: List[int] = field(default_factory=list) # Path to current node
|
311
|
+
selected_index: int = 0
|
312
|
+
editing: bool = False
|
313
|
+
input_buffer: str = ""
|
314
|
+
dirty: bool = False
|
315
|
+
should_quit: bool = False
|
316
|
+
message: Tuple[str, str] = field(
|
317
|
+
default_factory=lambda: ("", "")
|
318
|
+
) # (message, color)
|
319
|
+
message_timestamp: float = 0.0 # When the message was set
|
320
|
+
message_temporary: bool = False # Whether the message should auto-expire
|
321
|
+
message_timeout: float = 0.0 # Timeout in seconds for temporary messages
|
322
|
+
version: str = ""
|
323
|
+
build_info: str = ""
|
324
|
+
search_mode: bool = False
|
325
|
+
search_term: str = ""
|
326
|
+
search_results: List[Tuple[List[int], int]] = field(
|
327
|
+
default_factory=list
|
328
|
+
) # List of (path, child_index) tuples for each match
|
329
|
+
search_result_index: int = 0 # Current position in search results
|
330
|
+
search_result_mode: bool = (
|
331
|
+
False # True when we've executed search and are browsing results
|
332
|
+
)
|
333
|
+
pre_search_path: List[int] = field(
|
334
|
+
default_factory=list
|
335
|
+
) # Path before search was started
|
336
|
+
pre_search_index: int = 0 # Selected index before search was started
|
337
|
+
delete_confirm: bool = False
|
338
|
+
|
339
|
+
@property
|
340
|
+
def current_node(self) -> Node:
|
341
|
+
"""Get the currently selected node."""
|
342
|
+
node = self.mindmap
|
343
|
+
for idx in self.path:
|
344
|
+
if 0 <= idx < len(node.children):
|
345
|
+
node = node.children[idx]
|
346
|
+
else:
|
347
|
+
# Reset to root if path becomes invalid
|
348
|
+
self.path = []
|
349
|
+
return self.mindmap
|
350
|
+
return node
|
351
|
+
|
352
|
+
@property
|
353
|
+
def parent_node(self) -> Optional[Node]:
|
354
|
+
"""Get the parent of the current node."""
|
355
|
+
if not self.path:
|
356
|
+
return None
|
357
|
+
return self.get_node_at_path(self.path[:-1])
|
358
|
+
|
359
|
+
def get_node_at_path(self, path: List[int]) -> Node:
|
360
|
+
"""Get node at the given path."""
|
361
|
+
node = self.mindmap
|
362
|
+
for idx in path:
|
363
|
+
if 0 <= idx < len(node.children):
|
364
|
+
node = node.children[idx]
|
365
|
+
else:
|
366
|
+
raise IndexError("Invalid path")
|
367
|
+
return node
|
368
|
+
|
369
|
+
def set_message(
|
370
|
+
self,
|
371
|
+
text: str,
|
372
|
+
color: str = "",
|
373
|
+
temporary: bool = False,
|
374
|
+
timeout: float = STATUS_MESSAGE_TIMEOUT,
|
375
|
+
) -> None:
|
376
|
+
"""Set a status message to display to the user.
|
377
|
+
|
378
|
+
Args:
|
379
|
+
text: The message text
|
380
|
+
color: Color for the message
|
381
|
+
temporary: If True, message will auto-expire after timeout seconds
|
382
|
+
timeout: Number of seconds before the message expires (only used if temporary=True)
|
383
|
+
"""
|
384
|
+
self.message = (text, color or "default")
|
385
|
+
self.message_timestamp = time.time()
|
386
|
+
self.message_temporary = temporary
|
387
|
+
self.message_timeout = timeout if temporary else 0.0
|
388
|
+
|
389
|
+
def clear_message(self) -> None:
|
390
|
+
"""Clear any status message."""
|
391
|
+
self.message = ("", "")
|
392
|
+
self.message_temporary = False
|
393
|
+
self.message_timeout = 0.0
|
394
|
+
|
395
|
+
def check_expired_message(self) -> None:
|
396
|
+
"""Check if a temporary message has expired and clear it if needed."""
|
397
|
+
if self.message_temporary and self.message[0]:
|
398
|
+
current_time = time.time()
|
399
|
+
elapsed = current_time - self.message_timestamp
|
400
|
+
# Print debug info to console about message timing
|
401
|
+
# print(f"DEBUG: Message '{self.message[0]}' age: {elapsed:.1f}s vs timeout {self.message_timeout}s", file=sys.stderr)
|
402
|
+
if elapsed >= self.message_timeout:
|
403
|
+
# print(f"DEBUG: Clearing message '{self.message[0]}'", file=sys.stderr)
|
404
|
+
self.clear_message()
|
405
|
+
|
406
|
+
def navigate_prev_sibling(self) -> None:
|
407
|
+
"""Move selection to previous sibling (left)."""
|
408
|
+
# print(f"\nDEBUG: navigate_prev_sibling called, current selected_index={self.selected_index}")
|
409
|
+
if self.selected_index > 0:
|
410
|
+
self.selected_index -= 1
|
411
|
+
self.clear_message()
|
412
|
+
# print(f"DEBUG: selected_index now {self.selected_index}")
|
413
|
+
# else:
|
414
|
+
# print("DEBUG: Can't navigate to previous sibling (already at first sibling)")
|
415
|
+
|
416
|
+
def navigate_next_sibling(self) -> None:
|
417
|
+
"""Move selection to next sibling (right)."""
|
418
|
+
# print(f"\nDEBUG: navigate_next_sibling called, current selected_index={self.selected_index}")
|
419
|
+
# print(f"DEBUG: current node has {len(self.current_node.children)} children")
|
420
|
+
if self.selected_index < len(self.current_node.children) - 1:
|
421
|
+
self.selected_index += 1
|
422
|
+
self.clear_message()
|
423
|
+
# print(f"DEBUG: selected_index now {self.selected_index}")
|
424
|
+
# else:
|
425
|
+
# print("DEBUG: Can't navigate to next sibling (already at last sibling)")
|
426
|
+
|
427
|
+
def navigate_to_parent(self) -> None:
|
428
|
+
"""Move to parent node (up the tree)."""
|
429
|
+
# print(f"\nDEBUG: navigate_to_parent called, current path={self.path}")
|
430
|
+
if self.path:
|
431
|
+
index_to_remember = self.path[-1] # Remember which child we came from
|
432
|
+
self.path.pop()
|
433
|
+
self.selected_index = (
|
434
|
+
index_to_remember # Position cursor on the child we came from
|
435
|
+
)
|
436
|
+
self.clear_message()
|
437
|
+
# print(f"DEBUG: Moved to parent, new path={self.path}, selected_index={self.selected_index}")
|
438
|
+
# else:
|
439
|
+
# print("DEBUG: Can't navigate to parent (already at root)")
|
440
|
+
|
441
|
+
def navigate_to_child(self) -> None:
|
442
|
+
"""Move to selected child node (down the tree)."""
|
443
|
+
# print(f"\nDEBUG: navigate_to_child called")
|
444
|
+
if self.current_node.children:
|
445
|
+
self.path.append(self.selected_index)
|
446
|
+
self.selected_index = 0
|
447
|
+
self.clear_message()
|
448
|
+
# print(f"DEBUG: Moved into child, new path={self.path}")
|
449
|
+
# else:
|
450
|
+
# print("DEBUG: Can't navigate to child (no children)")
|
451
|
+
|
452
|
+
def start_editing(self) -> None:
|
453
|
+
"""Start editing the current node."""
|
454
|
+
self.editing = True
|
455
|
+
self.input_buffer = self.current_node.label
|
456
|
+
|
457
|
+
def start_inserting(self) -> None:
|
458
|
+
self.editing = True
|
459
|
+
self.input_buffer = self.current_node.label
|
460
|
+
|
461
|
+
def save_edit(self) -> None:
|
462
|
+
"""Save the current edit."""
|
463
|
+
from mdbub.core.mindmap import _parse_node_metadata
|
464
|
+
|
465
|
+
if self.input_buffer != self.current_node.label:
|
466
|
+
# Re-parse label for tags/metadata
|
467
|
+
label, metadata = _parse_node_metadata(self.input_buffer)
|
468
|
+
truncated = False
|
469
|
+
if len(label) > MAX_NODE_LABEL_VIZ_LENGTH:
|
470
|
+
label = label[:MAX_NODE_LABEL_VIZ_LENGTH] + "... [truncated]"
|
471
|
+
truncated = True
|
472
|
+
self.current_node.label = label
|
473
|
+
# Only update tags in metadata (preserve other keys)
|
474
|
+
if "tags" in metadata:
|
475
|
+
self.current_node.metadata["tags"] = metadata["tags"]
|
476
|
+
elif "tags" in self.current_node.metadata:
|
477
|
+
del self.current_node.metadata["tags"]
|
478
|
+
# Optionally handle other metadata keys here
|
479
|
+
self.dirty = True
|
480
|
+
if truncated:
|
481
|
+
self.set_message(
|
482
|
+
f"Node label was too long and truncated to {MAX_NODE_LABEL_VIZ_LENGTH} chars.",
|
483
|
+
"yellow",
|
484
|
+
temporary=True,
|
485
|
+
timeout=STATUS_MESSAGE_TIMEOUT_LONG,
|
486
|
+
)
|
487
|
+
else:
|
488
|
+
self.set_message(
|
489
|
+
"Updated node with changes",
|
490
|
+
"green",
|
491
|
+
temporary=True,
|
492
|
+
timeout=STATUS_MESSAGE_TIMEOUT_SHORT,
|
493
|
+
)
|
494
|
+
self.editing = False
|
495
|
+
self.input_buffer = ""
|
496
|
+
|
497
|
+
def cancel_edit(self) -> None:
|
498
|
+
"""Cancel the current edit."""
|
499
|
+
self.set_message(
|
500
|
+
"Cancelling node changes",
|
501
|
+
"yellow",
|
502
|
+
temporary=True,
|
503
|
+
timeout=STATUS_MESSAGE_TIMEOUT_SHORT,
|
504
|
+
)
|
505
|
+
self.editing = False
|
506
|
+
self.input_buffer = ""
|
507
|
+
|
508
|
+
def add_child(self) -> None:
|
509
|
+
"""Add a new child node and navigate to it."""
|
510
|
+
new_node = Node("")
|
511
|
+
self.current_node.children.append(new_node)
|
512
|
+
# Store the index of the new child
|
513
|
+
child_index = len(self.current_node.children) - 1
|
514
|
+
# Navigate into the new child node
|
515
|
+
self.path.append(child_index)
|
516
|
+
self.selected_index = 0 # Reset selection to first position in the new level
|
517
|
+
self.dirty = True
|
518
|
+
self.start_editing()
|
519
|
+
|
520
|
+
def add_sibling(self) -> None:
|
521
|
+
"""Add a new sibling node."""
|
522
|
+
if not self.path:
|
523
|
+
self.set_message("Cannot add sibling to root", "yellow", temporary=True)
|
524
|
+
return
|
525
|
+
|
526
|
+
parent = self.parent_node
|
527
|
+
if not parent:
|
528
|
+
self.set_message(
|
529
|
+
"Cannot add a sibling when there is no parent", "yellow", temporary=True
|
530
|
+
)
|
531
|
+
return
|
532
|
+
|
533
|
+
new_node = Node("")
|
534
|
+
insert_pos = self.path[-1] + 1
|
535
|
+
parent.children.insert(insert_pos, new_node)
|
536
|
+
self.path[-1] = insert_pos
|
537
|
+
self.dirty = True
|
538
|
+
self.start_editing()
|
539
|
+
|
540
|
+
def delete_node(self) -> None:
|
541
|
+
"""Delete the selected child node."""
|
542
|
+
# If there are no children, there's nothing to delete
|
543
|
+
if not self.current_node.children:
|
544
|
+
self.set_message("No children to delete", "yellow", temporary=True)
|
545
|
+
return
|
546
|
+
|
547
|
+
# Get the selected child index and node
|
548
|
+
selected_idx = self.selected_index
|
549
|
+
if selected_idx >= len(self.current_node.children):
|
550
|
+
return
|
551
|
+
|
552
|
+
selected_node = self.current_node.children[selected_idx]
|
553
|
+
|
554
|
+
# Confirm deletion if not already confirming
|
555
|
+
if not self.delete_confirm:
|
556
|
+
self.delete_confirm = True
|
557
|
+
# Use the same wrapper style and truncation for the node name in the confirmation message
|
558
|
+
node_label = selected_node.label
|
559
|
+
if len(node_label) > MAX_CHILDNODE_VIZ_LENGTH:
|
560
|
+
node_label = node_label[: MAX_CHILDNODE_VIZ_LENGTH - 3] + "..."
|
561
|
+
wrapped_node = (
|
562
|
+
f"{SYMBOL_CHILDNODE_OPENWRAP}{node_label}{SYMBOL_CHILDNODE_CLOSEWRAP}"
|
563
|
+
)
|
564
|
+
self.set_message(f"Delete {wrapped_node}? (y/n)", "yellow")
|
565
|
+
return
|
566
|
+
|
567
|
+
# Delete the selected child
|
568
|
+
del self.current_node.children[selected_idx]
|
569
|
+
|
570
|
+
# Adjust selected index if we deleted the last child
|
571
|
+
if (
|
572
|
+
self.selected_index >= len(self.current_node.children)
|
573
|
+
and self.current_node.children
|
574
|
+
):
|
575
|
+
self.selected_index = len(self.current_node.children) - 1
|
576
|
+
|
577
|
+
self.dirty = True
|
578
|
+
self.delete_confirm = False
|
579
|
+
self.set_message("Child node deleted", "green", temporary=True)
|
580
|
+
|
581
|
+
def cancel_delete(self) -> None:
|
582
|
+
"""Cancel the current delete operation."""
|
583
|
+
self.delete_confirm = False
|
584
|
+
self.set_message("Delete canceled", "yellow", temporary=True)
|
585
|
+
|
586
|
+
def navigate_to_first_child(self) -> None:
|
587
|
+
"""Jump to the first child."""
|
588
|
+
if self.current_node.children:
|
589
|
+
self.selected_index = 0
|
590
|
+
self.clear_message()
|
591
|
+
|
592
|
+
def navigate_to_last_child(self) -> None:
|
593
|
+
"""Jump to the last child."""
|
594
|
+
if self.current_node.children:
|
595
|
+
self.selected_index = len(self.current_node.children) - 1
|
596
|
+
self.clear_message()
|
597
|
+
|
598
|
+
def navigate_to_root(self) -> None:
|
599
|
+
"""Jump back to the root node."""
|
600
|
+
self.path = []
|
601
|
+
self.selected_index = 0
|
602
|
+
self.clear_message()
|
603
|
+
|
604
|
+
def start_search(self) -> None:
|
605
|
+
"""Enter search mode."""
|
606
|
+
# Save current position so we can return to it if user cancels
|
607
|
+
# Create a deep copy to ensure we don't have reference issues
|
608
|
+
self.pre_search_path = self.path.copy() if self.path else []
|
609
|
+
self.pre_search_index = self.selected_index
|
610
|
+
|
611
|
+
# Log the saved position for debugging
|
612
|
+
logging.debug(
|
613
|
+
f"Saved pre-search position: path={self.pre_search_path}, index={self.pre_search_index}"
|
614
|
+
)
|
615
|
+
|
616
|
+
self.search_mode = True
|
617
|
+
self.search_result_mode = False
|
618
|
+
self.search_term = ""
|
619
|
+
self.search_results = []
|
620
|
+
self.search_result_index = 0
|
621
|
+
# This message hides where the term is being typed, commenting out for now
|
622
|
+
# self.set_message("Enter search term", "yellow", temporary=True)
|
623
|
+
|
624
|
+
def cancel_search(self) -> None:
|
625
|
+
"""Exit search mode."""
|
626
|
+
self.search_mode = False
|
627
|
+
self.search_result_mode = False
|
628
|
+
self.search_term = ""
|
629
|
+
|
630
|
+
# Save current position for logging
|
631
|
+
current_path = self.path.copy()
|
632
|
+
current_index = self.selected_index
|
633
|
+
|
634
|
+
# On first launch, just return to root
|
635
|
+
if not self.pre_search_path and current_path:
|
636
|
+
# If we have no pre-search path but we're not at root, go to root
|
637
|
+
logging.debug("First search after launch, returning to root")
|
638
|
+
self.path = []
|
639
|
+
self.selected_index = 0
|
640
|
+
self.set_message(
|
641
|
+
"Returned to root",
|
642
|
+
"green",
|
643
|
+
temporary=True,
|
644
|
+
timeout=STATUS_MESSAGE_TIMEOUT_SHORT,
|
645
|
+
)
|
646
|
+
# Otherwise use pre-search position if we've moved
|
647
|
+
elif self.pre_search_path:
|
648
|
+
# Check if we've moved from pre-search position
|
649
|
+
if (
|
650
|
+
current_path != self.pre_search_path
|
651
|
+
or current_index != self.pre_search_index
|
652
|
+
):
|
653
|
+
logging.debug(
|
654
|
+
f"Restoring position from {current_path} to {self.pre_search_path}"
|
655
|
+
)
|
656
|
+
self.path = self.pre_search_path.copy()
|
657
|
+
self.selected_index = self.pre_search_index
|
658
|
+
self.set_message(
|
659
|
+
"Returned to pre-search position",
|
660
|
+
"green",
|
661
|
+
temporary=True,
|
662
|
+
timeout=STATUS_MESSAGE_TIMEOUT_SHORT,
|
663
|
+
)
|
664
|
+
|
665
|
+
# Clear search-related data
|
666
|
+
self.search_results = []
|
667
|
+
self.search_result_index = 0
|
668
|
+
self.pre_search_path = []
|
669
|
+
self.pre_search_index = 0
|
670
|
+
|
671
|
+
def _fuzzy_match(self, text: str, pattern: str) -> bool:
|
672
|
+
"""Perform a case-insensitive substring match.
|
673
|
+
|
674
|
+
Args:
|
675
|
+
text: The text to search in
|
676
|
+
pattern: The pattern to search for
|
677
|
+
|
678
|
+
Returns:
|
679
|
+
True if the pattern matches, False otherwise
|
680
|
+
"""
|
681
|
+
if not pattern:
|
682
|
+
return False
|
683
|
+
|
684
|
+
# Convert to lowercase for case-insensitive matching
|
685
|
+
text_lower = text.lower()
|
686
|
+
pattern_lower = pattern.lower()
|
687
|
+
|
688
|
+
# Simple substring match - more precise than the previous fuzzy match
|
689
|
+
return pattern_lower in text_lower
|
690
|
+
|
691
|
+
def perform_search(self) -> None:
|
692
|
+
"""Search the mindmap for the current search term."""
|
693
|
+
if not self.search_term:
|
694
|
+
return
|
695
|
+
|
696
|
+
# Reset search results
|
697
|
+
self.search_results = []
|
698
|
+
self.search_result_index = 0
|
699
|
+
|
700
|
+
# Recursively search through the mindmap
|
701
|
+
self._search_node(self.mindmap, [], 0)
|
702
|
+
|
703
|
+
# If we found matches, navigate to the first match
|
704
|
+
if self.search_results:
|
705
|
+
# Enter result browsing mode where we can't modify the search term
|
706
|
+
self.search_result_mode = True
|
707
|
+
self.navigate_to_search_result()
|
708
|
+
# result_count = len(self.search_results) # Removed unused variable
|
709
|
+
# This message hides the seach status bar, commenting out for now
|
710
|
+
# self.set_message(f"Found {result_count} match{'es' if result_count != 1 else ''}. Press right/left to navigate.", "green", temporary=True, timeout=STATUS_MESSAGE_TIMEOUT_LONG)
|
711
|
+
else:
|
712
|
+
self.set_message(
|
713
|
+
f"Pattern not found: '{self.search_term}'",
|
714
|
+
"yellow",
|
715
|
+
temporary=True,
|
716
|
+
timeout=STATUS_MESSAGE_TIMEOUT_LONG,
|
717
|
+
)
|
718
|
+
|
719
|
+
def _search_node(self, node: Node, current_path: List[int], depth: int) -> None:
|
720
|
+
"""Recursively search a node and its children for the search term.
|
721
|
+
|
722
|
+
Args:
|
723
|
+
node: The node to search
|
724
|
+
current_path: The path to this node
|
725
|
+
depth: Current recursion depth (to prevent stack overflow)
|
726
|
+
"""
|
727
|
+
# Limit search depth to prevent stack overflow
|
728
|
+
if depth > 100:
|
729
|
+
return
|
730
|
+
|
731
|
+
# Check this node's label
|
732
|
+
if self._fuzzy_match(node.label, self.search_term):
|
733
|
+
# For the root node, we save [0] as the child index since we can't navigate to the root itself
|
734
|
+
if not current_path:
|
735
|
+
self.search_results.append(([], 0))
|
736
|
+
|
737
|
+
# Search all children
|
738
|
+
for i, child in enumerate(node.children):
|
739
|
+
# Check if this child matches
|
740
|
+
if self._fuzzy_match(child.label, self.search_term):
|
741
|
+
# Save the path to the parent and the child's index
|
742
|
+
self.search_results.append((current_path.copy(), i))
|
743
|
+
|
744
|
+
# Recursively search this child's children
|
745
|
+
new_path = current_path.copy()
|
746
|
+
new_path.append(i)
|
747
|
+
self._search_node(child, new_path, depth + 1)
|
748
|
+
|
749
|
+
def navigate_to_search_result(self) -> None:
|
750
|
+
"""Navigate to the current search result."""
|
751
|
+
if not self.search_results:
|
752
|
+
return
|
753
|
+
|
754
|
+
# Get the current result
|
755
|
+
result_path, child_index = self.search_results[self.search_result_index]
|
756
|
+
|
757
|
+
# Navigate to the parent node of the match
|
758
|
+
self.path = result_path.copy()
|
759
|
+
|
760
|
+
# Select the matching child
|
761
|
+
self.selected_index = child_index
|
762
|
+
|
763
|
+
def next_search_result(self) -> None:
|
764
|
+
"""Navigate to the next search result."""
|
765
|
+
if not self.search_results:
|
766
|
+
return
|
767
|
+
|
768
|
+
# Move to the next result
|
769
|
+
self.search_result_index = (self.search_result_index + 1) % len(
|
770
|
+
self.search_results
|
771
|
+
)
|
772
|
+
self.navigate_to_search_result()
|
773
|
+
# This message hides the seach status bar, commenting out for now
|
774
|
+
# self.set_message(f"Match {self.search_result_index + 1}/{len(self.search_results)}", "green", temporary=True)
|
775
|
+
|
776
|
+
def prev_search_result(self) -> None:
|
777
|
+
"""Navigate to the previous search result."""
|
778
|
+
if not self.search_results:
|
779
|
+
return
|
780
|
+
|
781
|
+
# Move to the previous result
|
782
|
+
self.search_result_index = (self.search_result_index - 1) % len(
|
783
|
+
self.search_results
|
784
|
+
)
|
785
|
+
self.navigate_to_search_result()
|
786
|
+
# This message hides the seach status bar, commenting out for now
|
787
|
+
# self.set_message(f"Match {self.search_result_index + 1}/{len(self.search_results)}", "green", temporary=True)
|
788
|
+
|
789
|
+
def select_search_result(self) -> None:
|
790
|
+
"""Select the current search result and make it the focus node."""
|
791
|
+
if not self.search_results:
|
792
|
+
return
|
793
|
+
|
794
|
+
# Get the current result path and child index
|
795
|
+
result_path, child_index = self.search_results[self.search_result_index]
|
796
|
+
|
797
|
+
# Navigate to the child node itself (it should become the current node)
|
798
|
+
self.navigate_to_child()
|
799
|
+
|
800
|
+
# Clear search mode and results
|
801
|
+
self.search_mode = False
|
802
|
+
self.search_result_mode = False
|
803
|
+
self.search_results = []
|
804
|
+
self.search_result_index = 0
|
805
|
+
self.pre_search_path = []
|
806
|
+
self.pre_search_index = 0
|
807
|
+
|
808
|
+
# Confirm the selection with a message
|
809
|
+
self.set_message(
|
810
|
+
f"Selected match: '{self.current_node.label}'",
|
811
|
+
"green",
|
812
|
+
temporary=True,
|
813
|
+
timeout=STATUS_MESSAGE_TIMEOUT,
|
814
|
+
)
|
815
|
+
|
816
|
+
def confirm_delete(self, confirm: bool) -> None:
|
817
|
+
"""Confirm or cancel node deletion."""
|
818
|
+
if confirm:
|
819
|
+
# Actually delete the node
|
820
|
+
parent = self.parent_node
|
821
|
+
if not parent:
|
822
|
+
return
|
823
|
+
|
824
|
+
del parent.children[self.path[-1]]
|
825
|
+
|
826
|
+
# Adjust path if we deleted the last child
|
827
|
+
if self.path[-1] >= len(parent.children) and parent.children:
|
828
|
+
self.path[-1] = len(parent.children) - 1
|
829
|
+
|
830
|
+
self.dirty = True
|
831
|
+
self.set_message("Node deleted", "green", temporary=True)
|
832
|
+
else:
|
833
|
+
self.set_message("Delete canceled", "yellow", temporary=True)
|
834
|
+
|
835
|
+
self.delete_confirm = False
|
836
|
+
|
837
|
+
|
838
|
+
# Key constants for clearer code
|
839
|
+
KEY_UP = "\x1b[A"
|
840
|
+
KEY_DOWN = "\x1b[B"
|
841
|
+
KEY_RIGHT = "\x1b[C"
|
842
|
+
KEY_LEFT = "\x1b[D"
|
843
|
+
KEY_ENTER = "\r"
|
844
|
+
KEY_ESC = "\x1b"
|
845
|
+
KEY_TAB = "\t"
|
846
|
+
KEY_BACKSPACE = "\x7f"
|
847
|
+
KEY_INSERT = "\x1b[2~"
|
848
|
+
KEY_CTRL_C = "\x03"
|
849
|
+
KEY_CTRL_D = "\x04"
|
850
|
+
KEY_SLASH = "/"
|
851
|
+
KEY_CTRL_F = "\x06"
|
852
|
+
KEY_CTRL_E = "\x05"
|
853
|
+
KEY_CTRL_I = "\x09"
|
854
|
+
|
855
|
+
# TODO not sure this is used - Vibe programming :shrug: need to see if it should be used
|
856
|
+
# Commenting out for now - JTD 2025-06-21
|
857
|
+
# def handle_key(state: QuickModeState, key: str) -> bool:
|
858
|
+
# """Process a keypress and update state accordingly.
|
859
|
+
|
860
|
+
# Args:
|
861
|
+
# state: The current state object
|
862
|
+
# key: The key that was pressed
|
863
|
+
|
864
|
+
# Returns:
|
865
|
+
# bool: True if the application should continue, False if it should exit
|
866
|
+
# """
|
867
|
+
|
868
|
+
# # Handle delete confirmation state
|
869
|
+
# if state.delete_confirm:
|
870
|
+
# if key.lower() == "y":
|
871
|
+
# state.confirm_delete(True)
|
872
|
+
# elif key.lower() == "n" or key == "\x1b": # n or Escape
|
873
|
+
# state.confirm_delete(False)
|
874
|
+
# return True
|
875
|
+
|
876
|
+
# # Handle search mode
|
877
|
+
# if state.search_mode:
|
878
|
+
# if key == KEY_ESC: # Escape
|
879
|
+
# state.cancel_search()
|
880
|
+
# elif key == KEY_ENTER: # Enter
|
881
|
+
# if state.search_result_mode:
|
882
|
+
# # We're browsing results, so select the current one
|
883
|
+
# state.select_search_result()
|
884
|
+
# elif state.search_term:
|
885
|
+
# # We're typing a search, so execute it
|
886
|
+
# state.perform_search()
|
887
|
+
# else:
|
888
|
+
# # Empty search, just cancel
|
889
|
+
# state.cancel_search()
|
890
|
+
# elif (
|
891
|
+
# key == KEY_BACKSPACE and not state.search_result_mode
|
892
|
+
# ): # Backspace - only in input mode
|
893
|
+
# if state.search_term:
|
894
|
+
# state.search_term = state.search_term[:-1]
|
895
|
+
# elif key == KEY_LEFT: # Left arrow - previous result
|
896
|
+
# if state.search_results:
|
897
|
+
# state.prev_search_result()
|
898
|
+
# elif key == KEY_RIGHT: # Right arrow - next result
|
899
|
+
# if state.search_results:
|
900
|
+
# state.next_search_result()
|
901
|
+
# elif (
|
902
|
+
# len(key) == 1 and key.isprintable() and not state.search_result_mode
|
903
|
+
# ): # Only add chars in input mode
|
904
|
+
# state.search_term += key
|
905
|
+
# return True
|
906
|
+
|
907
|
+
# # Handle editing mode
|
908
|
+
# if state.editing:
|
909
|
+
# if key == KEY_ESC: # Escape
|
910
|
+
# state.cancel_edit()
|
911
|
+
# elif key == KEY_ENTER: # Enter
|
912
|
+
# state.save_edit()
|
913
|
+
# elif key == "e":
|
914
|
+
# state.start_edit()
|
915
|
+
# elif key == KEY_BACKSPACE: # Backspace
|
916
|
+
# if state.input_buffer:
|
917
|
+
# state.input_buffer = state.input_buffer[:-1]
|
918
|
+
# elif len(key) == 1 and key.isprintable():
|
919
|
+
# state.input_buffer += key
|
920
|
+
# return True
|
921
|
+
|
922
|
+
# # Navigation mode
|
923
|
+
# if key == KEY_SLASH: # Slash - start search
|
924
|
+
# state.start_search()
|
925
|
+
# return True
|
926
|
+
# elif key == KEY_CTRL_C: # Ctrl+C
|
927
|
+
# return False
|
928
|
+
# elif key == KEY_CTRL_D: # Ctrl+D
|
929
|
+
# return False
|
930
|
+
# elif key == KEY_UP: # Up arrow
|
931
|
+
# state.navigate_up()
|
932
|
+
# elif key == KEY_DOWN: # Down arrow
|
933
|
+
# state.navigate_down()
|
934
|
+
# elif key == KEY_LEFT: # Left arrow
|
935
|
+
# state.navigate_left()
|
936
|
+
# elif key == KEY_RIGHT: # Right arrow
|
937
|
+
# state.navigate_right()
|
938
|
+
# elif key == "\x1b[1;3A": # Alt+Up
|
939
|
+
# state.navigate_to_root()
|
940
|
+
# elif key == "\x1b[1;3D": # Alt+Left
|
941
|
+
# state.navigate_to_first_child()
|
942
|
+
# elif key == "\x1b[1;3C": # Alt+Right
|
943
|
+
# state.navigate_to_last_child()
|
944
|
+
# elif key == KEY_ENTER: # Enter
|
945
|
+
# state.add_sibling()
|
946
|
+
# elif key == KEY_TAB: # Tab
|
947
|
+
# state.add_child()
|
948
|
+
# elif key == KEY_BACKSPACE: # Delete or Backspace
|
949
|
+
# state.delete_node()
|
950
|
+
# elif key == KEY_SLASH: # Slash for search
|
951
|
+
# state.start_search()
|
952
|
+
# elif len(key) == 1 and key.isprintable():
|
953
|
+
# # Any other printable character starts editing
|
954
|
+
# state.start_editing()
|
955
|
+
# state.input_buffer = key
|
956
|
+
|
957
|
+
# return True
|
958
|
+
|
959
|
+
|
960
|
+
def get_key(timeout: float = 1.0) -> str:
|
961
|
+
"""Get a single keypress from terminal, handling special keys like arrows.
|
962
|
+
|
963
|
+
Args:
|
964
|
+
timeout: Maximum time to wait for a key press in seconds.
|
965
|
+
If no key is pressed within this time, returns empty string.
|
966
|
+
|
967
|
+
Returns:
|
968
|
+
The key pressed, or empty string if timeout occurred.
|
969
|
+
"""
|
970
|
+
# Dictionary of escape sequences
|
971
|
+
ARROW_KEY_SEQUENCES = {
|
972
|
+
"\x1b[A": KEY_UP, # Up arrow
|
973
|
+
"\x1b[B": KEY_DOWN, # Down arrow
|
974
|
+
"\x1b[C": KEY_RIGHT, # Right arrow
|
975
|
+
"\x1b[D": KEY_LEFT, # Left arrow
|
976
|
+
"\x1bOA": KEY_UP, # Up arrow (alternate)
|
977
|
+
"\x1bOB": KEY_DOWN, # Down arrow (alternate)
|
978
|
+
"\x1bOC": KEY_RIGHT, # Right arrow (alternate)
|
979
|
+
"\x1bOD": KEY_LEFT, # Left arrow (alternate)
|
980
|
+
}
|
981
|
+
|
982
|
+
fd = sys.stdin.fileno()
|
983
|
+
old_settings = termios.tcgetattr(fd)
|
984
|
+
key = ""
|
985
|
+
try:
|
986
|
+
tty.setraw(fd)
|
987
|
+
|
988
|
+
# Set stdin to non-blocking mode
|
989
|
+
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
|
990
|
+
|
991
|
+
# Wait for input with timeout using select
|
992
|
+
ready, _, _ = select.select([sys.stdin], [], [], timeout)
|
993
|
+
if ready:
|
994
|
+
key = sys.stdin.read(1)
|
995
|
+
else:
|
996
|
+
# Timeout occurred, no key pressed
|
997
|
+
return ""
|
998
|
+
if key == KEY_ESC: # Escape sequence
|
999
|
+
# Set stdin to non-blocking mode
|
1000
|
+
fcntl.fcntl(fd, fcntl.F_SETFL, os.O_NONBLOCK)
|
1001
|
+
|
1002
|
+
# Try to read the escape sequence
|
1003
|
+
seq = key
|
1004
|
+
try:
|
1005
|
+
while True:
|
1006
|
+
ch = sys.stdin.read(1)
|
1007
|
+
if not ch: # No more characters
|
1008
|
+
break
|
1009
|
+
seq += ch
|
1010
|
+
# Check if we have a known sequence
|
1011
|
+
if seq in ARROW_KEY_SEQUENCES:
|
1012
|
+
return ARROW_KEY_SEQUENCES[seq]
|
1013
|
+
# Avoid reading too many characters
|
1014
|
+
if len(seq) >= 6:
|
1015
|
+
break
|
1016
|
+
except Exception:
|
1017
|
+
# If anything goes wrong, just return what we have
|
1018
|
+
pass
|
1019
|
+
finally:
|
1020
|
+
# Reset stdin to blocking mode
|
1021
|
+
fcntl.fcntl(fd, fcntl.F_SETFL, 0)
|
1022
|
+
|
1023
|
+
return seq
|
1024
|
+
finally:
|
1025
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
1026
|
+
|
1027
|
+
return key
|
1028
|
+
|
1029
|
+
|
1030
|
+
def auto_save(state: QuickModeState, file: TextIO) -> None:
|
1031
|
+
"""Auto-save function to periodically save changes."""
|
1032
|
+
while not state.should_quit:
|
1033
|
+
if state.dirty:
|
1034
|
+
try:
|
1035
|
+
# Only show the message if we're not in the middle of another operation
|
1036
|
+
show_message = not (state.editing or state.delete_confirm)
|
1037
|
+
|
1038
|
+
# Save changes
|
1039
|
+
file.seek(0)
|
1040
|
+
file.truncate()
|
1041
|
+
file.write(mindmap_to_markdown(state.mindmap))
|
1042
|
+
file.flush()
|
1043
|
+
state.dirty = False
|
1044
|
+
|
1045
|
+
# Only show 'Saved' message if we're not in the middle of another operation
|
1046
|
+
if show_message:
|
1047
|
+
state.set_message("Autosave complete", "green", temporary=True)
|
1048
|
+
except Exception as e:
|
1049
|
+
state.set_message(f"Error autosaving: {str(e)}", "red")
|
1050
|
+
# Check every 2 seconds
|
1051
|
+
time.sleep(2)
|
1052
|
+
|
1053
|
+
|
1054
|
+
def main(
|
1055
|
+
file: TextIO, version: str, build_info: str, deep_link_path: Optional[str] = None
|
1056
|
+
) -> int:
|
1057
|
+
"""Main entry point for quick mode with session restore support."""
|
1058
|
+
import os
|
1059
|
+
|
1060
|
+
session_used = False
|
1061
|
+
session = None
|
1062
|
+
if file is None:
|
1063
|
+
session = load_session()
|
1064
|
+
if session and session.get("last_file"):
|
1065
|
+
try:
|
1066
|
+
file = open(session["last_file"], "r+")
|
1067
|
+
session_used = True
|
1068
|
+
except Exception as e:
|
1069
|
+
print(f"[mdbub] Failed to open last session file: {e}", file=sys.stderr)
|
1070
|
+
file = None
|
1071
|
+
if file is None:
|
1072
|
+
print("No file provided and no session to restore. Exiting.", file=sys.stderr)
|
1073
|
+
return 1
|
1074
|
+
try:
|
1075
|
+
# Read the file content
|
1076
|
+
content = file.read()
|
1077
|
+
|
1078
|
+
# Parse the markdown content into a mindmap
|
1079
|
+
create_new_mindmap = False
|
1080
|
+
try:
|
1081
|
+
mindmap = parse_markdown_to_mindmap(content)
|
1082
|
+
if not mindmap or not mindmap.label: # If parsing failed or empty file
|
1083
|
+
create_new_mindmap = True
|
1084
|
+
except Exception as e:
|
1085
|
+
print(f"Error parsing mindmap: {e}", file=sys.stderr)
|
1086
|
+
create_new_mindmap = True
|
1087
|
+
|
1088
|
+
# Create a new mindmap if needed
|
1089
|
+
if create_new_mindmap:
|
1090
|
+
# Create an empty mindmap with the filename as the root node
|
1091
|
+
filename = os.path.basename(file.name)
|
1092
|
+
root_name = os.path.splitext(filename)[0] if "." in filename else filename
|
1093
|
+
mindmap = Node(root_name)
|
1094
|
+
|
1095
|
+
# Initialize state
|
1096
|
+
state = QuickModeState(mindmap)
|
1097
|
+
if create_new_mindmap:
|
1098
|
+
state.dirty = True # Ensure we save the new mindmap
|
1099
|
+
state.version = version
|
1100
|
+
state.build_info = build_info
|
1101
|
+
|
1102
|
+
# Restore last node path if session was used
|
1103
|
+
if session_used and session and session.get("last_node_path"):
|
1104
|
+
try:
|
1105
|
+
path = session["last_node_path"]
|
1106
|
+
node = mindmap
|
1107
|
+
for i, idx in enumerate(path):
|
1108
|
+
if 0 <= idx < len(node.children):
|
1109
|
+
node = node.children[idx]
|
1110
|
+
else:
|
1111
|
+
raise IndexError
|
1112
|
+
state.path = path
|
1113
|
+
state.selected_index = path[-1] if path else 0
|
1114
|
+
except Exception:
|
1115
|
+
state.path = []
|
1116
|
+
state.selected_index = 0
|
1117
|
+
|
1118
|
+
# Anchor-style deep link: search for label containing [id:path/to/me]
|
1119
|
+
def find_node_with_anchor(root: Node, anchor: str) -> Optional[List[int]]:
|
1120
|
+
stack: List[Any] = [(root, [])] # Start with root node and empty path
|
1121
|
+
anchor_str = f"[id:{anchor}]"
|
1122
|
+
while stack:
|
1123
|
+
node, path = stack.pop()
|
1124
|
+
if anchor_str in node.label:
|
1125
|
+
if isinstance(path, list) and all(isinstance(i, int) for i in path):
|
1126
|
+
return path
|
1127
|
+
else:
|
1128
|
+
return None
|
1129
|
+
for idx, child in enumerate(node.children):
|
1130
|
+
stack.append((child, path + [idx]))
|
1131
|
+
return None
|
1132
|
+
|
1133
|
+
if deep_link_path:
|
1134
|
+
anchor_path = deep_link_path
|
1135
|
+
result_path = find_node_with_anchor(mindmap, anchor_path)
|
1136
|
+
if result_path is not None:
|
1137
|
+
state.path = result_path
|
1138
|
+
state.selected_index = 0 if not result_path else result_path[-1]
|
1139
|
+
else:
|
1140
|
+
# Show temp warning if anchor not found
|
1141
|
+
anchor_str = "/".join(anchor_path)
|
1142
|
+
state.set_message(
|
1143
|
+
f"Link id /{anchor_str} not found",
|
1144
|
+
"yellow",
|
1145
|
+
temporary=True,
|
1146
|
+
timeout=3.0,
|
1147
|
+
)
|
1148
|
+
# Remain at root
|
1149
|
+
|
1150
|
+
# Initialize pre-search position to the root
|
1151
|
+
# This ensures ESC after search will return to root even on first launch
|
1152
|
+
state.pre_search_path = []
|
1153
|
+
state.pre_search_index = 0
|
1154
|
+
|
1155
|
+
# Initialize UI
|
1156
|
+
# ui = QuickModeUI(state) # Removed unused variable
|
1157
|
+
|
1158
|
+
# Set up autosave thread
|
1159
|
+
autosave_thread = threading.Thread(
|
1160
|
+
target=auto_save, args=(state, file), daemon=True
|
1161
|
+
)
|
1162
|
+
autosave_thread.start()
|
1163
|
+
|
1164
|
+
# Set up signal handler for clean exit
|
1165
|
+
def handle_sigint(sig: int, frame: Any) -> None:
|
1166
|
+
state.should_quit = True
|
1167
|
+
return
|
1168
|
+
|
1169
|
+
signal.signal(signal.SIGINT, handle_sigint)
|
1170
|
+
|
1171
|
+
# Main UI loop - minimal UI approach
|
1172
|
+
should_quit = False
|
1173
|
+
key = "" # Initialize key variable
|
1174
|
+
while not should_quit and not state.should_quit:
|
1175
|
+
try:
|
1176
|
+
# Save session state on every navigation loop
|
1177
|
+
save_session(file.name, state.path)
|
1178
|
+
# Check for expired temporary messages
|
1179
|
+
state.check_expired_message()
|
1180
|
+
|
1181
|
+
# Clear screen and display current state
|
1182
|
+
print(COLOR_PRINTS_CLEAR, end="") # ANSI escape to clear screen
|
1183
|
+
|
1184
|
+
# Print only minimal info - no debugging clutter
|
1185
|
+
# Intentionally left blank to make UI super minimal
|
1186
|
+
|
1187
|
+
# Display breadcrumbs
|
1188
|
+
if not state.path:
|
1189
|
+
# When at the root, show just the root symbol
|
1190
|
+
print("\033[36m<no parents>\033[0m")
|
1191
|
+
else:
|
1192
|
+
# Build the path starting with the root node's label
|
1193
|
+
current_path = [state.mindmap.label] # Start with root node's label
|
1194
|
+
node = state.mindmap
|
1195
|
+
for i, idx in enumerate(state.path):
|
1196
|
+
if idx < len(node.children):
|
1197
|
+
node = node.children[idx]
|
1198
|
+
current_path.append(node.label)
|
1199
|
+
|
1200
|
+
# Format the breadcrumb path with the root node's text
|
1201
|
+
# Remove the current node from the breadcrumb (last element)
|
1202
|
+
|
1203
|
+
# Truncate long node names
|
1204
|
+
def truncate_node_name(name: str) -> str:
|
1205
|
+
if len(name) > MAX_BREADCRUMB_NODE_VIZ_LENGTH:
|
1206
|
+
return name[: MAX_BREADCRUMB_NODE_VIZ_LENGTH - 3] + "..."
|
1207
|
+
return name
|
1208
|
+
|
1209
|
+
# Truncate root node name if needed
|
1210
|
+
root_name = truncate_node_name(current_path[0])
|
1211
|
+
breadcrumb_path = f"{SYMBOL_BREADCRUMBNODE_OPENWRAP}{root_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}" # Root node's label
|
1212
|
+
|
1213
|
+
if len(current_path) > 3: # Root + more than 2 levels deep
|
1214
|
+
# Truncate the first level node name if needed
|
1215
|
+
first_level_name = truncate_node_name(current_path[1])
|
1216
|
+
breadcrumb_path += f" > {SYMBOL_BREADCRUMBNODE_OPENWRAP}{first_level_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}"
|
1217
|
+
if len(current_path) > 4: # Root + more than 3 levels deep
|
1218
|
+
# Add '... >' for each hidden level
|
1219
|
+
hidden_levels = (
|
1220
|
+
len(current_path) - 4
|
1221
|
+
) # -4 because we show root, first, parent and current
|
1222
|
+
|
1223
|
+
# Truncate long node names
|
1224
|
+
def truncate_node_name(name: str) -> str:
|
1225
|
+
if len(name) > MAX_BREADCRUMB_NODE_VIZ_LENGTH:
|
1226
|
+
return (
|
1227
|
+
name[: MAX_BREADCRUMB_NODE_VIZ_LENGTH - 3]
|
1228
|
+
+ "..."
|
1229
|
+
)
|
1230
|
+
return name
|
1231
|
+
|
1232
|
+
# First add the > after the first visible node
|
1233
|
+
breadcrumb_path += " >"
|
1234
|
+
|
1235
|
+
ellipsis_arrows = ""
|
1236
|
+
for _ in range(hidden_levels):
|
1237
|
+
ellipsis_arrows += " ... >"
|
1238
|
+
|
1239
|
+
# Truncate the parent node name if needed
|
1240
|
+
parent_name = truncate_node_name(current_path[-2])
|
1241
|
+
breadcrumb_path += f"{ellipsis_arrows} {SYMBOL_BREADCRUMBNODE_OPENWRAP}{parent_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}"
|
1242
|
+
else:
|
1243
|
+
# Truncate parent node name if needed
|
1244
|
+
parent_name = truncate_node_name(current_path[-2])
|
1245
|
+
breadcrumb_path += f" > {SYMBOL_BREADCRUMBNODE_OPENWRAP}{parent_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}"
|
1246
|
+
elif len(current_path) > 2: # Root + 1 child level
|
1247
|
+
# Truncate the first level node name if needed
|
1248
|
+
first_level_name = truncate_node_name(current_path[1])
|
1249
|
+
breadcrumb_path += f" > {SYMBOL_BREADCRUMBNODE_OPENWRAP}{first_level_name}{SYMBOL_BREADCRUMBNODE_CLOSEWRAP}"
|
1250
|
+
|
1251
|
+
print(
|
1252
|
+
f"{COLOR_PRINTS_BREADCRUMB_BAR_BG}{COLOR_PRINTS_BREADCRUMB_BAR_FG}{breadcrumb_path}{COLOR_PRINTS_RESET}"
|
1253
|
+
) # Breadcrumb bar color
|
1254
|
+
|
1255
|
+
# Display current node
|
1256
|
+
if state.editing:
|
1257
|
+
# Use the same bullet regardless of position when editing
|
1258
|
+
print(
|
1259
|
+
f"● {COLOR_PRINTS_TEXT_BG}{COLOR_PRINTS_TEXT_FG}{state.input_buffer}|{COLOR_PRINTS_RESET}"
|
1260
|
+
) # Main text with cursor
|
1261
|
+
else:
|
1262
|
+
# Use root symbol when at root, regular bullet otherwise
|
1263
|
+
bullet = SYMBOL_ROOT if not state.path else SYMBOL_BULLET
|
1264
|
+
print(
|
1265
|
+
f"{bullet} {COLOR_PRINTS_TEXT_BG}{COLOR_PRINTS_TEXT_FG}{state.current_node.label}{COLOR_PRINTS_RESET}"
|
1266
|
+
) # Main text
|
1267
|
+
|
1268
|
+
# Display children if any
|
1269
|
+
children = state.current_node.children
|
1270
|
+
if children:
|
1271
|
+
child_text = "└─ "
|
1272
|
+
total_children = len(children)
|
1273
|
+
selected_idx = state.selected_index
|
1274
|
+
|
1275
|
+
# Show pagination if needed
|
1276
|
+
window_size = min(MAX_VISIBLE_CHILDREN, total_children)
|
1277
|
+
window_half = window_size // 2
|
1278
|
+
window_start = max(
|
1279
|
+
0, min(selected_idx - window_half, total_children - window_size)
|
1280
|
+
)
|
1281
|
+
window_end = min(window_start + window_size, total_children)
|
1282
|
+
|
1283
|
+
if window_start > 0:
|
1284
|
+
child_text += "< "
|
1285
|
+
|
1286
|
+
# Helper function to truncate long child names
|
1287
|
+
def truncate_child_name(name: str) -> str:
|
1288
|
+
if len(name) > MAX_CHILDNODE_VIZ_LENGTH:
|
1289
|
+
return name[: MAX_CHILDNODE_VIZ_LENGTH - 3] + "..."
|
1290
|
+
return name
|
1291
|
+
|
1292
|
+
for i in range(window_start, window_end):
|
1293
|
+
# Truncate child name if needed
|
1294
|
+
child_name = truncate_child_name(children[i].label)
|
1295
|
+
if i == selected_idx:
|
1296
|
+
# Selected child: use highlight FG/BG
|
1297
|
+
child_text += f"{COLOR_PRINTS_CHILD_HIGHLIGHT_BG}{COLOR_PRINTS_CHILD_HIGHLIGHT_FG}{SYMBOL_CHILDNODE_OPENWRAP}{child_name}{SYMBOL_CHILDNODE_CLOSEWRAP}{COLOR_PRINTS_RESET} "
|
1298
|
+
else:
|
1299
|
+
child_text += f"{SYMBOL_CHILDNODE_OPENWRAP}{child_name}{SYMBOL_CHILDNODE_CLOSEWRAP} "
|
1300
|
+
|
1301
|
+
if window_end < total_children:
|
1302
|
+
child_text += "> "
|
1303
|
+
|
1304
|
+
if total_children > window_size:
|
1305
|
+
child_text += f"({selected_idx + 1}/{total_children})"
|
1306
|
+
|
1307
|
+
print(child_text)
|
1308
|
+
else:
|
1309
|
+
print("└─ No children (press Tab to add one)")
|
1310
|
+
|
1311
|
+
# Display status bar
|
1312
|
+
status_text = ""
|
1313
|
+
|
1314
|
+
# Always prioritize displaying message from state if available, regardless of mode
|
1315
|
+
if state.message[0]:
|
1316
|
+
# Use the message color if provided, otherwise use default
|
1317
|
+
color_code = f"{COLOR_PRINTS_STATUS_BAR_BG}{COLOR_PRINTS_STATUS_BAR_FG}" # Default to warning/yellow
|
1318
|
+
if state.message[1] == "green":
|
1319
|
+
color_code = (
|
1320
|
+
f"{COLOR_PRINTS_SUCCESS_BG}{COLOR_PRINTS_SUCCESS_FG}"
|
1321
|
+
)
|
1322
|
+
elif state.message[1] == "red":
|
1323
|
+
color_code = f"{COLOR_PRINTS_ERROR_BG}{COLOR_PRINTS_ERROR_FG}"
|
1324
|
+
|
1325
|
+
status_text = f"{color_code}{state.message[0]}{COLOR_PRINTS_RESET}"
|
1326
|
+
# If no message, show appropriate status based on mode
|
1327
|
+
elif state.delete_confirm:
|
1328
|
+
status_text = f"{COLOR_PRINTS_WARNING_BG}{COLOR_PRINTS_WARNING_FG}Delete? (y/n){COLOR_PRINTS_RESET}"
|
1329
|
+
elif state.editing:
|
1330
|
+
# Use more verbose format that's easier to understand
|
1331
|
+
status_text = f"{COLOR_PRINTS_SUCCESS_BG}{COLOR_PRINTS_SUCCESS_FG}Editing: Enter to save, Esc to cancel{COLOR_PRINTS_RESET}"
|
1332
|
+
elif state.search_mode:
|
1333
|
+
# Display search info with match count if we have results
|
1334
|
+
if state.search_results:
|
1335
|
+
current_pos = state.search_result_index + 1
|
1336
|
+
total = len(state.search_results)
|
1337
|
+
status_text = f"{COLOR_PRINTS_ACCENT_BG}{COLOR_PRINTS_ACCENT_FG}Search: {state.search_term}| ({current_pos}/{total}) \u2190\u2192:navigate Enter:select ESC:exit{COLOR_PRINTS_RESET}"
|
1338
|
+
else:
|
1339
|
+
status_text = f"{COLOR_PRINTS_ACCENT_BG}{COLOR_PRINTS_ACCENT_FG}Search: {state.search_term}| Enter:search ESC:cancel{COLOR_PRINTS_RESET}"
|
1340
|
+
else:
|
1341
|
+
status_text = f"{COLOR_PRINTS_STATUS_BAR_BG}{COLOR_PRINTS_STATUS_BAR_FG}^C/^D:quit ↑↓←→:nav tab:add-child enter:add-sibling del:delete /=search{COLOR_PRINTS_RESET}"
|
1342
|
+
print("\n" + status_text)
|
1343
|
+
|
1344
|
+
# Get key with timeout to allow message expiration
|
1345
|
+
key = get_key(timeout=0.5) # Shorter timeout for more responsive UI
|
1346
|
+
|
1347
|
+
# If no key was pressed (timeout), check for expired messages and continue the loop
|
1348
|
+
if key == "":
|
1349
|
+
state.check_expired_message()
|
1350
|
+
continue
|
1351
|
+
|
1352
|
+
# Debug disabled
|
1353
|
+
# if key in [KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT]:
|
1354
|
+
# print(f"\nDEBUG: Arrow key pressed: {repr(key)}")
|
1355
|
+
# time.sleep(0.2) # Less delay for better responsiveness
|
1356
|
+
|
1357
|
+
# Process input based on current state
|
1358
|
+
if key in (KEY_CTRL_C, KEY_CTRL_D): # Ctrl+C or Ctrl+D
|
1359
|
+
should_quit = True
|
1360
|
+
state.should_quit = True
|
1361
|
+
elif state.delete_confirm:
|
1362
|
+
# Delete confirmation mode
|
1363
|
+
if key.lower() == "y":
|
1364
|
+
state.delete_node()
|
1365
|
+
state.delete_confirm = False
|
1366
|
+
elif key.lower() == "n" or key == KEY_ESC: # n or Escape
|
1367
|
+
state.delete_confirm = False
|
1368
|
+
elif state.editing:
|
1369
|
+
# Editing mode
|
1370
|
+
if key == KEY_ENTER: # Enter
|
1371
|
+
state.save_edit()
|
1372
|
+
elif key == KEY_ESC: # Escape
|
1373
|
+
state.cancel_edit()
|
1374
|
+
elif key == KEY_BACKSPACE: # Backspace
|
1375
|
+
state.input_buffer = state.input_buffer[:-1]
|
1376
|
+
else:
|
1377
|
+
# Add character to input buffer if printable
|
1378
|
+
if len(key) == 1 and key.isprintable():
|
1379
|
+
state.input_buffer += key
|
1380
|
+
elif state.search_mode:
|
1381
|
+
# Search mode
|
1382
|
+
if key == KEY_ENTER: # Enter - execute search or select result
|
1383
|
+
if state.search_result_mode:
|
1384
|
+
# We're browsing results, so select the current one
|
1385
|
+
state.select_search_result()
|
1386
|
+
elif state.search_term:
|
1387
|
+
# We're typing a search, so execute it
|
1388
|
+
state.perform_search()
|
1389
|
+
else:
|
1390
|
+
# Empty search, just cancel
|
1391
|
+
state.cancel_search()
|
1392
|
+
elif key == KEY_ESC: # Escape - cancel search
|
1393
|
+
state.cancel_search()
|
1394
|
+
elif (
|
1395
|
+
key == KEY_BACKSPACE and not state.search_result_mode
|
1396
|
+
): # Backspace - delete last character (only if not in result mode)
|
1397
|
+
if state.search_term:
|
1398
|
+
state.search_term = state.search_term[:-1]
|
1399
|
+
elif key == KEY_LEFT: # Left arrow - previous result
|
1400
|
+
if state.search_results:
|
1401
|
+
state.prev_search_result()
|
1402
|
+
elif key == KEY_RIGHT: # Right arrow - next result
|
1403
|
+
if state.search_results:
|
1404
|
+
state.next_search_result()
|
1405
|
+
elif (
|
1406
|
+
len(key) == 1
|
1407
|
+
and key.isprintable()
|
1408
|
+
and not state.search_result_mode
|
1409
|
+
): # Only add characters if not in result mode
|
1410
|
+
# Add character to search term if printable
|
1411
|
+
state.search_term += key
|
1412
|
+
else:
|
1413
|
+
# Navigation mode with correctly named methods
|
1414
|
+
if key in (KEY_SLASH, KEY_CTRL_F): # Slash or Ctrl+F - start search
|
1415
|
+
state.start_search()
|
1416
|
+
elif key == KEY_UP: # Up arrow - navigate up to parent
|
1417
|
+
state.navigate_to_parent() # Move up the tree
|
1418
|
+
elif key == KEY_DOWN: # Down arrow - navigate down to child
|
1419
|
+
state.navigate_to_child() # Move down the tree
|
1420
|
+
elif key == KEY_LEFT: # Left arrow - previous sibling
|
1421
|
+
state.navigate_prev_sibling() # Move left horizontally
|
1422
|
+
elif key == KEY_RIGHT: # Right arrow - next sibling
|
1423
|
+
state.navigate_next_sibling() # Move right horizontally
|
1424
|
+
elif key == KEY_TAB: # Tab
|
1425
|
+
state.add_child()
|
1426
|
+
elif key == KEY_ENTER: # Enter
|
1427
|
+
if state.path: # Not at root
|
1428
|
+
state.start_editing()
|
1429
|
+
state.input_buffer = ""
|
1430
|
+
state.add_sibling()
|
1431
|
+
elif key == KEY_BACKSPACE: # Delete
|
1432
|
+
state.delete_node()
|
1433
|
+
elif key == KEY_SLASH: # Search
|
1434
|
+
state.search_mode = True
|
1435
|
+
state.search_term = ""
|
1436
|
+
elif key in (
|
1437
|
+
KEY_CTRL_I,
|
1438
|
+
KEY_INSERT,
|
1439
|
+
KEY_CTRL_E,
|
1440
|
+
): # Insert key - edit current label in-place
|
1441
|
+
state.start_inserting() # input_buffer is set to current label by start_editing
|
1442
|
+
elif len(key) == 1 and key.isprintable():
|
1443
|
+
# Start editing and add the pressed key
|
1444
|
+
state.start_editing()
|
1445
|
+
state.input_buffer = key
|
1446
|
+
except Exception as e:
|
1447
|
+
print(f"\n\033[31mError: {e}\033[0m")
|
1448
|
+
print("Press any key to continue or Ctrl+C to quit...")
|
1449
|
+
try:
|
1450
|
+
err_key = get_key()
|
1451
|
+
if err_key in (KEY_CTRL_C, KEY_CTRL_D): # Ctrl+C or Ctrl+D
|
1452
|
+
should_quit = True
|
1453
|
+
state.should_quit = True
|
1454
|
+
except Exception:
|
1455
|
+
should_quit = True
|
1456
|
+
state.should_quit = True
|
1457
|
+
|
1458
|
+
# Final save if needed
|
1459
|
+
if state.dirty:
|
1460
|
+
file.seek(0)
|
1461
|
+
file.truncate()
|
1462
|
+
file.write(mindmap_to_markdown(state.mindmap))
|
1463
|
+
# Always save session on quit
|
1464
|
+
save_session(file.name, state.path)
|
1465
|
+
return 0
|
1466
|
+
except Exception as e:
|
1467
|
+
print(f"Error in quick mode: {e}", file=sys.stderr)
|
1468
|
+
import traceback
|
1469
|
+
|
1470
|
+
traceback.print_exc()
|
1471
|
+
return 1
|