allow smaller windows

This commit is contained in:
pdxlocations
2025-08-22 12:17:34 -07:00
parent d939561e2d
commit 27b0ca7482
4 changed files with 123 additions and 67 deletions
+23 -14
View File
@@ -24,12 +24,21 @@ from contact.ui.user_config import json_editor
from contact.utilities.singleton import menu_state
# Setup Variables
width = 80
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
save_option = "Save Changes"
max_help_lines = 0
help_win = None
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
# Compute the effective menu width for the current terminal
def get_menu_width() -> int:
# Leave at least 2 columns for borders; clamp to >= 20 for usability
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
# Get the parent directory of the script
script_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.abspath(os.path.join(script_dir, os.pardir))
@@ -45,35 +54,35 @@ config_folder = os.path.abspath(config.node_configs_file_path)
field_mapping, help_text = parse_ini_file(translation_file)
def display_menu() -> tuple[object, object]: # curses.window or pad types
def display_menu() -> tuple[object, object]:
min_help_window_height = 6
num_items = len(menu_state.current_menu) + (1 if menu_state.show_save_option else 0)
# Determine the available height for the menu
max_menu_height = curses.LINES
menu_height = min(max_menu_height - min_help_window_height, num_items + 5)
w = get_menu_width()
start_y = (curses.LINES - menu_height) // 2 - (min_help_window_height // 2)
start_x = (curses.COLS - width) // 2
start_x = (curses.COLS - w) // 2
# Calculate remaining space for help window
global max_help_lines
remaining_space = curses.LINES - (start_y + menu_height + 2) # +2 for padding
max_help_lines = max(remaining_space, 1) # Ensure at least 1 lines for help
menu_win = curses.newwin(menu_height, width, start_y, start_x)
menu_win = curses.newwin(menu_height, w, start_y, start_x)
menu_win.erase()
menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame"))
menu_win.border()
menu_win.keypad(True)
menu_pad = curses.newpad(len(menu_state.current_menu) + 1, width - 8)
menu_pad = curses.newpad(len(menu_state.current_menu) + 1, w - 8)
menu_pad.bkgd(get_color("background"))
header = " > ".join(word.title() for word in menu_state.menu_path)
if len(header) > width - 4:
header = header[: width - 7] + "..."
if len(header) > w - 4:
header = header[: w - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
transformed_path = transform_menu_path(menu_state.menu_path)
@@ -84,15 +93,15 @@ def display_menu() -> tuple[object, object]: # curses.window or pad types
full_key = ".".join(transformed_path + [option])
display_name = field_mapping.get(full_key, option)
display_option = f"{display_name}"[: width // 2 - 2]
display_value = f"{current_value}"[: width // 2 - 4]
display_option = f"{display_name}"[: w // 2 - 2]
display_value = f"{current_value}"[: w // 2 - 4]
try:
color = get_color(
"settings_sensitive" if option in sensitive_settings else "settings_default",
reverse=(idx == menu_state.selected_index),
)
menu_pad.addstr(idx, 0, f"{display_option:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
menu_pad.addstr(idx, 0, f"{display_option:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
except curses.error:
pass
@@ -100,7 +109,7 @@ def display_menu() -> tuple[object, object]: # curses.window or pad types
save_position = menu_height - 2
menu_win.addstr(
save_position,
(width - len(save_option)) // 2,
(w - len(save_option)) // 2,
save_option,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
@@ -134,7 +143,6 @@ def draw_help_window(
max_help_lines: int,
transformed_path: List[str],
) -> None:
global help_win
if "help_win" not in globals():
@@ -145,8 +153,9 @@ def draw_help_window(
)
help_y = menu_start_y + menu_height
# Use current terminal width for the help window width calculation
help_win = update_help_window(
help_win, help_text, transformed_path, selected_option, max_help_lines, width, help_y, menu_start_x
help_win, help_text, transformed_path, selected_option, max_help_lines, get_menu_width(), help_y, menu_start_x
)
+36 -15
View File
@@ -22,7 +22,6 @@ def get_node_color(node_index: int, reverse: bool = False):
Segment = tuple[str, str, bool, bool]
WrappedLine = List[Segment]
width = 80
sensitive_settings = ["Reboot", "Reset Node DB", "Shutdown", "Factory Reset"]
save_option = "Save Changes"
@@ -73,9 +72,8 @@ def move_highlight(
# 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")
)
win_h, win_w = menu_win.getmaxyx()
menu_win.chgat(win_h - 2, (win_w - len(save_option)) // 2, len(save_option), get_color("settings_save"))
else:
menu_pad.chgat(
old_idx,
@@ -90,9 +88,10 @@ def move_highlight(
# Highlight new selection
if show_save_option and new_idx == max_index:
win_h, win_w = menu_win.getmaxyx()
menu_win.chgat(
menu_win.getmaxyx()[0] - 2,
(width - len(save_option)) // 2,
win_h - 2,
(win_w - len(save_option)) // 2,
len(save_option),
get_color("settings_save", reverse=True),
)
@@ -124,13 +123,14 @@ def move_highlight(
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:
win_h, win_w = menu_win.getmaxyx()
help_win = update_help_window(
help_win,
help_text,
transformed_path,
selected_option,
max_help_lines,
width,
win_w,
help_y,
menu_win.getbegyx()[1],
)
@@ -167,23 +167,42 @@ def update_help_window(
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)
# Clamp target position and width to the current terminal size
help_x = max(0, help_x)
help_y = max(0, help_y)
# Ensure requested width fits on screen from help_x
max_w_from_x = max(1, curses.COLS - help_x)
safe_width = min(width, max_w_from_x)
# Always leave a minimal border area; enforce a minimum usable width of 3
safe_width = max(3, safe_width)
wrapped_help = get_wrapped_help_text(help_text, transformed_path, selected_option, safe_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
# Re-clamp Y to keep the window visible
if help_y + help_height > curses.LINES:
help_y = curses.LINES - help_height
help_y = max(0, curses.LINES - help_height)
# If width would overflow the screen, shrink it
if help_x + safe_width > curses.COLS:
safe_width = max(3, curses.COLS - help_x)
# Create or update the help window
if help_win is None:
help_win = curses.newwin(help_height, width, help_y, help_x)
help_win = curses.newwin(help_height, safe_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.resize(help_height, safe_width)
try:
help_win.mvwin(help_y, help_x)
except curses.error:
# If moving fails due to edge conditions, pin to (0,0) as a fallback
help_y = 0
help_x = 0
help_win.mvwin(help_y, help_x)
help_win.bkgd(get_color("background"))
help_win.attrset(get_color("window_frame"))
@@ -295,14 +314,16 @@ def get_wrapped_help_text(
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(' '))
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
+23 -15
View File
@@ -10,11 +10,17 @@ from contact.utilities.input_handlers import get_list_input
from contact.utilities.singleton import menu_state
width = 80
MAX_MENU_WIDTH = 80 # desired max; will shrink on small terminals
max_help_lines = 6
save_option = "Save Changes"
# Compute an effective width that fits the current terminal
def get_effective_width() -> int:
# Leave space for borders; ensure a sane minimum
return max(20, min(MAX_MENU_WIDTH, curses.COLS - 2))
def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
"""
Allows the user to select a foreground and background color for a key.
@@ -28,13 +34,14 @@ def edit_color_pair(key: str, current_value: List[str]) -> List[str]:
def edit_value(key: str, current_value: str) -> str:
w = get_effective_width()
height = 10
input_width = width - 16 # Allow space for "New Value: "
input_width = w - 16 # Allow space for "New Value: "
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
start_x = max(0, (curses.COLS - w) // 2)
# Create a centered window
edit_win = curses.newwin(height, width, start_y, start_x)
edit_win = curses.newwin(height, w, start_y, start_x)
edit_win.bkgd(get_color("background"))
edit_win.attrset(get_color("window_frame"))
edit_win.border()
@@ -43,7 +50,7 @@ def edit_value(key: str, current_value: str) -> str:
edit_win.addstr(1, 2, f"Editing {key}", get_color("settings_default", bold=True))
edit_win.addstr(3, 2, "Current Value:", get_color("settings_default"))
wrap_width = width - 4 # Account for border and padding
wrap_width = w - 4 # Account for border and padding
wrapped_lines = [current_value[i : i + wrap_width] for i in range(0, len(current_value), wrap_width)]
for i, line in enumerate(wrapped_lines[:4]): # Limit display to fit the window height
@@ -86,7 +93,7 @@ def edit_value(key: str, current_value: str) -> str:
menu_state.need_redraw = False
# Re-create the window to fully reset state
edit_win = curses.newwin(height, width, start_y, start_x)
edit_win = curses.newwin(height, w, start_y, start_x)
edit_win.timeout(200)
edit_win.bkgd(get_color("background"))
edit_win.attrset(get_color("window_frame"))
@@ -154,11 +161,12 @@ def display_menu() -> tuple[Any, Any, List[str]]:
max_menu_height = curses.LINES
menu_height = min(max_menu_height, num_items + 5)
num_items = len(options)
w = get_effective_width()
start_y = (curses.LINES - menu_height) // 2
start_x = (curses.COLS - width) // 2
start_x = max(0, (curses.COLS - w) // 2)
# Create the window
menu_win = curses.newwin(menu_height, width, start_y, start_x)
menu_win = curses.newwin(menu_height, w, start_y, start_x)
menu_win.erase()
menu_win.bkgd(get_color("background"))
menu_win.attrset(get_color("window_frame"))
@@ -166,13 +174,13 @@ def display_menu() -> tuple[Any, Any, List[str]]:
menu_win.keypad(True)
# Create the pad for scrolling
menu_pad = curses.newpad(num_items + 1, width - 8)
menu_pad = curses.newpad(num_items + 1, w - 8)
menu_pad.bkgd(get_color("background"))
# Display the menu path
header = " > ".join(menu_state.menu_path)
if len(header) > width - 4:
header = header[: width - 7] + "..."
if len(header) > w - 4:
header = header[: w - 7] + "..."
menu_win.addstr(1, 2, header, get_color("settings_breadcrumbs", bold=True))
# Populate the pad with menu options
@@ -182,18 +190,18 @@ def display_menu() -> tuple[Any, Any, List[str]]:
if isinstance(menu_state.current_menu, dict)
else menu_state.current_menu[int(key.strip("[]"))]
)
display_key = f"{key}"[: width // 2 - 2]
display_value = f"{value}"[: width // 2 - 8]
display_key = f"{key}"[: w // 2 - 2]
display_value = f"{value}"[: w // 2 - 8]
color = get_color("settings_default", reverse=(idx == menu_state.selected_index))
menu_pad.addstr(idx, 0, f"{display_key:<{width // 2 - 2}} {display_value}".ljust(width - 8), color)
menu_pad.addstr(idx, 0, f"{display_key:<{w // 2 - 2}} {display_value}".ljust(w - 8), color)
# Add Save button to the main window
if menu_state.show_save_option:
save_position = menu_height - 2
menu_win.addstr(
save_position,
(width - len(save_option)) // 2,
(w - len(save_option)) // 2,
save_option,
get_color("settings_save", reverse=(menu_state.selected_index == len(menu_state.current_menu))),
)
+41 -23
View File
@@ -4,6 +4,20 @@ import curses
import ipaddress
from typing import Any, Optional, List
# Dialogs should be at most 80 cols, but shrink on small terminals
MAX_DIALOG_WIDTH = 80
MIN_DIALOG_WIDTH = 20
def get_dialog_width() -> int:
# Leave 2 columns for borders and clamp to a sane minimum
try:
return max(MIN_DIALOG_WIDTH, min(MAX_DIALOG_WIDTH, curses.COLS - 2))
except Exception:
# Fallback if curses not ready yet
return MAX_DIALOG_WIDTH
from contact.ui.colors import get_color
from contact.ui.nav_utils import move_highlight, draw_arrows, wrap_text
from contact.ui.dialog import dialog
@@ -45,13 +59,13 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
input_win.refresh()
height = 8
width = 80
width = get_dialog_width()
margin = 2 # Left and right margin
input_width = width - (2 * margin) # Space available for text
max_input_rows = height - 4 # Space for input
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
start_y = max(0, (curses.LINES - height) // 2)
start_x = max(0, (curses.COLS - width) // 2)
input_win = curses.newwin(height, width, start_y, start_x)
input_win.timeout(200)
@@ -204,9 +218,7 @@ def get_text_input(prompt: str, selected_config: str, input_type: str) -> Option
# Clear only the input area (without touching prompt text)
for i in range(max_input_rows):
if row + 1 + i < height - 1:
input_win.addstr(
row + 1 + i, margin, " " * min(input_width, width - margin - 1), get_color("settings_default")
)
input_win.addstr(row + 1 + i, margin, " " * input_width, get_color("settings_default"))
# Redraw the prompt text so it never disappears
input_win.addstr(row + 1, margin, prompt_text, get_color("settings_default"))
@@ -244,9 +256,9 @@ def get_admin_key_input(current_value: List[bytes]) -> Optional[List[str]]:
cvalue = to_base64(current_value) # Convert current values to Base64
height = 9
width = 80
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
width = get_dialog_width()
start_y = max(0, (curses.LINES - height) // 2)
start_x = max(0, (curses.COLS - width) // 2)
admin_key_win = curses.newwin(height, width, start_y, start_x)
admin_key_win.timeout(200)
@@ -322,9 +334,9 @@ from contact.utilities.singleton import menu_state # Required if not already im
def get_repeated_input(current_value: List[str]) -> Optional[str]:
height = 9
width = 80
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
width = get_dialog_width()
start_y = max(0, (curses.LINES - height) // 2)
start_x = max(0, (curses.COLS - width) // 2)
repeated_win = curses.newwin(height, width, start_y, start_x)
repeated_win.timeout(200)
@@ -344,15 +356,17 @@ def get_repeated_input(current_value: List[str]) -> Optional[str]:
repeated_win.border()
repeated_win.addstr(1, 2, "Edit up to 3 Values:", get_color("settings_default", bold=True))
win_h, win_w = repeated_win.getmaxyx()
for i, line in enumerate(user_values):
prefix = "" if i == cursor_pos else " "
repeated_win.addstr(
3 + i, 2, f"{prefix}Value{i + 1}: ", get_color("settings_default", bold=(i == cursor_pos))
)
repeated_win.addstr(3 + i, 18, line[: width - 20]) # Prevent overflow
repeated_win.addstr(3 + i, 18, line[: max(0, win_w - 20)]) # Prevent overflow
if invalid_input:
repeated_win.addstr(7, 2, invalid_input[: width - 4], get_color("settings_default", bold=True))
win_h, win_w = repeated_win.getmaxyx()
repeated_win.addstr(7, 2, invalid_input[: max(0, win_w - 4)], get_color("settings_default", bold=True))
repeated_win.move(3 + cursor_pos, 18 + len(user_values[cursor_pos]))
repeated_win.refresh()
@@ -404,9 +418,9 @@ def get_fixed32_input(current_value: int) -> int:
original_value = current_value
ip_string = str(ipaddress.IPv4Address(current_value))
height = 10
width = 80
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
width = get_dialog_width()
start_y = max(0, (curses.LINES - height) // 2)
start_x = max(0, (curses.COLS - width) // 2)
fixed32_win = curses.newwin(height, width, start_y, start_x)
fixed32_win.bkgd(get_color("background"))
@@ -483,9 +497,9 @@ def get_list_input(
selected_index = list_options.index(current_option) if current_option in list_options else 0
height = min(len(list_options) + 5, curses.LINES)
width = 80
start_y = (curses.LINES - height) // 2
start_x = (curses.COLS - width) // 2
width = get_dialog_width()
start_y = max(0, (curses.LINES - height) // 2)
start_x = max(0, (curses.COLS - width) // 2)
list_win = curses.newwin(height, width, start_y, start_x)
list_win.timeout(200)
@@ -493,7 +507,7 @@ def get_list_input(
list_win.attrset(get_color("window_frame"))
list_win.keypad(True)
list_pad = curses.newpad(len(list_options) + 1, width - 8)
list_pad = curses.newpad(len(list_options) + 1, max(1, width - 8))
list_pad.bkgd(get_color("background"))
max_index = len(list_options) - 1
@@ -504,9 +518,11 @@ def get_list_input(
list_win.border()
list_win.addstr(1, 2, prompt, get_color("settings_default", bold=True))
win_h, win_w = list_win.getmaxyx()
pad_w = max(1, win_w - 8)
for idx, item in enumerate(list_options):
color = get_color("settings_default", reverse=(idx == selected_index))
list_pad.addstr(idx, 0, item.ljust(width - 8), color)
list_pad.addstr(idx, 0, item[:pad_w].ljust(pad_w), color)
list_win.refresh()
list_pad.refresh(
@@ -517,7 +533,9 @@ def get_list_input(
list_win.getbegyx()[0] + list_win.getmaxyx()[0] - 2,
list_win.getbegyx()[1] + list_win.getmaxyx()[1] - 4,
)
draw_arrows(list_win, visible_height, max_index, [0], show_save_option=False)
# Recompute visible height each draw in case of resize
vis_h = list_win.getmaxyx()[0] - 5
draw_arrows(list_win, vis_h, max_index, [0], show_save_option=False)
# Initial draw
redraw_list_ui()