Files
contact/contact/ui/nav_utils.py
T
2025-06-10 17:42:14 -05:00

427 lines
15 KiB
Python

import curses
import re
from unicodedata import east_asian_width
from contact.ui.colors import get_color
from contact.utilities.control_utils import transform_menu_path
from typing import Any, Optional, List, Dict
from contact.utilities.singleton import interface_state, ui_state
def get_node_color(node_index: int, reverse: bool = False):
node_num = ui_state.node_list[node_index]
node = interface_state.interface.nodesByNum.get(node_num, {})
if node.get("isFavorite"):
return get_color("node_favorite", reverse=reverse)
elif node.get("isIgnored"):
return get_color("node_ignored", reverse=reverse)
return get_color("settings_default", reverse=reverse)
# Aliases
Segment = tuple[str, str, bool, bool]
WrappedLine = List[Segment]
width = 80
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
save_option = "Save Changes"
def move_highlight(
old_idx: int, options: List[str], menu_win: curses.window, menu_pad: curses.window, **kwargs: Any
) -> None:
show_save_option = None
start_index = [0]
help_text = None
max_help_lines = 0
help_win = None
if "help_win" in kwargs:
help_win = kwargs["help_win"]
if "menu_state" in kwargs:
new_idx = kwargs["menu_state"].selected_index
show_save_option = kwargs["menu_state"].show_save_option
start_index = kwargs["menu_state"].start_index
transformed_path = transform_menu_path(kwargs["menu_state"].menu_path)
else:
new_idx = kwargs["selected_index"]
transformed_path = []
if "help_text" in kwargs:
help_text = kwargs["help_text"]
if "max_help_lines" in kwargs:
max_help_lines = kwargs["max_help_lines"]
if old_idx == new_idx: # No-op
return
max_index = len(options) + (1 if show_save_option else 0) - 1
visible_height = menu_win.getmaxyx()[0] - 5 - (2 if show_save_option else 0)
# Adjust menu_state.start_index only when moving out of visible range
if new_idx == max_index and show_save_option:
pass
elif new_idx < start_index[-1]: # Moving above the visible area
start_index[-1] = new_idx
elif new_idx >= start_index[-1] + visible_height: # Moving below the visible area
start_index[-1] = new_idx - visible_height
# Ensure menu_state.start_index is within bounds
start_index[-1] = max(0, min(start_index[-1], max_index - visible_height + 1))
# Clear old selection
if show_save_option and old_idx == max_index:
menu_win.chgat(
menu_win.getmaxyx()[0] - 2, (width - len(save_option)) // 2, len(save_option), get_color("settings_save")
)
else:
menu_pad.chgat(
old_idx,
0,
menu_pad.getmaxyx()[1],
(
get_color("settings_sensitive")
if options[old_idx] in sensitive_settings
else get_color("settings_default")
),
)
# Highlight new selection
if show_save_option and new_idx == max_index:
menu_win.chgat(
menu_win.getmaxyx()[0] - 2,
(width - len(save_option)) // 2,
len(save_option),
get_color("settings_save", reverse=True),
)
else:
menu_pad.chgat(
new_idx,
0,
menu_pad.getmaxyx()[1],
(
get_color("settings_sensitive", reverse=True)
if options[new_idx] in sensitive_settings
else get_color("settings_default", reverse=True)
),
)
menu_win.refresh()
# Refresh pad only if scrolling is needed
menu_pad.refresh(
start_index[-1],
0,
menu_win.getbegyx()[0] + 3,
menu_win.getbegyx()[1] + 4,
menu_win.getbegyx()[0] + 3 + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 4,
)
# Update help window only if help_text is populated
selected_option = options[new_idx] if new_idx < len(options) else None
help_y = menu_win.getbegyx()[0] + menu_win.getmaxyx()[0]
if help_win:
help_win = update_help_window(
help_win,
help_text,
transformed_path,
selected_option,
max_help_lines,
width,
help_y,
menu_win.getbegyx()[1],
)
draw_arrows(menu_win, visible_height, max_index, start_index, show_save_option)
def draw_arrows(
win: object, visible_height: int, max_index: int, start_index: List[int], show_save_option: bool
) -> None:
mi = max_index - (2 if show_save_option else 0)
if visible_height < mi:
if start_index[-1] > 0:
win.addstr(3, 2, "", get_color("settings_default"))
else:
win.addstr(3, 2, " ", get_color("settings_default"))
if mi - start_index[-1] >= visible_height + (0 if show_save_option else 1):
win.addstr(visible_height + 3, 2, "", get_color("settings_default"))
else:
win.addstr(visible_height + 3, 2, " ", get_color("settings_default"))
def update_help_window(
help_win: object, # curses window or None
help_text: Dict[str, str],
transformed_path: List[str],
selected_option: Optional[str],
max_help_lines: int,
width: int,
help_y: int,
help_x: int,
) -> object: # returns a curses window
"""Handles rendering the help window consistently."""
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, width, max_help_lines)
help_height = min(len(wrapped_help) + 2, max_help_lines + 2) # +2 for border
help_height = max(help_height, 3) # Ensure at least 3 rows (1 text + border)
# Ensure help window does not exceed screen size
if help_y + help_height > curses.LINES:
help_y = curses.LINES - help_height
# Create or update the help window
if help_win is None:
help_win = curses.newwin(help_height, width, help_y, help_x)
else:
help_win.erase()
help_win.refresh()
help_win.resize(help_height, width)
help_win.mvwin(help_y, help_x)
help_win.bkgd(get_color("background"))
help_win.attrset(get_color("window_frame"))
help_win.border()
for idx, line_segments in enumerate(wrapped_help):
x_pos = 2 # Start after border
for text, color, bold, underline in line_segments:
try:
attr = get_color(color, bold=bold, underline=underline)
help_win.addstr(1 + idx, x_pos, text, attr)
x_pos += len(text)
except curses.error:
pass # Prevent crashes
help_win.refresh()
return help_win
def get_wrapped_help_text(
help_text: Dict[str, str], transformed_path: List[str], selected_option: Optional[str], width: int, max_lines: int
) -> List[WrappedLine]:
"""Fetches and formats help text for display, ensuring it fits within the allowed lines."""
full_help_key = ".".join(transformed_path + [selected_option]) if selected_option else None
help_content = help_text.get(full_help_key, "No help available.")
wrap_width = max(width - 6, 10) # Ensure a valid wrapping width
# Color replacements
color_mappings = {
r"\[warning\](.*?)\[/warning\]": ("settings_warning", True, False), # Red for warnings
r"\[note\](.*?)\[/note\]": ("settings_note", True, False), # Green for notes
r"\[underline\](.*?)\[/underline\]": ("settings_default", False, True), # Underline
r"\\033\[31m(.*?)\\033\[0m": ("settings_warning", True, False), # Red text
r"\\033\[32m(.*?)\\033\[0m": ("settings_note", True, False), # Green text
r"\\033\[4m(.*?)\\033\[0m": ("settings_default", False, True), # Underline
}
def extract_ansi_segments(text: str) -> List[Segment]:
"""Extracts and replaces ANSI color codes, ensuring spaces are preserved."""
matches = []
last_pos = 0
pattern_matches = []
# Find all matches and store their positions
for pattern, (color, bold, underline) in color_mappings.items():
for match in re.finditer(pattern, text):
pattern_matches.append((match.start(), match.end(), match.group(1), color, bold, underline))
# Sort matches by start position to process sequentially
pattern_matches.sort(key=lambda x: x[0])
for start, end, content, color, bold, underline in pattern_matches:
# Preserve non-matching text including spaces
if last_pos < start:
segment = text[last_pos:start]
matches.append((segment, "settings_default", False, False))
# Append the colored segment
matches.append((content, color, bold, underline))
last_pos = end
# Preserve any trailing text
if last_pos < len(text):
matches.append((text[last_pos:], "settings_default", False, False))
return matches
def wrap_ansi_text(segments: List[Segment], wrap_width: int) -> List[WrappedLine]:
"""Wraps text while preserving ANSI formatting and spaces."""
wrapped_lines = []
line_buffer = []
line_length = 0
for text, color, bold, underline in segments:
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately
for word in words:
word_length = len(word)
if line_length + word_length > wrap_width and word.strip():
# If the word (ignoring spaces) exceeds width, wrap the line
wrapped_lines.append(line_buffer)
line_buffer = []
line_length = 0
line_buffer.append((word, color, bold, underline))
line_length += word_length
if line_buffer:
wrapped_lines.append(line_buffer)
return wrapped_lines
raw_lines = help_content.split("\\n") # Preserve new lines
wrapped_help = []
for raw_line in raw_lines:
color_segments = extract_ansi_segments(raw_line)
wrapped_segments = wrap_ansi_text(color_segments, wrap_width)
wrapped_help.extend(wrapped_segments)
pass
# Trim and add ellipsis if needed
if len(wrapped_help) > max_lines:
wrapped_help = wrapped_help[:max_lines]
wrapped_help[-1].append(("...", "settings_default", False, False))
return wrapped_help
def text_width(text: str) -> int:
return sum(2 if east_asian_width(c) in "FW" else 1 for c in text)
def wrap_text(text: str, wrap_width: int) -> List[str]:
"""Wraps text while preserving spaces and breaking long words."""
whitespace = '\t\n\x0b\x0c\r '
whitespace_trans = dict.fromkeys(map(ord, whitespace), ord(' '))
text = text.translate(whitespace_trans)
words = re.findall(r"\S+|\s+", text) # Capture words and spaces separately
wrapped_lines = []
line_buffer = ""
line_length = 0
margin = 2 # Left and right margin
wrap_width -= margin
for word in words:
word_length = text_width(word)
if word_length > wrap_width: # Break long words
if line_buffer:
wrapped_lines.append(line_buffer.strip())
line_buffer = ""
line_length = 0
for i in range(0, word_length, wrap_width):
wrapped_lines.append(word[i : i + wrap_width])
continue
if line_length + word_length > wrap_width and word.strip():
wrapped_lines.append(line_buffer.strip())
line_buffer = ""
line_length = 0
line_buffer += word
line_length += word_length
if line_buffer:
wrapped_lines.append(line_buffer.strip())
return wrapped_lines
def move_main_highlight(
old_idx: int, new_idx, options: List[str], menu_win: curses.window, menu_pad: curses.window, ui_state: object
) -> None:
if old_idx == new_idx: # No-op
return
max_index = len(options) - 1
visible_height = menu_win.getmaxyx()[0] - 2
if new_idx < ui_state.start_index[ui_state.current_window]: # Moving above the visible area
ui_state.start_index[ui_state.current_window] = new_idx
elif new_idx >= ui_state.start_index[ui_state.current_window] + visible_height: # Moving below the visible area
ui_state.start_index[ui_state.current_window] = new_idx - visible_height + 1
# Ensure start_index is within bounds
ui_state.start_index[ui_state.current_window] = max(
0, min(ui_state.start_index[ui_state.current_window], max_index - visible_height + 1)
)
highlight_line(menu_win, menu_pad, old_idx, new_idx, visible_height)
if ui_state.current_window == 0: # hack to fix max_index
max_index += 1
draw_main_arrows(menu_win, max_index, window=ui_state.current_window)
menu_win.refresh()
def highlight_line(
menu_win: curses.window, menu_pad: curses.window, old_idx: int, new_idx: int, visible_height: int
) -> None:
if ui_state.current_window == 0:
color_old = (
get_color("channel_selected") if old_idx == ui_state.selected_channel else get_color("channel_list")
)
color_new = get_color("channel_list", reverse=True) if True else get_color("channel_list", reverse=True)
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, color_old)
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, color_new)
elif ui_state.current_window == 2:
menu_pad.chgat(old_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(old_idx))
menu_pad.chgat(new_idx, 1, menu_pad.getmaxyx()[1] - 4, get_node_color(new_idx, reverse=True))
menu_win.refresh()
# Refresh pad only if scrolling is needed
menu_pad.refresh(
ui_state.start_index[ui_state.current_window],
0,
menu_win.getbegyx()[0] + 1,
menu_win.getbegyx()[1] + 1,
menu_win.getbegyx()[0] + visible_height,
menu_win.getbegyx()[1] + menu_win.getmaxyx()[1] - 3,
)
def draw_main_arrows(win: object, max_index: int, window: int, **kwargs) -> None:
height, width = win.getmaxyx()
usable_height = height - 2
usable_width = width - 2
if window == 1 and ui_state.display_log:
if log_height := kwargs.get("log_height"):
usable_height -= log_height - 1
if usable_height < max_index:
if ui_state.start_index[window] > 0:
win.addstr(1, usable_width, "", get_color("settings_default"))
else:
win.addstr(1, usable_width, " ", get_color("settings_default"))
if max_index - ui_state.start_index[window] - 1 >= usable_height:
win.addstr(usable_height, usable_width, "", get_color("settings_default"))
else:
win.addstr(usable_height, usable_width, " ", get_color("settings_default"))
else:
win.addstr(1, usable_width, " ", get_color("settings_default"))
win.addstr(usable_height, usable_width, " ", get_color("settings_default"))
def get_msg_window_lines(messages_win, packetlog_win) -> None:
packetlog_height = packetlog_win.getmaxyx()[0] - 1 if ui_state.display_log else 0
return messages_win.getmaxyx()[0] - 2 - packetlog_height