diff --git a/VERSION b/VERSION index 1fe6958..369bd4c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.28 +0.0.29 diff --git a/src/main.c b/src/main.c index 311bc3c..5e6f999 100644 --- a/src/main.c +++ b/src/main.c @@ -281,20 +281,42 @@ void on_menu_agent(GtkMenuItem *item, gpointer data) { * sovereign://settings page (key-capture UI) and persisted to SQLite. */ -/* Made non-static so tab_manager.c can connect it to each webview. */ +/* Made non-static so tab_manager.c can connect it to each webview. + * + * This handler is connected to two places: + * 1. Each webview's "key-press-event" (in tab_manager.c tab_create()) + * 2. The main window's "key-press-event" (in main.c setup) + * + * For the webview connection, we bypass shortcut interception when the + * active page is a sovereign:// internal page — this lets the page's JS + * capture arbitrary key combos (e.g. the settings page's shortcut capture + * UI needs to see Ctrl+key events that would otherwise be intercepted). + * + * For the window connection, we always process shortcuts so that + * browser-level shortcuts (Ctrl+T, Ctrl+N, etc.) work even on + * sovereign:// pages. The window handler is a fallback that fires after + * the webview handler (event propagation: child → parent → window). */ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, gpointer data) { - (void)widget; (void)data; + /* Check if this handler is connected to a webview (not the window). + * WEBKIT_IS_WEB_VIEW is true for webview connections, false for the + * window connection. */ + gboolean from_webview = WEBKIT_IS_WEB_VIEW(widget); + /* When the active tab is showing a sovereign:// internal page (e.g. - * the settings page's keyboard-shortcut capture), don't intercept - * any key events — let them pass through to the web page's JS so it - * can capture arbitrary key combos (including Ctrl+key). */ - tab_info_t *active = tab_manager_get_active(); - if (active && active->current_url[0] != '\0' && - strncmp(active->current_url, "sovereign://", 12) == 0) { - return FALSE; + * the settings page's keyboard-shortcut capture), bypass shortcut + * interception on the webview connection only — let the event pass + * through to the web page's JS so it can capture arbitrary key combos. + * The window-level connection still processes shortcuts so Ctrl+T, + * Ctrl+N, etc. work on internal pages. */ + if (from_webview) { + tab_info_t *active = tab_manager_get_active(); + if (active && active->current_url[0] != '\0' && + strncmp(active->current_url, "sovereign://", 12) == 0) { + return FALSE; + } } int action = shortcuts_lookup(event); @@ -306,6 +328,10 @@ gboolean on_key_press(GtkWidget *widget, GdkEventKey *event, tab_manager_new_tab(NULL); return TRUE; + case SHORTCUT_NEW_WINDOW: + tab_manager_new_window_blank(); + return TRUE; + case SHORTCUT_CLOSE_TAB: tab_manager_close_active(); return TRUE; diff --git a/src/shortcuts.c b/src/shortcuts.c index 0c1d825..ac79865 100644 --- a/src/shortcuts.c +++ b/src/shortcuts.c @@ -26,6 +26,10 @@ static const shortcut_meta_t g_registry[SHORTCUT_COUNT] = { "new_tab", "New tab", "Open a new tab", "t" }, + [SHORTCUT_NEW_WINDOW] = { + "new_window", "New window", + "Open a new browser window", "n" + }, [SHORTCUT_CLOSE_TAB] = { "close_tab", "Close tab", "Close the active tab", "w" diff --git a/src/shortcuts.h b/src/shortcuts.h index 20cbe2a..8ee40ea 100644 --- a/src/shortcuts.h +++ b/src/shortcuts.h @@ -28,6 +28,7 @@ extern "C" { typedef enum { SHORTCUT_NEW_TAB = 0, + SHORTCUT_NEW_WINDOW, SHORTCUT_CLOSE_TAB, SHORTCUT_FOCUS_URL, SHORTCUT_NEXT_TAB, diff --git a/src/tab_manager.c b/src/tab_manager.c index 75c6437..a09fdb8 100644 --- a/src/tab_manager.c +++ b/src/tab_manager.c @@ -145,6 +145,9 @@ static void on_tab_close_clicked_proxy_new(GtkMenuItem *item, gpointer d static void on_avatar_clicked(GtkButton *btn, gpointer data); static GtkWidget *tab_manager_new_window(const char *url, WebKitWebView *related_view); +static gboolean on_notebook_button_press(GtkWidget *widget, + GdkEventButton *event, + gpointer user_data); static gboolean on_window_focus_in(GtkWidget *widget, GdkEventFocus *event, gpointer user_data); @@ -562,6 +565,117 @@ static void on_tab_close_clicked(GtkButton *btn, gpointer user_data) { } } +/* Notebook-level button-press handler. Catches right-clicks on tab + * labels that GtkNotebook's internal handling would otherwise consume + * before the tab label's own button-press-event handler fires. We find + * which tab was clicked by checking the tab label's allocation against + * the click coordinates, then show the same context menu as + * on_tab_label_button_press. */ +static gboolean on_notebook_button_press(GtkWidget *widget, + GdkEventButton *event, + gpointer user_data) { + (void)user_data; + if (widget == NULL) return FALSE; + GtkNotebook *nb = GTK_NOTEBOOK(widget); + if (event->button != 3 && event->button != 2) return FALSE; + + gint n_pages = gtk_notebook_get_n_pages(nb); + for (gint i = 0; i < n_pages; i++) { + GtkWidget *page = gtk_notebook_get_nth_page(nb, i); + if (page == NULL) continue; + GtkWidget *label = gtk_notebook_get_tab_label(nb, page); + if (label == NULL) continue; + + GtkAllocation alloc; + gtk_widget_get_allocation(label, &alloc); + + /* Convert click coords to the label's coordinate space. The + * event coords are relative to the notebook's bin window. */ + gint x = (gint)event->x; + gint y = (gint)event->y; + + /* Get the label's allocation position relative to the notebook. */ + gint lx = alloc.x; + gint ly = alloc.y; + + if (x >= lx && x < lx + alloc.width && + y >= ly && y < ly + alloc.height) { + /* Found the clicked tab. Look up the tab_info_t. */ + if (i < g_tab_count && g_tabs[i] != NULL) { + /* Reuse the existing handler by synthesizing a call. */ + tab_info_t *tab = g_tabs[i]; + int index = tab_array_find(tab); + if (index < 0) return FALSE; + + if (event->button == 2) { + const browser_settings_t *s = settings_get(); + if (s->middle_click_close) { + tab_manager_close_tab(index); + return TRUE; + } + return FALSE; + } + + /* Right-click: show context menu (same as + * on_tab_label_button_press). */ + GtkWidget *menu = gtk_menu_new(); + + GtkWidget *item_new = gtk_menu_item_new_with_label("New Tab"); + g_signal_connect(item_new, "activate", + G_CALLBACK(on_tab_close_clicked_proxy_new), NULL); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new); + + GtkWidget *item_close = gtk_menu_item_new_with_label("Close Tab"); + g_signal_connect_swapped(item_close, "activate", + G_CALLBACK(tab_manager_close_tab), + GINT_TO_POINTER(index)); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close); + + GtkWidget *item_close_others = + gtk_menu_item_new_with_label("Close Other Tabs"); + g_signal_connect_swapped(item_close_others, "activate", + G_CALLBACK(tab_manager_close_others), + GINT_TO_POINTER(index)); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_others); + + GtkWidget *item_close_right = + gtk_menu_item_new_with_label("Close Tabs to the Right"); + g_signal_connect_swapped(item_close_right, "activate", + G_CALLBACK(tab_manager_close_to_right), + GINT_TO_POINTER(index)); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_close_right); + + gtk_menu_shell_append(GTK_MENU_SHELL(menu), + gtk_separator_menu_item_new()); + + GtkWidget *item_new_win = + gtk_menu_item_new_with_label("Open in New Window"); + g_signal_connect_swapped(item_new_win, "activate", + G_CALLBACK(tab_manager_open_in_new_window), + GINT_TO_POINTER(index)); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new_win); + + GtkWidget *item_dup = gtk_menu_item_new_with_label("Duplicate Tab"); + g_signal_connect_swapped(item_dup, "activate", + G_CALLBACK(tab_manager_duplicate), + GINT_TO_POINTER(index)); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_dup); + + GtkWidget *item_reload = gtk_menu_item_new_with_label("Reload Tab"); + g_signal_connect_swapped(item_reload, "activate", + G_CALLBACK(tab_manager_reload), + GINT_TO_POINTER(index)); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_reload); + + gtk_widget_show_all(menu); + gtk_menu_popup_at_pointer(GTK_MENU(menu), (GdkEvent *)event); + return TRUE; + } + } + } + return FALSE; +} + static gboolean on_tab_label_button_press(GtkWidget *widget, GdkEventButton *event, gpointer user_data) { @@ -612,6 +726,13 @@ static gboolean on_tab_label_button_press(GtkWidget *widget, gtk_menu_shell_append(GTK_MENU_SHELL(menu), gtk_separator_menu_item_new()); + GtkWidget *item_new_win = + gtk_menu_item_new_with_label("Open in New Window"); + g_signal_connect_swapped(item_new_win, "activate", + G_CALLBACK(tab_manager_open_in_new_window), + GINT_TO_POINTER(index)); + gtk_menu_shell_append(GTK_MENU_SHELL(menu), item_new_win); + GtkWidget *item_dup = gtk_menu_item_new_with_label("Duplicate Tab"); g_signal_connect_swapped(item_dup, "activate", G_CALLBACK(tab_manager_duplicate), @@ -987,13 +1108,33 @@ static GtkWidget *build_tab_label(tab_info_t *tab) { gtk_widget_set_tooltip_text(close_btn, "Close tab"); g_signal_connect(close_btn, "clicked", G_CALLBACK(on_tab_close_clicked), tab); + /* Connect button-press so right-click on the close button + * also triggers the tab context menu. */ + gtk_widget_add_events(close_btn, GDK_BUTTON_PRESS_MASK); + g_signal_connect(close_btn, "button-press-event", + G_CALLBACK(on_tab_label_button_press), tab); gtk_box_pack_start(GTK_BOX(box), close_btn, FALSE, FALSE, 0); } - /* Connect button-press for middle-click and right-click on the label. */ + /* Connect button-press for middle-click and right-click on the label. + * We add the event mask and connect the handler to the box AND all + * child widgets (favicon, label, close button), because child widgets + * intercept button-press events before they bubble up to the parent. + * Without this, right-clicking on the label or close button wouldn't + * show the context menu. */ gtk_widget_add_events(box, GDK_BUTTON_PRESS_MASK); g_signal_connect(box, "button-press-event", G_CALLBACK(on_tab_label_button_press), tab); + /* Also connect child widgets so right-clicks on them trigger the + * context menu. The handler checks event->button and only acts on + * right-click (button 3) and middle-click (button 2), so normal + * left-clicks on the close button still work. */ + gtk_widget_add_events(tab->favicon, GDK_BUTTON_PRESS_MASK); + g_signal_connect(tab->favicon, "button-press-event", + G_CALLBACK(on_tab_label_button_press), tab); + gtk_widget_add_events(tab->title_label, GDK_BUTTON_PRESS_MASK); + g_signal_connect(tab->title_label, "button-press-event", + G_CALLBACK(on_tab_label_button_press), tab); gtk_widget_show_all(box); return box; @@ -2372,6 +2513,15 @@ void tab_manager_init(GtkContainer *parent, gtk_notebook_set_show_border(GTK_NOTEBOOK(g_notebook), FALSE); gtk_notebook_set_show_tabs(GTK_NOTEBOOK(g_notebook), TRUE); + /* Connect button-press on the notebook to handle right-click context + * menus on tabs. This is more reliable than connecting to individual + * tab label child widgets, because GtkNotebook intercepts button + * presses on tabs for tab switching before they reach the label's + * children. */ + gtk_widget_add_events(g_notebook, GDK_BUTTON_PRESS_MASK); + g_signal_connect(g_notebook, "button-press-event", + G_CALLBACK(on_notebook_button_press), NULL); + /* New-tab button as an action widget at the end of the tab strip. */ GtkWidget *new_btn = gtk_button_new(); gtk_button_set_relief(GTK_BUTTON(new_btn), GTK_RELIEF_NONE); @@ -2686,6 +2836,23 @@ void tab_manager_duplicate(int index) { tab_manager_new_tab(tab->current_url); } +void tab_manager_open_in_new_window(int index) { + if (index < 0 || index >= g_tab_count) return; + tab_info_t *tab = g_tabs[index]; + if (tab == NULL) return; + /* Open the tab's current URL in a new window. Pass the active + * webview as the related view so the new window shares the + * WebProcess. */ + tab_manager_new_window(tab->current_url, tab->webview); +} + +void tab_manager_new_window_blank(void) { + /* Open a new window with the default new-tab URL. Pass the active + * webview as the related view for WebProcess sharing. */ + tab_info_t *tab = tab_manager_get_active(); + tab_manager_new_window(NULL, tab ? tab->webview : NULL); +} + void tab_manager_reload(int index) { if (index < 0 || index >= g_tab_count) return; tab_info_t *tab = g_tabs[index]; @@ -2727,6 +2894,19 @@ static window_state_t *window_state_for_notebook(GtkWidget *notebook) { * scheme works. It is packed into the sidebar container (the left pane * of the window-level GtkPaned). Called the first time the sidebar is * shown for a window. */ + +/* Close button callback for the sidebar floating X button. */ +static void on_sidebar_close_clicked(GtkButton *btn, gpointer user_data) { + (void)btn; + window_state_t *ws = (window_state_t *)user_data; + if (ws == NULL) return; + if (ws->sidebar_visible) { + gtk_paned_set_position(GTK_PANED(ws->paned), 0); + gtk_widget_hide(ws->sidebar_container); + ws->sidebar_visible = FALSE; + } +} + static void sidebar_create_webview(window_state_t *ws) { if (ws == NULL || ws->sidebar_webview != NULL) return; if (g_ctx == NULL) return; @@ -2753,9 +2933,36 @@ static void sidebar_create_webview(window_state_t *ws) { gtk_widget_set_vexpand(GTK_WIDGET(ws->sidebar_webview), TRUE); gtk_widget_set_hexpand(GTK_WIDGET(ws->sidebar_webview), TRUE); - /* Pack into the sidebar container. */ + /* Use a GtkOverlay to float a close (X) button in the top-right + * corner over the webview, without a separate header bar. This + * saves vertical space — the X sits on the same row as the chat + * page's tab bar (Chat / Conversations / Skills). */ + GtkWidget *overlay = gtk_overlay_new(); + gtk_widget_set_vexpand(overlay, TRUE); + gtk_widget_set_hexpand(overlay, TRUE); + + gtk_container_add(GTK_CONTAINER(overlay), + GTK_WIDGET(ws->sidebar_webview)); + + /* Floating close button — top-right corner. */ + GtkWidget *close_btn = gtk_button_new(); + gtk_button_set_relief(GTK_BUTTON(close_btn), GTK_RELIEF_NONE); + gtk_button_set_image(GTK_BUTTON(close_btn), + gtk_image_new_from_icon_name("window-close-symbolic", + GTK_ICON_SIZE_MENU)); + gtk_widget_set_tooltip_text(close_btn, "Close sidebar"); + gtk_widget_set_halign(close_btn, GTK_ALIGN_END); + gtk_widget_set_valign(close_btn, GTK_ALIGN_START); + gtk_widget_set_margin_top(close_btn, 2); + gtk_widget_set_margin_end(close_btn, 2); + g_signal_connect(close_btn, "clicked", + G_CALLBACK(on_sidebar_close_clicked), ws); + + gtk_overlay_add_overlay(GTK_OVERLAY(overlay), close_btn); + + /* Pack the overlay into the sidebar container. */ gtk_box_pack_start(GTK_BOX(ws->sidebar_container), - GTK_WIDGET(ws->sidebar_webview), TRUE, TRUE, 0); + overlay, TRUE, TRUE, 0); /* Load the chat page. */ webkit_web_view_load_uri(ws->sidebar_webview, AGENT_CHAT_URL_STR); diff --git a/src/tab_manager.h b/src/tab_manager.h index e2bab06..81a8bd4 100644 --- a/src/tab_manager.h +++ b/src/tab_manager.h @@ -51,6 +51,19 @@ void tab_manager_init(GtkContainer *parent, */ int tab_manager_new_tab(const char *url); +/* + * Open the tab at the given index's URL in a new browser window. + * Creates a new top-level GtkWindow with its own notebook and a single + * tab loading the same URL. No-op if the index is out of range. + */ +void tab_manager_open_in_new_window(int index); + +/* + * Open a new browser window with a blank tab (or the default new-tab + * URL). Used by the Ctrl+N shortcut. + */ +void tab_manager_new_window_blank(void); + /* * Close the tab at the given index. If it's the last tab, the window * destroy handler will fire (caller is responsible for quitting). diff --git a/src/version.h b/src/version.h index 76053ef..8523332 100644 --- a/src/version.h +++ b/src/version.h @@ -11,9 +11,9 @@ #ifndef SOVEREIGN_BROWSER_VERSION_H #define SOVEREIGN_BROWSER_VERSION_H -#define SB_VERSION "v0.0.28" +#define SB_VERSION "v0.0.29" #define SB_VERSION_MAJOR 0 #define SB_VERSION_MINOR 0 -#define SB_VERSION_PATCH 28 +#define SB_VERSION_PATCH 29 #endif /* SOVEREIGN_BROWSER_VERSION_H */