diff --git a/.gitignore b/.gitignore index f672990..f39bfd4 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ programs/gui/icons/ CLAUDE.md programs/lib/bearssl ramdisk.tar -PLANS/ \ No newline at end of file +PLANS/.mcp.json diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..aee1abe --- /dev/null +++ b/.mcp.json @@ -0,0 +1,12 @@ +{ + "mcpServers": { + "montauk-workflow": { + "type": "stdio", + "command": "/home/daniel-hammer/dev/montauk-workflow-env/bin/python", + "args": [ + "/home/daniel-hammer/dev/montauk-workflow/mcp_server.py" + ], + "env": {} + } + } +} \ No newline at end of file diff --git a/programs/src/bluetooth/main.cpp b/programs/src/bluetooth/main.cpp index 2d6a9e6..16fbf6d 100644 --- a/programs/src/bluetooth/main.cpp +++ b/programs/src/bluetooth/main.cpp @@ -7,9 +7,8 @@ #include #include #include -#include +#include #include -#include extern "C" { #include @@ -22,46 +21,51 @@ using namespace gui; // Constants // ============================================================================ -static constexpr int WIN_W = 360; -static constexpr int WIN_H = 420; -static constexpr int FONT_SIZE = 18; -static constexpr int FONT_SIZE_SM = 14; +static constexpr int WIN_W = 360; +static constexpr int WIN_H = 420; +static constexpr int ROW_H = 56; +static constexpr int TAB_H = 36; +static constexpr int PAD = 16; +static constexpr int BTN_W = 90; +static constexpr int BTN_H = 28; +static constexpr int STATUS_H = 28; +static constexpr int SCAN_BTN_W = 100; +static constexpr int SCROLL_STEP = 20; -static constexpr int ROW_H = 56; -static constexpr int TAB_H = 36; -static constexpr int PAD = 16; -static constexpr int BTN_W = 90; -static constexpr int BTN_H = 28; -static constexpr int BTN_RAD = 6; -static constexpr int STATUS_H = 28; - -static constexpr int MAX_SCAN = 16; +static constexpr int MAX_SCAN = 16; static constexpr int MAX_CONNECTED = 8; -static constexpr Color BG_COLOR = Color::from_rgb(0xFF, 0xFF, 0xFF); -static constexpr Color TEXT_COLOR = Color::from_rgb(0x33, 0x33, 0x33); -static constexpr Color DIM_TEXT = Color::from_rgb(0x66, 0x66, 0x66); -static constexpr Color ACCENT = Color::from_rgb(0x36, 0x7B, 0xF0); -static constexpr Color ROW_HOVER = Color::from_rgb(0xF0, 0xF4, 0xFF); -static constexpr Color BORDER = Color::from_rgb(0xCC, 0xCC, 0xCC); -static constexpr Color WHITE = Color::from_rgb(0xFF, 0xFF, 0xFF); -static constexpr Color GREEN = Color::from_rgb(0x2E, 0xA0, 0x43); -static constexpr Color RED = Color::from_rgb(0xCC, 0x33, 0x33); -static constexpr Color TAB_BG = Color::from_rgb(0xF5, 0xF5, 0xF5); -static constexpr Color TAB_ACTIVE = Color::from_rgb(0x36, 0x7B, 0xF0); -static constexpr Color TAB_INACTIVE = Color::from_rgb(0x66, 0x66, 0x66); +static constexpr Color BG_COLOR = Color::from_rgb(0xFF, 0xFF, 0xFF); +static constexpr Color TEXT_COLOR = Color::from_rgb(0x33, 0x33, 0x33); +static constexpr Color DIM_TEXT = Color::from_rgb(0x66, 0x66, 0x66); +static constexpr Color ACCENT = Color::from_rgb(0x36, 0x7B, 0xF0); +static constexpr Color ROW_HOVER = Color::from_rgb(0xF0, 0xF4, 0xFF); +static constexpr Color BORDER = Color::from_rgb(0xCC, 0xCC, 0xCC); +static constexpr Color WHITE = Color::from_rgb(0xFF, 0xFF, 0xFF); +static constexpr Color GREEN = Color::from_rgb(0x2E, 0xA0, 0x43); +static constexpr Color RED = Color::from_rgb(0xCC, 0x33, 0x33); // ============================================================================ // State // ============================================================================ -enum Tab { TAB_DEVICES = 0, TAB_SCAN = 1 }; +enum Tab { + TAB_DEVICES = 0, + TAB_SCAN = 1, + TAB_COUNT = 2, +}; + +static const char* g_tab_labels[TAB_COUNT] = { + "Devices", + "Scan", +}; -static TrueTypeFont* g_font = nullptr; static int g_win_w = WIN_W; static int g_win_h = WIN_H; static Tab g_tab = TAB_DEVICES; +static int g_mouse_x = -1; +static int g_mouse_y = -1; static int g_hover_row = -1; // Adapter info @@ -85,6 +89,142 @@ static int g_scroll = 0; static char g_status[80]; static uint64_t g_status_time = 0; +// ============================================================================ +// Theme and layout +// ============================================================================ + +static mtk::Theme bt_theme() { + mtk::Theme theme = mtk::make_theme(ACCENT); + theme.window_bg = BG_COLOR; + theme.surface = Color::from_rgb(0xF5, 0xF5, 0xF5); + theme.surface_alt = Color::from_rgb(0xF8, 0xF8, 0xF8); + theme.surface_hover = ROW_HOVER; + theme.border = BORDER; + theme.text = TEXT_COLOR; + theme.text_muted = Color::from_rgb(0x88, 0x88, 0x88); + theme.text_subtle = DIM_TEXT; + theme.selection = ACCENT; + theme.accent_soft = ROW_HOVER; + theme.danger = RED; + theme.danger_hover = mtk::darken(RED, 18); + theme.disabled_bg = Color::from_rgb(0xE8, 0xE8, 0xE8); + theme.disabled_fg = Color::from_rgb(0x9A, 0x9A, 0x9A); + return theme; +} + +static Rect tab_bar_rect() { + return {0, 0, g_win_w, TAB_H}; +} + +static Rect content_rect() { + return {0, TAB_H, g_win_w, gui_max(g_win_h - TAB_H - STATUS_H, 0)}; +} + +static Rect status_bar_rect() { + return {0, g_win_h - STATUS_H, g_win_w, STATUS_H}; +} + +static Rect scan_button_rect() { + Rect content = content_rect(); + return {(g_win_w - SCAN_BTN_W) / 2, content.y + 12, SCAN_BTN_W, BTN_H}; +} + +static int devices_list_top() { + return content_rect().y; +} + +static int devices_list_bottom() { + return status_bar_rect().y; +} + +static int scan_list_top() { + Rect scan_btn = scan_button_rect(); + return scan_btn.y + scan_btn.h + 12; +} + +static int scan_list_bottom() { + return status_bar_rect().y; +} + +static Rect devices_row_rect(int index) { + return {0, devices_list_top() + index * ROW_H - g_scroll, g_win_w, ROW_H}; +} + +static Rect scan_row_rect(int index) { + return {0, scan_list_top() + index * ROW_H - g_scroll, g_win_w, ROW_H}; +} + +static Rect row_action_button_rect(const Rect& row) { + return {g_win_w - PAD - BTN_W, row.y + (ROW_H - BTN_H) / 2, BTN_W, BTN_H}; +} + +static int current_row_count() { + return g_tab == TAB_DEVICES ? g_device_count : g_scan_count; +} + +static int current_list_top() { + return g_tab == TAB_DEVICES ? devices_list_top() : scan_list_top(); +} + +static int current_list_bottom() { + return g_tab == TAB_DEVICES ? devices_list_bottom() : scan_list_bottom(); +} + +static int max_scroll() { + int visible_h = gui_max(current_list_bottom() - current_list_top(), 0); + int total_h = current_row_count() * ROW_H; + if (total_h <= visible_h) return 0; + return total_h - visible_h; +} + +static void clamp_scroll() { + g_scroll = gui_clamp(g_scroll, 0, max_scroll()); +} + +static void draw_text_fit(Canvas& c, int x, int y, + const char* text, int max_w, Color color) { + if (!text || !text[0] || max_w <= 4) return; + if (text_width(text) <= max_w) { + c.text(x, y, text, color); + return; + } + + const char* ellipsis = "..."; + int ell_w = text_width(ellipsis); + if (ell_w >= max_w) return; + + char buf[96]; + int out = 0; + while (text[out] && out < (int)sizeof(buf) - 4) { + buf[out] = text[out]; + buf[out + 1] = '\0'; + if (text_width(buf) + ell_w > max_w) + break; + out++; + } + + if (out <= 0) return; + buf[out] = '.'; + buf[out + 1] = '.'; + buf[out + 2] = '.'; + buf[out + 3] = '\0'; + c.text(x, y, buf, color); +} + +static void draw_centered_text(Canvas& c, int area_y, int area_h, const char* text, Color color) { + int tw = text_width(text); + int th = system_font_height(); + c.text((g_win_w - tw) / 2, area_y + (area_h - th) / 2, text, color); +} + +static bool mouse_in_rect(const Rect& rect) { + return rect.contains(g_mouse_x, g_mouse_y); +} + +static mtk::WidgetState button_state(const Rect& rect, bool enabled, bool active = false) { + return mtk::widget_state(active, mouse_in_rect(rect), enabled); +} + // ============================================================================ // Bluetooth helpers // ============================================================================ @@ -104,7 +244,6 @@ static void refresh_devices() { g_device_count = montauk::bt_list(g_devices, MAX_CONNECTED); if (g_device_count < 0) g_device_count = 0; - // Resolve names from scan results or use address string for (int i = 0; i < g_device_count; i++) { bool found = false; for (int j = 0; j < g_scan_count; j++) { @@ -126,6 +265,8 @@ static void refresh_devices() { static void do_scan() { g_scanning = true; g_scan_count = 0; + g_scroll = 0; + g_hover_row = -1; set_status("Scanning..."); } @@ -133,6 +274,7 @@ static void finish_scan() { int r = montauk::bt_scan(g_scan, MAX_SCAN, 3000); g_scan_count = r > 0 ? r : 0; g_scanning = false; + clamp_scroll(); char msg[64]; snprintf(msg, sizeof(msg), "Found %d device%s", g_scan_count, g_scan_count == 1 ? "" : "s"); set_status(msg); @@ -140,22 +282,18 @@ static void finish_scan() { static void do_connect(const uint8_t* addr) { int r = montauk::bt_connect(addr); - if (r >= 0) { - set_status("Connected"); - } else { - set_status("Connection failed"); - } + if (r >= 0) set_status("Connected"); + else set_status("Connection failed"); refresh_devices(); + clamp_scroll(); } static void do_disconnect(const uint8_t* addr) { int r = montauk::bt_disconnect(addr); - if (r >= 0) { - set_status("Disconnected"); - } else { - set_status("Disconnect failed"); - } + if (r >= 0) set_status("Disconnected"); + else set_status("Disconnect failed"); refresh_devices(); + clamp_scroll(); } static bool is_connected(const uint8_t* addr) { @@ -173,14 +311,14 @@ static bool is_connected(const uint8_t* addr) { static const char* device_class_str(uint32_t cod) { uint32_t major = (cod >> 8) & 0x1F; switch (major) { - case 1: return "Computer"; - case 2: return "Phone"; - case 3: return "Network"; - case 4: return "Audio/Video"; - case 5: return "Peripheral"; - case 6: return "Imaging"; - case 7: return "Wearable"; - default: return "Device"; + case 1: return "Computer"; + case 2: return "Phone"; + case 3: return "Network"; + case 4: return "Audio/Video"; + case 5: return "Peripheral"; + case 6: return "Imaging"; + case 7: return "Wearable"; + default: return "Device"; } } @@ -196,248 +334,70 @@ static const char* rssi_bar(int8_t rssi) { return "Very weak"; } -// ============================================================================ -// Render -// ============================================================================ - -static void render(Canvas& canvas) { - int W = g_win_w; - int H = g_win_h; - - // Background - canvas.fill(BG_COLOR); - - // Tab bar - int tab_y = 0; - canvas.fill_rect(0, tab_y, W, TAB_H, TAB_BG); - canvas.hline(0, tab_y + TAB_H - 1, W, BORDER); - - int tab_w = W / 2; - const char* tab_labels[] = { "Devices", "Scan" }; - for (int i = 0; i < 2; i++) { - // Active tab gets white background to merge with content area - if (g_tab == i) - canvas.fill_rect(i * tab_w, tab_y, tab_w, TAB_H, BG_COLOR); - - Color tc = (g_tab == i) ? TAB_ACTIVE : TAB_INACTIVE; - int tw = text_width(g_font, tab_labels[i], FONT_SIZE); - int tx = i * tab_w + (tab_w - tw) / 2; - draw_text(canvas, g_font, tx, tab_y + (TAB_H - text_height(g_font, FONT_SIZE)) / 2, - tab_labels[i], tc, FONT_SIZE); - // Active indicator underline - if (g_tab == i) { - canvas.fill_rect(i * tab_w + 4, tab_y + TAB_H - 3, tab_w - 8, 3, TAB_ACTIVE); - } - } - - int content_y = tab_y + TAB_H; - int content_h = H - content_y; - - // Status bar at bottom - int list_h = content_h - STATUS_H; - - if (g_tab == TAB_DEVICES) { - // Connected devices list - if (g_device_count == 0) { - const char* msg = "No connected devices"; - int mw = text_width(g_font, msg, FONT_SIZE); - draw_text(canvas, g_font, - (W - mw) / 2, content_y + list_h / 2 - text_height(g_font, FONT_SIZE) / 2, - msg, DIM_TEXT, FONT_SIZE); - } else { - for (int i = 0; i < g_device_count; i++) { - int ry = content_y + i * ROW_H - g_scroll; - if (ry + ROW_H < content_y || ry >= content_y + list_h) continue; - - // Hover highlight - if (g_hover_row == i) - canvas.fill_rect(0, ry, W, ROW_H, ROW_HOVER); - - // Connected indicator dot - Color dot_c = g_devices[i].connected ? GREEN : DIM_TEXT; - fill_circle(canvas, PAD + 6, ry + ROW_H / 2, 5, dot_c); - - // Device name - draw_text(canvas, g_font, PAD + 20, ry + 8, g_device_names[i], TEXT_COLOR, FONT_SIZE); - - // Address below name - char addr_str[24]; - format_addr(addr_str, sizeof(addr_str), g_devices[i].bdAddr); - draw_text(canvas, g_font, PAD + 20, ry + 8 + text_height(g_font, FONT_SIZE) + 2, - addr_str, DIM_TEXT, FONT_SIZE_SM); - - // Disconnect button - if (g_devices[i].connected) { - int bx = W - PAD - BTN_W; - int by = ry + (ROW_H - BTN_H) / 2; - draw_button(canvas, g_font, bx, by, BTN_W, BTN_H, - "Disconnect", RED, WHITE, BTN_RAD, FONT_SIZE_SM); - } - - // Divider - canvas.hline(PAD, ry + ROW_H - 1, W - 2 * PAD, BORDER); - } - } - } else { - // Scan tab - // Scan button at top of content area - int scan_btn_y = content_y + 12; - int scan_btn_w = 100; - int scan_btn_x = (W - scan_btn_w) / 2; - if (g_scanning) { - draw_button(canvas, g_font, scan_btn_x, scan_btn_y, scan_btn_w, BTN_H, - "Scanning...", BORDER, DIM_TEXT, BTN_RAD, FONT_SIZE_SM); - } else { - draw_button(canvas, g_font, scan_btn_x, scan_btn_y, scan_btn_w, BTN_H, - "Scan", ACCENT, WHITE, BTN_RAD, FONT_SIZE_SM); - } - - int list_top = scan_btn_y + BTN_H + 12; - int scan_list_h = content_y + list_h - list_top; - - if (g_scan_count == 0 && !g_scanning) { - const char* msg = "Press Scan to find devices"; - int mw = text_width(g_font, msg, FONT_SIZE); - draw_text(canvas, g_font, - (W - mw) / 2, list_top + scan_list_h / 2 - text_height(g_font, FONT_SIZE) / 2, - msg, DIM_TEXT, FONT_SIZE); - } else { - for (int i = 0; i < g_scan_count; i++) { - int ry = list_top + i * ROW_H - g_scroll; - if (ry + ROW_H < list_top || ry >= list_top + scan_list_h) continue; - - // Hover highlight - if (g_hover_row == i) - canvas.fill_rect(0, ry, W, ROW_H, ROW_HOVER); - - // Device type indicator - const char* type_str = device_class_str(g_scan[i].classOfDevice); - fill_circle(canvas, PAD + 6, ry + ROW_H / 2, 5, ACCENT); - - // Device name (or address if unnamed) - const char* display_name = g_scan[i].name[0] ? g_scan[i].name : "Unknown Device"; - draw_text(canvas, g_font, PAD + 20, ry + 4, display_name, TEXT_COLOR, FONT_SIZE); - - // Type + signal below name - char detail[80]; - char addr_str[24]; - format_addr(addr_str, sizeof(addr_str), g_scan[i].bdAddr); - snprintf(detail, sizeof(detail), "%s | %s | %s", - type_str, addr_str, rssi_bar(g_scan[i].rssi)); - draw_text(canvas, g_font, PAD + 20, ry + 4 + text_height(g_font, FONT_SIZE) + 2, - detail, DIM_TEXT, FONT_SIZE_SM); - - // Connect/Disconnect button - bool conn = is_connected(g_scan[i].bdAddr); - int bx = W - PAD - BTN_W; - int by = ry + (ROW_H - BTN_H) / 2; - if (conn) { - draw_button(canvas, g_font, bx, by, BTN_W, BTN_H, - "Disconnect", RED, WHITE, BTN_RAD, FONT_SIZE_SM); - } else { - draw_button(canvas, g_font, bx, by, BTN_W, BTN_H, - "Connect", ACCENT, WHITE, BTN_RAD, FONT_SIZE_SM); - } - - // Divider - canvas.hline(PAD, ry + ROW_H - 1, W - 2 * PAD, BORDER); - } - } - } - - // Status bar - int status_y = H - STATUS_H; - canvas.fill_rect(0, status_y, W, STATUS_H, TAB_BG); - canvas.hline(0, status_y, W, BORDER); - - // Status bar content: adapter info on the left, status message on the right - uint64_t now = montauk::get_milliseconds(); - int sy = status_y + (STATUS_H - text_height(g_font, FONT_SIZE_SM)) / 2; - - if (g_adapter_ok) { - char adapter_info[96]; - char addr_str[24]; - format_addr(addr_str, sizeof(addr_str), g_adapter.bdAddr); - const char* name = g_adapter.name[0] ? g_adapter.name : "Adapter"; - snprintf(adapter_info, sizeof(adapter_info), "%s | %s | %d connected", - name, addr_str, g_device_count); - draw_text(canvas, g_font, PAD, sy, adapter_info, DIM_TEXT, FONT_SIZE_SM); - } else { - draw_text(canvas, g_font, PAD, sy, "No adapter found", RED, FONT_SIZE_SM); - } - - // Temporary status message (right-aligned, fades after 5s) - if (g_status[0] && (now - g_status_time) < 5000) { - int sw = text_width(g_font, g_status, FONT_SIZE_SM); - draw_text(canvas, g_font, W - PAD - sw, sy, g_status, DIM_TEXT, FONT_SIZE_SM); - } +static void set_tab(Tab tab) { + if (tab == g_tab) return; + g_tab = tab; + g_scroll = 0; + g_hover_row = -1; + clamp_scroll(); } // ============================================================================ -// Hit testing +// Hover and hit testing // ============================================================================ -static bool handle_click(int mx, int my) { - int W = g_win_w; - - // Tab bar - int tab_y = 0; - if (my >= tab_y && my < tab_y + TAB_H) { - Tab new_tab = (mx < W / 2) ? TAB_DEVICES : TAB_SCAN; - if (new_tab != g_tab) { - g_tab = new_tab; - g_scroll = 0; - g_hover_row = -1; - return true; +static int get_hover_row(int mx, int my) { + if (g_tab == TAB_DEVICES) { + for (int i = 0; i < g_device_count; i++) { + if (devices_row_rect(i).contains(mx, my)) + return i; } - return false; + } else { + for (int i = 0; i < g_scan_count; i++) { + if (scan_row_rect(i).contains(mx, my)) + return i; + } + } + return -1; +} + +static bool handle_click(int mx, int my) { + int tab = mtk::hit_tab_bar(tab_bar_rect(), TAB_COUNT, mx, my); + if (tab >= 0) { + set_tab((Tab)tab); + return true; } - int content_y = tab_y + TAB_H; - if (g_tab == TAB_SCAN) { - // Scan button - int scan_btn_y = content_y + 8; - int scan_btn_w = 100; - int scan_btn_x = (W - scan_btn_w) / 2; - if (mx >= scan_btn_x && mx < scan_btn_x + scan_btn_w && - my >= scan_btn_y && my < scan_btn_y + BTN_H && !g_scanning) { + Rect scan_btn = scan_button_rect(); + if (!g_scanning && scan_btn.contains(mx, my)) { do_scan(); return true; } - // Scan result rows - int list_top = scan_btn_y + BTN_H + 12; for (int i = 0; i < g_scan_count; i++) { - int ry = list_top + i * ROW_H - g_scroll; - if (my >= ry && my < ry + ROW_H) { - // Check if disconnect/connect button clicked - int bx = W - PAD - BTN_W; - int by = ry + (ROW_H - BTN_H) / 2; - if (mx >= bx && mx < bx + BTN_W && my >= by && my < by + BTN_H) { - if (is_connected(g_scan[i].bdAddr)) { - do_disconnect(g_scan[i].bdAddr); - } else { - do_connect(g_scan[i].bdAddr); - } - return true; - } + Rect row = scan_row_rect(i); + if (!row.contains(mx, my)) continue; + + Rect action = row_action_button_rect(row); + if (action.contains(mx, my)) { + if (is_connected(g_scan[i].bdAddr)) + do_disconnect(g_scan[i].bdAddr); + else + do_connect(g_scan[i].bdAddr); + return true; } } } else { - // Device rows for (int i = 0; i < g_device_count; i++) { - int ry = content_y + i * ROW_H - g_scroll; - if (my >= ry && my < ry + ROW_H) { - // Disconnect button - if (g_devices[i].connected) { - int bx = W - PAD - BTN_W; - int by = ry + (ROW_H - BTN_H) / 2; - if (mx >= bx && mx < bx + BTN_W && my >= by && my < by + BTN_H) { - do_disconnect(g_devices[i].bdAddr); - return true; - } - } + Rect row = devices_row_rect(i); + if (!row.contains(mx, my)) continue; + if (!g_devices[i].connected) continue; + + Rect action = row_action_button_rect(row); + if (action.contains(mx, my)) { + do_disconnect(g_devices[i].bdAddr); + return true; } } } @@ -445,25 +405,142 @@ static bool handle_click(int mx, int my) { return false; } -static int get_hover_row(int mx, int my) { - int content_y = TAB_H; +// ============================================================================ +// Render +// ============================================================================ - if (g_tab == TAB_DEVICES) { - int count = g_device_count; - for (int i = 0; i < count; i++) { - int ry = content_y + i * ROW_H - g_scroll; - if (my >= ry && my < ry + ROW_H) - return i; - } - } else { - int list_top = content_y + 8 + BTN_H + 12; - for (int i = 0; i < g_scan_count; i++) { - int ry = list_top + i * ROW_H - g_scroll; - if (my >= ry && my < ry + ROW_H) - return i; - } +static void render_devices_tab(Canvas& canvas, const mtk::Theme& theme) { + Rect list = {0, devices_list_top(), g_win_w, gui_max(devices_list_bottom() - devices_list_top(), 0)}; + int fh = system_font_height(); + + if (g_device_count == 0) { + draw_centered_text(canvas, list.y, list.h, "No connected devices", theme.text_subtle); + return; } - return -1; + + for (int i = 0; i < g_device_count; i++) { + Rect row = devices_row_rect(i); + if (row.y + row.h <= list.y || row.y >= list.y + list.h) continue; + + if (g_hover_row == i) + canvas.fill_rect(row.x, row.y, row.w, row.h, theme.surface_hover); + else + mtk::draw_list_row(canvas, row, false, (i & 1) != 0, theme); + + Color dot_color = g_devices[i].connected ? GREEN : theme.text_subtle; + fill_circle(canvas, PAD + 6, row.y + ROW_H / 2, 5, dot_color); + + Rect action = row_action_button_rect(row); + int text_x = PAD + 20; + int text_w_max = (g_devices[i].connected ? action.x : g_win_w - PAD) - text_x - 10; + draw_text_fit(canvas, text_x, row.y + 8, g_device_names[i], text_w_max, theme.text); + + char addr_str[24]; + format_addr(addr_str, sizeof(addr_str), g_devices[i].bdAddr); + draw_text_fit(canvas, text_x, row.y + 8 + fh + 2, addr_str, text_w_max, theme.text_subtle); + + if (g_devices[i].connected) { + mtk::draw_button(canvas, action, "Disconnect", mtk::BUTTON_DANGER, + button_state(action, true), theme); + } + + mtk::draw_separator(canvas, PAD, row.y + ROW_H - 1, g_win_w - 2 * PAD, theme); + } +} + +static void render_scan_tab(Canvas& canvas, const mtk::Theme& theme) { + Rect scan_btn = scan_button_rect(); + Rect list = {0, scan_list_top(), g_win_w, gui_max(scan_list_bottom() - scan_list_top(), 0)}; + int fh = system_font_height(); + + if (g_scanning) { + mtk::draw_button(canvas, scan_btn, "Scanning...", mtk::BUTTON_SECONDARY, + button_state(scan_btn, false), theme); + } else { + mtk::draw_button(canvas, scan_btn, "Scan", mtk::BUTTON_PRIMARY, + button_state(scan_btn, true), theme); + } + + if (g_scan_count == 0 && !g_scanning) { + draw_centered_text(canvas, list.y, list.h, "Press Scan to find devices", theme.text_subtle); + return; + } + + for (int i = 0; i < g_scan_count; i++) { + Rect row = scan_row_rect(i); + if (row.y + row.h <= list.y || row.y >= list.y + list.h) continue; + + if (g_hover_row == i) + canvas.fill_rect(row.x, row.y, row.w, row.h, theme.surface_hover); + else + mtk::draw_list_row(canvas, row, false, (i & 1) != 0, theme); + + fill_circle(canvas, PAD + 6, row.y + ROW_H / 2, 5, theme.accent); + + Rect action = row_action_button_rect(row); + int text_x = PAD + 20; + int text_w_max = action.x - text_x - 10; + + const char* display_name = g_scan[i].name[0] ? g_scan[i].name : "Unknown Device"; + draw_text_fit(canvas, text_x, row.y + 4, display_name, text_w_max, theme.text); + + char detail[80]; + char addr_str[24]; + format_addr(addr_str, sizeof(addr_str), g_scan[i].bdAddr); + snprintf(detail, sizeof(detail), "%s | %s | %s", + device_class_str(g_scan[i].classOfDevice), addr_str, rssi_bar(g_scan[i].rssi)); + draw_text_fit(canvas, text_x, row.y + 4 + fh + 2, detail, text_w_max, theme.text_subtle); + + bool conn = is_connected(g_scan[i].bdAddr); + mtk::draw_button(canvas, action, conn ? "Disconnect" : "Connect", + conn ? mtk::BUTTON_DANGER : mtk::BUTTON_PRIMARY, + button_state(action, true), theme); + + mtk::draw_separator(canvas, PAD, row.y + ROW_H - 1, g_win_w - 2 * PAD, theme); + } +} + +static void render_status(Canvas& canvas, const mtk::Theme& theme) { + Rect status = status_bar_rect(); + int sy = status.y + (status.h - system_font_height()) / 2; + uint64_t now = montauk::get_milliseconds(); + + canvas.fill_rect(status.x, status.y, status.w, status.h, theme.surface); + mtk::draw_separator(canvas, 0, status.y, g_win_w, theme); + + int status_w = 0; + bool show_status = g_status[0] && (now - g_status_time) < 5000; + if (show_status) + status_w = text_width(g_status); + + int left_max = g_win_w - PAD * 2 - (show_status ? status_w + 12 : 0); + if (g_adapter_ok) { + char adapter_info[96]; + char addr_str[24]; + format_addr(addr_str, sizeof(addr_str), g_adapter.bdAddr); + const char* name = g_adapter.name[0] ? g_adapter.name : "Adapter"; + snprintf(adapter_info, sizeof(adapter_info), "%s | %s | %d connected", + name, addr_str, g_device_count); + draw_text_fit(canvas, PAD, sy, adapter_info, left_max, theme.text_subtle); + } else { + draw_text_fit(canvas, PAD, sy, "No adapter found", left_max, theme.danger); + } + + if (show_status) + canvas.text(g_win_w - PAD - status_w, sy, g_status, theme.text_subtle); +} + +static void render(Canvas& canvas) { + mtk::Theme theme = bt_theme(); + canvas.fill(theme.window_bg); + mtk::draw_tab_bar(canvas, tab_bar_rect(), g_tab_labels, TAB_COUNT, g_tab, theme); + + if (g_tab == TAB_DEVICES) + render_devices_tab(canvas, theme); + else + render_scan_tab(canvas, theme); + + render_status(canvas, theme); } // ============================================================================ @@ -471,31 +548,23 @@ static int get_hover_row(int mx, int my) { // ============================================================================ extern "C" void _start() { - // Load font - { - TrueTypeFont* f = (TrueTypeFont*)montauk::malloc(sizeof(TrueTypeFont)); - if (f) { - montauk::memset(f, 0, sizeof(TrueTypeFont)); - if (!f->init("0:/fonts/Roboto-Medium.ttf")) { montauk::mfree(f); f = nullptr; } - } - g_font = f; - } + if (!fonts::init()) + montauk::exit(1); - // Query adapter refresh_adapter(); refresh_devices(); + clamp_scroll(); g_status[0] = 0; WsWindow win; if (!win.create("Bluetooth", WIN_W, WIN_H)) montauk::exit(1); - Canvas canvas = win.canvas(); - + mtk::StandaloneHost host(&win); + Canvas canvas = host.canvas(); render(canvas); - win.present(); + host.present(); - // Periodic refresh timer uint64_t last_refresh = montauk::get_milliseconds(); while (true) { @@ -504,29 +573,30 @@ extern "C" void _start() { if (r < 0) break; - // Handle pending scan (non-blocking: kick it off after render) if (g_scanning) { - canvas = win.canvas(); + canvas = host.canvas(); render(canvas); - win.present(); + host.present(); finish_scan(); refresh_devices(); - canvas = win.canvas(); + g_hover_row = get_hover_row(g_mouse_x, g_mouse_y); + canvas = host.canvas(); render(canvas); - win.present(); + host.present(); continue; } if (r == 0) { - // Periodic refresh every 5 seconds uint64_t now = montauk::get_milliseconds(); if (now - last_refresh >= 5000) { refresh_adapter(); refresh_devices(); + clamp_scroll(); + g_hover_row = get_hover_row(g_mouse_x, g_mouse_y); last_refresh = now; - canvas = win.canvas(); + canvas = host.canvas(); render(canvas); - win.present(); + host.present(); } montauk::sleep_ms(16); continue; @@ -534,52 +604,62 @@ extern "C" void _start() { bool redraw = false; - if (ev.type == 3) break; // close + if (ev.type == 3) break; - // Resize if (ev.type == 2) { g_win_w = win.width; g_win_h = win.height; + clamp_scroll(); + g_hover_row = get_hover_row(g_mouse_x, g_mouse_y); redraw = true; } - // Keyboard if (ev.type == 0 && ev.key.pressed) { - if (ev.key.scancode == 0x01) break; // Escape - if (ev.key.ascii == 's' || ev.key.ascii == 'S') { - if (!g_scanning) { do_scan(); redraw = true; } + if (ev.key.scancode == 0x01) break; + if ((ev.key.ascii == 's' || ev.key.ascii == 'S') && !g_scanning) { + do_scan(); + redraw = true; + } + if (ev.key.ascii == '1') { + set_tab(TAB_DEVICES); + redraw = true; + } + if (ev.key.ascii == '2') { + set_tab(TAB_SCAN); + redraw = true; } - if (ev.key.ascii == '1') { g_tab = TAB_DEVICES; g_scroll = 0; redraw = true; } - if (ev.key.ascii == '2') { g_tab = TAB_SCAN; g_scroll = 0; redraw = true; } } - // Mouse if (ev.type == 1) { - bool clicked = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1); + bool moved = (ev.mouse.x != g_mouse_x) || (ev.mouse.y != g_mouse_y); + g_mouse_x = ev.mouse.x; + g_mouse_y = ev.mouse.y; - int new_hover = get_hover_row(ev.mouse.x, ev.mouse.y); + int new_hover = get_hover_row(g_mouse_x, g_mouse_y); if (new_hover != g_hover_row) { g_hover_row = new_hover; redraw = true; } - if (clicked) { - if (handle_click(ev.mouse.x, ev.mouse.y)) - redraw = true; - } + if (moved) + redraw = true; + + bool clicked = (ev.mouse.buttons & 1) && !(ev.mouse.prev_buttons & 1); + if (clicked && handle_click(ev.mouse.x, ev.mouse.y)) + redraw = true; - // Scroll if (ev.mouse.scroll) { - g_scroll -= ev.mouse.scroll * 20; - if (g_scroll < 0) g_scroll = 0; + g_scroll -= ev.mouse.scroll * SCROLL_STEP; + clamp_scroll(); + g_hover_row = get_hover_row(g_mouse_x, g_mouse_y); redraw = true; } } if (redraw) { - canvas = win.canvas(); + canvas = host.canvas(); render(canvas); - win.present(); + host.present(); } }