Compare commits

...

2 Commits

Author SHA1 Message Date
pdxlocations
478f017de1 bump version 2025-05-18 14:43:47 -07:00
pdxlocations
c96c4edb01 Add Arrows to Main UI (#177)
* init

* convert globals to dataclass

* move lock to app state

* Almost working changes

* more almost working changes

* so close

* mostly working changes

* closer changes

* I think it works!

* working changes

* hack fix

* Merge branch 'main' into refactor-chat-ui

* clean-up
2025-05-18 12:28:03 -07:00
4 changed files with 176 additions and 53 deletions

View File

@@ -12,8 +12,7 @@ from contact.utilities.db_handler import get_name_from_database, update_node_inf
from contact.utilities.input_handlers import get_list_input
import contact.ui.default_config as config
import contact.ui.dialog
from contact.ui.nav_utils import move_main_highlight, draw_main_arrows, get_msg_window_lines
from contact.utilities.singleton import ui_state, interface_state
@@ -86,6 +85,7 @@ def handle_resize(stdscr: curses.window, firstrun: bool) -> None:
draw_channel_list()
draw_messages_window(True)
draw_node_list()
except:
# Resize events can come faster than we can re-draw, which can cause a curses error.
# In this case we'll see another curses.KEY_RESIZE in our key handler and draw again later.
@@ -137,7 +137,7 @@ def main_ui(stdscr: curses.window) -> None:
select_channel(len(ui_state.channel_list) - 1)
elif ui_state.current_window == 1:
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = max(msg_line_count - get_msg_window_lines(), 0)
ui_state.selected_message = max(msg_line_count - get_msg_window_lines(messages_win, packetlog_win), 0)
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(len(ui_state.node_list) - 1)
@@ -148,7 +148,9 @@ def main_ui(stdscr: curses.window) -> None:
ui_state.selected_channel - (channel_win.getmaxyx()[0] - 2)
) # select_channel will bounds check for us
elif ui_state.current_window == 1:
ui_state.selected_message = max(ui_state.selected_message - get_msg_window_lines(), 0)
ui_state.selected_message = max(
ui_state.selected_message - get_msg_window_lines(messages_win, packetlog_win), 0
)
refresh_pad(1)
elif ui_state.current_window == 2:
select_node(
@@ -163,7 +165,8 @@ def main_ui(stdscr: curses.window) -> None:
elif ui_state.current_window == 1:
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = min(
ui_state.selected_message + get_msg_window_lines(), msg_line_count - get_msg_window_lines()
ui_state.selected_message + get_msg_window_lines(messages_win, packetlog_win),
msg_line_count - get_msg_window_lines(messages_win, packetlog_win),
)
refresh_pad(1)
elif ui_state.current_window == 2:
@@ -181,7 +184,6 @@ def main_ui(stdscr: curses.window) -> None:
channel_win.attrset(get_color("window_frame"))
channel_win.box()
channel_win.refresh()
highlight_line(False, 0, ui_state.selected_channel)
refresh_pad(0)
if old_window == 1:
messages_win.attrset(get_color("window_frame"))
@@ -193,7 +195,6 @@ def main_ui(stdscr: curses.window) -> None:
nodes_win.attrset(get_color("window_frame"))
nodes_win.box()
nodes_win.refresh()
highlight_line(False, 2, ui_state.selected_node)
refresh_pad(2)
if ui_state.current_window == 0:
@@ -201,7 +202,6 @@ def main_ui(stdscr: curses.window) -> None:
channel_win.box()
channel_win.attrset(get_color("window_frame"))
channel_win.refresh()
highlight_line(True, 0, ui_state.selected_channel)
refresh_pad(0)
elif ui_state.current_window == 1:
messages_win.attrset(get_color("window_frame_selected"))
@@ -215,7 +215,6 @@ def main_ui(stdscr: curses.window) -> None:
nodes_win.box()
nodes_win.attrset(get_color("window_frame"))
nodes_win.refresh()
highlight_line(True, 2, ui_state.selected_node)
refresh_pad(2)
# Check for Esc
@@ -421,8 +420,7 @@ def main_ui(stdscr: curses.window) -> None:
def draw_channel_list() -> None:
channel_pad.erase()
win_height, win_width = channel_win.getmaxyx()
start_index = max(0, ui_state.selected_channel - (win_height - 3)) # Leave room for borders
win_width = channel_win.getmaxyx()[1]
channel_pad.resize(len(ui_state.all_messages), channel_win.getmaxyx()[1])
@@ -460,6 +458,8 @@ def draw_channel_list() -> None:
)
channel_win.box()
channel_win.attrset((get_color("window_frame")))
draw_main_arrows(channel_win, len(ui_state.channel_list), window=0)
channel_win.refresh()
refresh_pad(0)
@@ -501,10 +501,22 @@ def draw_messages_window(scroll_to_bottom: bool = False) -> None:
messages_win.attrset(get_color("window_frame"))
messages_win.refresh()
visible_lines = get_msg_window_lines(messages_win, packetlog_win)
if scroll_to_bottom:
ui_state.selected_message = max(msg_line_count - get_msg_window_lines(), 0)
ui_state.selected_message = max(msg_line_count - visible_lines, 0)
ui_state.start_index[1] = max(msg_line_count - visible_lines, 0)
pass
else:
ui_state.selected_message = max(min(ui_state.selected_message, msg_line_count - get_msg_window_lines()), 0)
ui_state.selected_message = max(min(ui_state.selected_message, msg_line_count - visible_lines), 0)
draw_main_arrows(
messages_win,
msg_line_count,
window=1,
log_height=packetlog_win.getmaxyx()[0],
)
messages_win.refresh()
refresh_pad(1)
@@ -547,6 +559,8 @@ def draw_node_list() -> None:
)
nodes_win.box()
nodes_win.attrset(get_color("window_frame"))
draw_main_arrows(nodes_win, len(ui_state.node_list), window=2)
nodes_win.refresh()
refresh_pad(2)
@@ -567,9 +581,15 @@ def select_channel(idx: int) -> None:
remove_notification(ui_state.selected_channel)
draw_channel_list()
return
highlight_line(False, 0, old_selected_channel)
highlight_line(True, 0, ui_state.selected_channel)
refresh_pad(0)
move_main_highlight(
old_idx=old_selected_channel,
new_idx=ui_state.selected_channel,
options=ui_state.channel_list,
menu_win=channel_win,
menu_pad=channel_pad,
ui_state=ui_state,
)
def scroll_channels(direction: int) -> None:
@@ -587,7 +607,30 @@ def scroll_messages(direction: int) -> None:
ui_state.selected_message += direction
msg_line_count = messages_pad.getmaxyx()[0]
ui_state.selected_message = max(0, min(ui_state.selected_message, msg_line_count - get_msg_window_lines()))
ui_state.selected_message = max(
0, min(ui_state.selected_message, msg_line_count - get_msg_window_lines(messages_win, packetlog_win))
)
max_index = msg_line_count - 1
visible_height = get_msg_window_lines(messages_win, packetlog_win)
if ui_state.selected_message < ui_state.start_index[ui_state.current_window]: # Moving above the visible area
ui_state.start_index[ui_state.current_window] = ui_state.selected_message
elif ui_state.selected_message >= ui_state.start_index[ui_state.current_window]: # Moving below the visible area
ui_state.start_index[ui_state.current_window] = ui_state.selected_message
# 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)
)
draw_main_arrows(
messages_win,
msg_line_count,
ui_state.current_window,
log_height=packetlog_win.getmaxyx()[0],
)
messages_win.refresh()
refresh_pad(1)
@@ -596,9 +639,14 @@ def select_node(idx: int) -> None:
old_selected_node = ui_state.selected_node
ui_state.selected_node = max(0, min(idx, len(ui_state.node_list) - 1))
highlight_line(False, 2, old_selected_node)
highlight_line(True, 2, ui_state.selected_node)
refresh_pad(2)
move_main_highlight(
old_idx=old_selected_node,
new_idx=ui_state.selected_node,
options=ui_state.node_list,
menu_win=nodes_win,
menu_pad=nodes_pad,
ui_state=ui_state,
)
draw_function_win()
@@ -805,11 +853,6 @@ def draw_function_win() -> None:
draw_help()
def get_msg_window_lines() -> None:
packetlog_height = packetlog_win.getmaxyx()[0] - 1 if ui_state.display_log else 0
return messages_win.getmaxyx()[0] - 2 - packetlog_height
def refresh_pad(window: int) -> None:
win_height = channel_win.getmaxyx()[0]
@@ -817,7 +860,7 @@ def refresh_pad(window: int) -> None:
if window == 1:
pad = messages_pad
box = messages_win
lines = get_msg_window_lines()
lines = get_msg_window_lines(messages_win, packetlog_win)
selected_item = ui_state.selected_message
start_index = ui_state.selected_message
@@ -845,34 +888,10 @@ def refresh_pad(window: int) -> None:
box.getbegyx()[0] + 1,
box.getbegyx()[1] + 1,
box.getbegyx()[0] + lines,
box.getbegyx()[1] + box.getmaxyx()[1] - 2,
box.getbegyx()[1] + box.getmaxyx()[1] - 3,
)
def highlight_line(highlight: bool, window: int, line: int) -> None:
pad = nodes_pad
color = get_color("node_list")
select_len = nodes_win.getmaxyx()[1] - 2
if window == 2:
node_num = ui_state.node_list[line]
node = interface_state.interface.nodesByNum[node_num]
if "isFavorite" in node and node["isFavorite"]:
color = get_color("node_favorite")
if "isIgnored" in node and node["isIgnored"]:
color = get_color("node_ignored")
if window == 0:
pad = channel_pad
color = get_color(
"channel_selected" if (line == ui_state.selected_channel and highlight == False) else "channel_list"
)
select_len = channel_win.getmaxyx()[1] - 2
pad.chgat(line, 1, select_len, color | curses.A_REVERSE if highlight else color)
def add_notification(channel_number: int) -> None:
if channel_number not in ui_state.notifications:
ui_state.notifications.append(channel_number)

View File

@@ -3,6 +3,18 @@ import re
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]
@@ -128,7 +140,6 @@ def draw_arrows(
win: object, visible_height: int, max_index: int, start_index: List[int], show_save_option: bool
) -> None:
# vh = visible_height + (1 if show_save_option else 0)
mi = max_index - (2 if show_save_option else 0)
if visible_height < mi:
@@ -316,3 +327,91 @@ def wrap_text(text: str, wrap_width: int) -> List[str]:
wrapped_lines.append(line_buffer)
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

View File

@@ -25,6 +25,11 @@ class ChatUIState:
selected_node: int = 0
current_window: int = 0
selected_index: int = 0
start_index: List[int] = field(default_factory=lambda: [0, 0, 0])
show_save_option: bool = False
menu_path: List[str] = field(default_factory=list)
@dataclass
class InterfaceState:

View File

@@ -1,6 +1,6 @@
[project]
name = "contact"
version = "1.3.8"
version = "1.3.9"
description = "This Python curses client for Meshtastic is a terminal-based client designed to manage device settings, enable mesh chat communication, and handle configuration backups and restores."
authors = [
{name = "Ben Lipsey",email = "ben@pdxlocations.com"}