diff --git a/src/config/Constants.hpp b/src/config/Constants.hpp index 0259c2e..693ee7d 100644 --- a/src/config/Constants.hpp +++ b/src/config/Constants.hpp @@ -20,6 +20,10 @@ inline constexpr int kDefaultMaxHistoryItems = 70; // Default open shortcut as a GNOME accelerator (matches core::kDefaultPreset, // Super+V). Stored verbatim in settings and written straight to gsettings. inline constexpr std::string_view kDefaultHotkeyAccelerator = "v"; +// In-window actions, same accelerator syntax as the open shortcut ("Return", +// "Return"). Parsed with gtk_accelerator_parse at the key handler. +inline constexpr std::string_view kDefaultPasteAccelerator = "Return"; +inline constexpr std::string_view kDefaultPinAccelerator = "Return"; inline constexpr std::string_view kHistoryDbName = "history.db"; inline constexpr std::string_view kSettingsFileName = "settings.json"; inline constexpr std::string_view kInstanceSocketName = "copyclip.sock"; diff --git a/src/core/Models.hpp b/src/core/Models.hpp index fd72d37..51a794a 100644 --- a/src/core/Models.hpp +++ b/src/core/Models.hpp @@ -78,6 +78,9 @@ struct Settings { // GNOME accelerator for the open shortcut (e.g. "v"); free-form, so a // string rather than the preset enum. Presets remain as UI quick-picks. std::string hotkey{config::kDefaultHotkeyAccelerator}; + // In-window accelerators (same string form): paste / pin the highlighted clip. + std::string paste_hotkey{config::kDefaultPasteAccelerator}; + std::string pin_hotkey{config::kDefaultPinAccelerator}; bool first_run_completed = false; int max_history_items = config::kDefaultMaxHistoryItems; bool auto_hide_on_copy = true; diff --git a/src/storage/JsonSettingsRepository.cpp b/src/storage/JsonSettingsRepository.cpp index 58dbac2..6be5051 100644 --- a/src/storage/JsonSettingsRepository.cpp +++ b/src/storage/JsonSettingsRepository.cpp @@ -25,6 +25,8 @@ namespace { // dataclass field names verbatim (it serializes via dataclasses.asdict()). constexpr const char* kKeyTheme = "theme"; constexpr const char* kKeyHotkey = "hotkey"; +constexpr const char* kKeyPasteHotkey = "paste_hotkey"; +constexpr const char* kKeyPinHotkey = "pin_hotkey"; constexpr const char* kKeyFirstRunCompleted = "first_run_completed"; constexpr const char* kKeyMaxHistoryItems = "max_history_items"; constexpr const char* kKeyAutoHideOnCopy = "auto_hide_on_copy"; @@ -70,6 +72,8 @@ constexpr int kJsonIndent = 2; .theme = *theme, // Accept both legacy preset tokens ("super_v") and raw accelerators. .hotkey = core::accelerator_from_stored(hotkey_text), + .paste_hotkey = json.value(kKeyPasteHotkey, defaults.paste_hotkey), + .pin_hotkey = json.value(kKeyPinHotkey, defaults.pin_hotkey), .first_run_completed = json.value(kKeyFirstRunCompleted, defaults.first_run_completed), .max_history_items = json.value(kKeyMaxHistoryItems, defaults.max_history_items), .auto_hide_on_copy = json.value(kKeyAutoHideOnCopy, defaults.auto_hide_on_copy), @@ -115,6 +119,8 @@ void JsonSettingsRepository::save(const core::Settings& settings) { const nlohmann::json json = {{kKeyTheme, core::to_string(settings.theme)}, {kKeyHotkey, settings.hotkey}, + {kKeyPasteHotkey, settings.paste_hotkey}, + {kKeyPinHotkey, settings.pin_hotkey}, {kKeyFirstRunCompleted, settings.first_run_completed}, {kKeyMaxHistoryItems, settings.max_history_items}, {kKeyAutoHideOnCopy, settings.auto_hide_on_copy}, diff --git a/src/ui/CMakeLists.txt b/src/ui/CMakeLists.txt index 3516922..3dd9375 100644 --- a/src/ui/CMakeLists.txt +++ b/src/ui/CMakeLists.txt @@ -30,6 +30,7 @@ add_library( StatusNotifierItem.cpp KeystrokePaster.cpp CopyAction.cpp + KeyAction.cpp widgets/ClipCard.cpp widgets/ShortcutChooser.cpp dialogs/SettingsDialog.cpp diff --git a/src/ui/Constants.hpp b/src/ui/Constants.hpp index 703d4cb..add9ec6 100644 --- a/src/ui/Constants.hpp +++ b/src/ui/Constants.hpp @@ -27,6 +27,14 @@ inline constexpr int kContentMargin = 12; // sheets. inline constexpr int kDialogContentWidth = 400; +// How long a settings toast stays visible (AdwToast timeout, in seconds). +inline constexpr unsigned int kSettingsToastTimeoutSec = 3; + +// Short enough for AdwToast's single-line ellipsis; contextual by which side +// collided so the user knows which row already owns the combo. +inline constexpr const char* kToastPasteCollidesWithPin = "Already used by Pin"; +inline constexpr const char* kToastPinCollidesWithPaste = "Already used by Paste"; + // Code points shown on a collapsed card before truncation. inline constexpr std::size_t kMaxPreviewChars = 120; diff --git a/src/ui/KeyAction.cpp b/src/ui/KeyAction.cpp new file mode 100644 index 0000000..09a9afc --- /dev/null +++ b/src/ui/KeyAction.cpp @@ -0,0 +1,101 @@ +#include "ui/KeyAction.hpp" + +#include +#include + +#include + +namespace copyclip::ui { + +namespace { + +[[nodiscard]] bool is_return_key(unsigned int keyval) { + return keyval == GDK_KEY_Return || keyval == GDK_KEY_KP_Enter || keyval == GDK_KEY_ISO_Enter; +} + +// Keys that activate a focused button (Return family and space). A paste binding +// that is just one of these unmodified must yield when a button holds focus; +// modified paste combos (e.g. Ctrl+V) never do — buttons do not handle them. +[[nodiscard]] bool is_unmodified_activate_key(unsigned int keyval, unsigned int modifiers) { + if (modifiers != 0) { + return false; + } + return is_return_key(keyval) || keyval == GDK_KEY_space; +} + +} // namespace + +bool BoundAccelerator::matches(unsigned int event_keyval, unsigned int event_modifiers) const { + if (keyval == 0) { + return false; + } + if (modifiers != event_modifiers) { + return false; + } + if (keyval == event_keyval) { + return true; + } + // One binding for Enter covers the physical Return and keypad variants. + return is_return_key(keyval) && is_return_key(event_keyval); +} + +BoundAccelerator bound_accelerator(std::string_view accelerator) { + if (accelerator.empty()) { + return {}; + } + const std::string name{accelerator}; + guint keyval = 0; + GdkModifierType mods = static_cast(0); + gtk_accelerator_parse(name.c_str(), &keyval, &mods); + if (keyval == 0) { + return {}; + } + // Keep only the bits gtk_accelerator_get_default_mod_mask cares about, so a + // match against the event's masked state is an equality check. + const auto mask = static_cast(gtk_accelerator_get_default_mod_mask()); + return BoundAccelerator{.keyval = keyval, .modifiers = static_cast(mods) & mask}; +} + +KeyAction key_action(unsigned int keyval, unsigned int modifiers, const KeyContext& context, + const WindowShortcuts& shortcuts) { + switch (keyval) { + case GDK_KEY_Escape: + // First Escape drops an active search, a second dismisses — so narrowing a + // search is undoable without losing the window. Modifiers on Escape are + // ignored: Esc is always the window's own. + return context.search_active ? KeyAction::ClearSearch : KeyAction::Dismiss; + default: + break; + } + + // Configured actions before the fixed arrows, so a user can rebind over them. + if (shortcuts.pin.matches(keyval, modifiers)) { + return KeyAction::TogglePin; + } + if (shortcuts.paste.matches(keyval, modifiers)) { + // A focused button owns unmodified activate keys (Return, space). Modified + // paste bindings still paste — Settings/Clear never consume Ctrl+V. + if (context.button_focused && is_unmodified_activate_key(keyval, modifiers)) { + return KeyAction::None; + } + return KeyAction::Paste; + } + + // Bare arrows only: with modifiers, leave Ctrl/Shift+arrows to the search entry + // (word jump / extend selection). Capture-phase would otherwise steal them. + if (modifiers == 0) { + switch (keyval) { + case GDK_KEY_Up: + case GDK_KEY_KP_Up: + return KeyAction::SelectPrevious; + case GDK_KEY_Down: + case GDK_KEY_KP_Down: + return KeyAction::SelectNext; + default: + break; + } + } + return KeyAction::None; // typing, Tab, modified arrows, everything else +} + +} // namespace copyclip::ui diff --git a/src/ui/KeyAction.hpp b/src/ui/KeyAction.hpp new file mode 100644 index 0000000..0a991d8 --- /dev/null +++ b/src/ui/KeyAction.hpp @@ -0,0 +1,58 @@ +#pragma once + +// The main window's keyboard policy, as a pure decision: which action a key press +// asks for, given what the window currently holds and the user's configured +// paste/pin accelerators. No widgets are touched here, so the policy unit-tests +// headless while MainWindow keeps only the dispatch. + +#include +#include + +namespace copyclip::ui { + +// What a key press asks the main window to do. +enum class KeyAction : std::uint8_t { + None, // not ours — let the focused widget have it + ClearSearch, // drop the active filter, keeping the window up + Dismiss, // hide the window without pasting + SelectPrevious, // move the highlight to the previous match + SelectNext, // move the highlight to the next match + Paste, // paste the highlighted clip + TogglePin, // pin / unpin the highlighted clip +}; + +// The window state the policy depends on. Grouped in a struct so the call site +// reads as named fields rather than a row of anonymous bools. +struct KeyContext { + bool search_active = false; // the search entry holds text + bool button_focused = false; // a button has focus, so it owns Enter +}; + +// A parsed GNOME accelerator (keyval + modifier mask). Produced by +// bound_accelerator() from the settings string; MainWindow parses on each key +// press (cheap) so a Settings change is live without a cache invalidation path. +struct BoundAccelerator { + unsigned int keyval = 0; + unsigned int modifiers = 0; + + // True when the event matches this binding. Return / KP_Enter / ISO_Enter + // are treated as the same key so the default paste binding covers them all. + [[nodiscard]] bool matches(unsigned int event_keyval, unsigned int event_modifiers) const; +}; + +// Parse a GNOME accelerator string ("Return", "Return"). Invalid or +// empty strings yield a never-matching binding (keyval 0). +[[nodiscard]] BoundAccelerator bound_accelerator(std::string_view accelerator); + +// The two in-window shortcuts Settings stores as accelerator strings. +struct WindowShortcuts { + BoundAccelerator paste; + BoundAccelerator pin; +}; + +// Map a key event to the action it asks for. `modifiers` must already be masked +// to gtk_accelerator_get_default_mod_mask() so it lines up with BoundAccelerator. +[[nodiscard]] KeyAction key_action(unsigned int keyval, unsigned int modifiers, + const KeyContext& context, const WindowShortcuts& shortcuts); + +} // namespace copyclip::ui diff --git a/src/ui/MainWindow.cpp b/src/ui/MainWindow.cpp index ba0ca58..7fd9963 100644 --- a/src/ui/MainWindow.cpp +++ b/src/ui/MainWindow.cpp @@ -4,11 +4,14 @@ #include "ui/ClipText.hpp" #include "ui/Constants.hpp" #include "ui/Fuzzy.hpp" +#include "ui/KeyAction.hpp" #include "ui/Theme.hpp" #include "ui/widgets/ClipCard.hpp" +#include #include #include +#include #include #include @@ -77,6 +80,33 @@ void trim_heap() { return 0; } +// True when a button holds the focus (see KeyAction for why that matters). +[[nodiscard]] bool button_focused(GtkWindow* window) { + GtkWidget* focus = gtk_window_get_focus(window); + return focus != nullptr && GTK_IS_BUTTON(focus) != FALSE; +} + +// True while focus sits inside a popover — the search entry's right-click Cut/Copy/ +// Paste menu, or the emoji chooser. Those are not dialogs, so the dialog guard misses +// them, and they own their own Escape and arrow keys. +[[nodiscard]] bool popover_focused(GtkWindow* window) { + GtkWidget* focus = gtk_window_get_focus(window); + return focus != nullptr && gtk_widget_get_ancestor(focus, GTK_TYPE_POPOVER) != nullptr; +} + +// The first row at or after `start`, scanning in display order or against it, that +// the search filter left visible — or nullptr once the scan runs off the list. +[[nodiscard]] Gtk::ListBoxRow* visible_row_from(Gtk::Widget* start, bool forward) { + for (Gtk::Widget* candidate = start; candidate != nullptr; + candidate = forward ? candidate->get_next_sibling() : candidate->get_prev_sibling()) { + auto* row = dynamic_cast(candidate); + if (row != nullptr && row->get_visible()) { + return row; + } + } + return nullptr; +} + } // namespace MainWindow::MainWindow(GtkApplication* application, core::HistoryService& history, @@ -106,11 +136,23 @@ void MainWindow::build_ui(GtkApplication* application) { // Closing hides the window so the app keeps capturing in the background. g_signal_connect(window_, "close-request", - G_CALLBACK(+[](GtkWindow* window, gpointer) -> gboolean { - gtk_widget_set_visible(GTK_WIDGET(window), FALSE); + G_CALLBACK(+[](GtkWindow*, gpointer self) -> gboolean { + static_cast(self)->hide(); return TRUE; }), - nullptr); + this); + + // Escape, Up/Down and Enter are the window's own, wherever focus sits — capture + // phase so they are read before the focused child's stock bindings (the search + // entry would otherwise swallow Enter, which is the whole bug). + auto key_controller = Gtk::EventControllerKey::create(); + key_controller->set_propagation_phase(Gtk::PropagationPhase::CAPTURE); + key_controller->signal_key_pressed().connect(sigc::mem_fun(*this, &MainWindow::on_key_pressed), + false); + // add_controller takes ownership of a ref, so gobj_copy() mints it — unlike the + // plain gobj() handoff used for child widgets below, which would double-unref here. + gtk_widget_add_controller(GTK_WIDGET(window_), + GTK_EVENT_CONTROLLER(key_controller->gobj_copy())); // Trim on every hide — close, copy, and toggle all route through this signal. g_signal_connect(window_, "hide", G_CALLBACK(+[](GtkWidget*, gpointer) { trim_heap(); }), @@ -185,17 +227,20 @@ void MainWindow::build_ui(GtkApplication* application) { stack_ = Gtk::make_managed(); stack_->set_vexpand(true); - auto* scrolled = Gtk::make_managed(); - scrolled->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); + scrolled_ = Gtk::make_managed(); + scrolled_->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC); list_ = Gtk::make_managed(); - list_->set_selection_mode(Gtk::SelectionMode::NONE); + // Single selection is the keyboard cursor: apply_filter keeps exactly one match + // highlighted, the arrow keys move it, and Enter pastes it. Row activation is + // left alone — the mouse is ClipCard's own gesture (copy / Ctrl-pin). + list_->set_selection_mode(Gtk::SelectionMode::SINGLE); list_->add_css_class("background"); list_->set_valign(Gtk::Align::START); // Keep rows ordered so incrementally-added cards land in place (see rebuild_cards). list_->set_sort_func( [](Gtk::ListBoxRow* a, Gtk::ListBoxRow* b) { return clip_card_sort(a, b); }); - scrolled->set_child(*list_); - stack_->add(*scrolled, kPageList); + scrolled_->set_child(*list_); + stack_->add(*scrolled_, kPageList); auto* empty = Gtk::make_managed(Gtk::Orientation::VERTICAL, kContentMargin); empty->set_valign(Gtk::Align::CENTER); @@ -232,6 +277,12 @@ void MainWindow::rebuild_cards() { const std::vector entries = history_.get().entries(); card_count_ = entries.size(); + // Pinning recreates the very card the cursor sits on, taking the selection with + // it. Remember what it held so the cursor can be put back below, instead of + // apply_filter snapping it to the top of the list. + const auto* selected = dynamic_cast(list_->get_selected_row()); + const std::string selected_content = selected != nullptr ? selected->entry().content : ""; + // Index the entries we want shown, by their key, for O(1) lookup below. std::map wanted; for (const core::ClipboardEntry& entry : entries) { @@ -279,6 +330,12 @@ void MainWindow::rebuild_cards() { cards_.emplace(entry.content, card); } + // Put the cursor back on the card it was on, recreated or not. Gone for good — + // cleared, evicted, filtered out — leaves apply_filter to pick the fallback. + if (const auto card = cards_.find(selected_content); card != cards_.end()) { + list_->select_row(*card->second); + } + list_->invalidate_sort(); apply_filter(); } @@ -287,6 +344,9 @@ void MainWindow::apply_filter() { // The search bar is only useful once there is something to search. search_->set_visible(card_count_ > 0); + const Gtk::ListBoxRow* selected = list_->get_selected_row(); + ClipCard* first_shown = nullptr; + bool selection_shown = false; std::size_t visible = 0; for (Gtk::Widget* child = list_->get_first_child(); child != nullptr; child = child->get_next_sibling()) { @@ -296,8 +356,26 @@ void MainWindow::apply_filter() { } const bool shown = matches(card->content()); card->set_visible(shown); - if (shown) { - ++visible; + if (!shown) { + continue; + } + ++visible; + if (first_shown == nullptr) { + first_shown = card; + } + selection_shown = selection_shown || card == selected; + } + + // Always leave a row highlighted for Enter to act on. When the filter — or a + // rebuild that dropped the card — took the selection away, fall back to the + // first match and scroll back up to it: hidden rows get no allocation, so that + // row sits at the very top of the list. + if (!selection_shown) { + if (first_shown != nullptr) { + list_->select_row(*first_shown); + scrolled_->get_vadjustment()->set_value(0.0); + } else { + list_->unselect_all(); } } @@ -315,7 +393,133 @@ void MainWindow::apply_filter() { stack_->set_visible_child(kPageEmpty); } +bool MainWindow::on_key_pressed(guint keyval, guint /*keycode*/, Gdk::ModifierType state) { + // Settings and first-run are AdwDialogs presented inside this very window, and + // menus are popovers in it — while either is up, its own keys must win. + if (adw_application_window_get_visible_dialog(window_) != nullptr || + popover_focused(GTK_WINDOW(window_))) { + return false; + } + // Read the entry, not search_text_: GtkSearchEntry delays search-changed by + // ~150 ms, so the cached copy lags behind text the user just typed — Escape + // right after the first keystroke must still read as an active search. + // Caveat: an in-progress input-method preedit is not detected, so Enter and + // the arrows preempt CJK candidate selection. Filter through Gtk::IMContext if + // that ever matters. + const KeyContext context{.search_active = !search_->get_text().empty(), + .button_focused = button_focused(GTK_WINDOW(window_))}; + // Same GNOME accelerator strings Settings stores; mask matches BoundAccelerator. + const auto mod_mask = static_cast(gtk_accelerator_get_default_mod_mask()); + const auto modifiers = static_cast(state & mod_mask); + const core::Settings& cfg = settings_.get().settings(); + const WindowShortcuts shortcuts{.paste = bound_accelerator(cfg.paste_hotkey), + .pin = bound_accelerator(cfg.pin_hotkey)}; + switch (key_action(keyval, modifiers, context, shortcuts)) { + case KeyAction::ClearSearch: + clear_search(); + return true; + case KeyAction::Dismiss: + hide(); + return true; + case KeyAction::SelectPrevious: + // Flush the debounced filter first so navigation matches what the user typed. + sync_search_from_entry(); + move_selection(false); + return true; + case KeyAction::SelectNext: + sync_search_from_entry(); + move_selection(true); + return true; + case KeyAction::Paste: + sync_search_from_entry(); + return activate_selection(); + case KeyAction::TogglePin: + // Pin targets the selected row; keep the filter in sync so the cursor + // still points at a visible match after a just-typed query. + sync_search_from_entry(); + return pin_selection(); + case KeyAction::None: + return false; + } + // No default case, so a new KeyAction trips -Wswitch rather than being ignored. + return false; +} + +void MainWindow::move_selection(bool forward) { + // Ctrl+click reaches GtkListBox as a toggle and leaves the list unselected, so + // the cursor can be missing even with matches on screen. Start the scan at the + // top then, rather than letting the arrows go dead. + Gtk::Widget* start = list_->get_first_child(); + if (Gtk::ListBoxRow* const current = list_->get_selected_row(); current != nullptr) { + start = forward ? current->get_next_sibling() : current->get_prev_sibling(); + } + // Stop at the ends rather than wrapping: with the list also acting as the + // paste target, wrapping past the last row invites pasting the wrong clip. + if (Gtk::ListBoxRow* const next = visible_row_from(start, forward); next != nullptr) { + list_->select_row(*next); + reveal(*next); + } +} + +ClipCard* MainWindow::selected_card() const { + return dynamic_cast(list_->get_selected_row()); +} + +void MainWindow::clear_search() { + // Entry first so a late search-changed still sees empty; then force the + // filter state without waiting for GtkSearchEntry's ~150 ms debounce. + search_->set_text(""); + search_text_.clear(); + apply_filter(); +} + +void MainWindow::sync_search_from_entry() { + const std::string live = search_->get_text().raw(); + if (live == search_text_) { + return; + } + search_text_ = live; + apply_filter(); +} + +bool MainWindow::activate_selection() { + ClipCard* const card = selected_card(); + if (card == nullptr) { + return false; + } + // Straight through, unlike ClipCard's click: the rebuild copy() sets off is + // itself deferred (see schedule_refresh), so no widget dies under this dispatch. + copy(card->entry()); + return true; +} + +bool MainWindow::pin_selection() { + ClipCard* const card = selected_card(); + if (card == nullptr) { + return false; + } + // Same path as Ctrl+click on a card; rebuild_cards keeps the keyboard cursor. + pin(card->entry().content); + return true; +} + +void MainWindow::reveal(Gtk::ListBoxRow& row) { + double row_x = 0.0; + double row_y = 0.0; + if (row.translate_coordinates(*list_, 0.0, 0.0, row_x, row_y)) { + // The list is what the viewport scrolls, so list coordinates are the + // adjustment's own. + scrolled_->get_vadjustment()->clamp_page(row_y, row_y + row.get_height()); + } +} + void MainWindow::copy(const core::ClipboardEntry& entry) { + // ClipCard defers its click to an idle, so a fast double click can queue two + // copies of the same clip — which with auto-paste on injects Ctrl+V twice into + // the target app. The first one hid the window, so that is the signal to stop. + if (gtk_widget_get_visible(GTK_WIDGET(window_)) == FALSE) { + return; + } // Reconstruct the clipboard payload for the entry's kind. Image bytes are // fetched lazily by hash; rich text carries its HTML alongside the plain text. core::ClipContent content; @@ -330,11 +534,27 @@ void MainWindow::copy(const core::ClipboardEntry& entry) { } // CopyAction handles clipboard + history + auto-paste; the window just hides. if (copy_action_.run(content)) { - gtk_widget_set_visible(GTK_WIDGET(window_), FALSE); + hide(); } } +void MainWindow::hide() { + // Drop the filter on the way out, whichever path got here — pasting, clicking, + // closing or toggling. The window is a popup: reopening it on someone's old + // query (which grab_focus leaves unselected, so typing appends to it) is never + // what was meant. clear_search() also syncs search_text_ immediately so a + // fast hide→present cannot reopen with a stale filter. + clear_search(); + gtk_widget_set_visible(GTK_WIDGET(window_), FALSE); +} + void MainWindow::pin(const std::string& content) { + // Ctrl+click reaches ListBox as a selection toggle under SINGLE mode and can + // leave nothing selected before rebuild_cards snapshots the cursor. Re-assert + // the pin target as the selection so the keyboard cursor survives the rebuild. + if (const auto it = cards_.find(content); it != cards_.end()) { + list_->select_row(*it->second); + } history_.get().toggle_pin(content); } @@ -370,7 +590,7 @@ GtkWidget* MainWindow::native() const { void MainWindow::toggle() { if (gtk_widget_get_visible(GTK_WIDGET(window_)) != FALSE) { - gtk_widget_set_visible(GTK_WIDGET(window_), FALSE); + hide(); return; } present(); diff --git a/src/ui/MainWindow.hpp b/src/ui/MainWindow.hpp index 7c19526..b90fbd0 100644 --- a/src/ui/MainWindow.hpp +++ b/src/ui/MainWindow.hpp @@ -9,6 +9,11 @@ // new clip touches one widget, not all N. Search toggles each card's visibility in // place. Refreshes are deferred to an idle so a card may safely trigger one from // inside its own click handler. +// +// The window is keyboard-first: focus stays in the search entry, Up/Down move the +// highlighted row through the filtered list, Enter pastes it, and Escape drops the +// search before dismissing. A window-level key controller owns that policy, so the +// keys work wherever focus happens to sit. #include "core/HistoryService.hpp" #include "core/Interfaces.hpp" @@ -20,11 +25,17 @@ #include +#include + #include #include +#include +#include #include #include +#include + #include #include #include @@ -62,7 +73,31 @@ class MainWindow { void schedule_refresh(); void rebuild_cards(); void apply_filter(); + // The window's keyboard policy — Escape, Up/Down, Enter and Ctrl+Enter. + // Returns true when the key was consumed; everything else falls through to + // the focused widget. + bool on_key_pressed(guint keyval, guint keycode, Gdk::ModifierType state); + // Move the highlighted row to the next/previous match, and scroll it into view. + void move_selection(bool forward); + // Paste the highlighted clip. False when there is nothing highlighted, so the + // key keeps its stock behavior. + bool activate_selection(); + // Pin / unpin the highlighted clip. False when nothing is selected. + bool pin_selection(); + // The selected ClipCard, or nullptr when the list has no keyboard cursor. + [[nodiscard]] ClipCard* selected_card() const; + // Drop the filter immediately (entry + cached query + list). GtkSearchEntry's + // search-changed is delayed ~150 ms; Escape and hide must not wait for it. + void clear_search(); + // Pull the live entry into search_text_ and refilter if it changed. Call before + // paste/arrows so a fast type→Enter does not act on the pre-debounce list. + void sync_search_from_entry(); + // Scroll the list so `row` is visible — moving the selection from the search + // entry never moves focus, so GTK won't scroll for us. + void reveal(Gtk::ListBoxRow& row); void copy(const core::ClipboardEntry& entry); + // Hide the window; the app keeps capturing in the background. + void hide(); void pin(const std::string& content); void clear_history(); void open_settings(); @@ -77,6 +112,7 @@ class MainWindow { std::string search_text_; AdwApplicationWindow* window_ = nullptr; Gtk::Stack* stack_ = nullptr; + Gtk::ScrolledWindow* scrolled_ = nullptr; // holds list_; its vadjustment scrolls it Gtk::ListBox* list_ = nullptr; // Live cards keyed by their clip content, for incremental reconciliation. The // cards are owned by `list_`; these are non-owning observers kept in sync with diff --git a/src/ui/ShortcutText.cpp b/src/ui/ShortcutText.cpp index 63c0897..c29fd3f 100644 --- a/src/ui/ShortcutText.cpp +++ b/src/ui/ShortcutText.cpp @@ -1,5 +1,6 @@ #include "ui/ShortcutText.hpp" +#include "config/Constants.hpp" #include "core/Hotkeys.hpp" #include @@ -19,6 +20,17 @@ std::vector quick_picks() { return picks; } +std::vector paste_quick_picks() { + // Defaults share config:: constants with Settings; extra picks are free-form. + return {{.label = "Enter", .accelerator = std::string{config::kDefaultPasteAccelerator}}, + {.label = "Ctrl+Enter", .accelerator = std::string{config::kDefaultPinAccelerator}}}; +} + +std::vector pin_quick_picks() { + return {{.label = "Ctrl+Enter", .accelerator = std::string{config::kDefaultPinAccelerator}}, + {.label = "Ctrl+P", .accelerator = "p"}}; +} + std::vector parse_keybinding_paths(std::string_view list) { std::vector paths; for (std::size_t open = list.find('\''); open != std::string_view::npos;) { diff --git a/src/ui/ShortcutText.hpp b/src/ui/ShortcutText.hpp index f625527..dda5d4a 100644 --- a/src/ui/ShortcutText.hpp +++ b/src/ui/ShortcutText.hpp @@ -15,10 +15,14 @@ struct QuickPick { std::string accelerator; // GNOME accelerator, e.g. "v" }; -// The built-in presets as quick-picks, in catalog order, so the capture UI can -// offer common combos alongside free-form capture. +// Built-in open-window presets as quick-picks (catalog order), for the global +// shortcut chooser. [[nodiscard]] std::vector quick_picks(); +// Common in-window paste / pin combos offered next to free-form capture. +[[nodiscard]] std::vector paste_quick_picks(); +[[nodiscard]] std::vector pin_quick_picks(); + // Parse a gsettings string-array value ("[]", "@as []", or "['/a/', '/b/']") // into its entries. [[nodiscard]] std::vector parse_keybinding_paths(std::string_view list); diff --git a/src/ui/dialogs/FirstRunDialog.cpp b/src/ui/dialogs/FirstRunDialog.cpp index 6e583c5..4637e57 100644 --- a/src/ui/dialogs/FirstRunDialog.cpp +++ b/src/ui/dialogs/FirstRunDialog.cpp @@ -1,6 +1,7 @@ #include "ui/dialogs/FirstRunDialog.hpp" #include "ui/Constants.hpp" +#include "ui/ShortcutText.hpp" #include #include @@ -38,8 +39,12 @@ FirstRunDialog::FirstRunDialog(GtkWidget* parent, std::string initial_accelerato // The chooser presents its capture sheet on the same window, over the welcome // dialog. Its callback tracks the chosen accelerator for finish(). chooser_ = std::make_unique( - parent, ADW_PREFERENCES_GROUP(group), accelerator_, - [this](const std::string& accelerator) { accelerator_ = accelerator; }); + parent, ADW_PREFERENCES_GROUP(group), + ShortcutChooser::Config{.title = "Open CopyClip", + .tooltip = "Key combination that summons the window", + .require_modifier = true, + .picks = quick_picks()}, + accelerator_, [this](const std::string& accelerator) { accelerator_ = accelerator; }); gtk_box_append(GTK_BOX(content), group); GtkWidget* button = gtk_button_new_with_label("Get Started"); diff --git a/src/ui/dialogs/SettingsDialog.cpp b/src/ui/dialogs/SettingsDialog.cpp index d627d90..1eafc6f 100644 --- a/src/ui/dialogs/SettingsDialog.cpp +++ b/src/ui/dialogs/SettingsDialog.cpp @@ -4,6 +4,8 @@ #include "core/Models.hpp" #include "ui/Constants.hpp" #include "ui/GnomeShortcut.hpp" +#include "ui/KeyAction.hpp" +#include "ui/ShortcutText.hpp" #include @@ -12,6 +14,7 @@ #include #include #include +#include #include #include @@ -23,6 +26,17 @@ namespace { constexpr std::array kThemeOrder{core::Theme::System, core::Theme::Light, core::Theme::Dark}; +// True when two accelerator strings bind the same key+mods (Return / KP_Enter +// count as one). Used so paste and pin can't claim the same combo. +[[nodiscard]] bool accelerators_collide(std::string_view left, std::string_view right) { + const BoundAccelerator a = bound_accelerator(left); + const BoundAccelerator b = bound_accelerator(right); + if (a.keyval == 0 || b.keyval == 0) { + return left == right; // unparseable: fall back to exact string equality + } + return a.matches(b.keyval, b.modifiers); +} + [[nodiscard]] unsigned int theme_index(core::Theme theme) { for (unsigned int i = 0; i < kThemeOrder.size(); ++i) { if (kThemeOrder.at(i) == theme) { @@ -82,7 +96,7 @@ SettingsDialog::SettingsDialog(GtkWidget* parent, core::SettingsService& setting g_signal_connect(theme_row, "notify::selected", G_CALLBACK(&SettingsDialog::on_theme_selected), this); - AdwPreferencesGroup* shortcut_group = add_group(page, "Shortcut"); + AdwPreferencesGroup* shortcut_group = add_group(page, "Shortcuts"); auto* shortcut_enabled_row = ADW_SWITCH_ROW(adw_switch_row_new()); adw_preferences_row_set_title(ADW_PREFERENCES_ROW(shortcut_enabled_row), "Global shortcut"); @@ -94,10 +108,34 @@ SettingsDialog::SettingsDialog(GtkWidget* parent, core::SettingsService& setting g_signal_connect(shortcut_enabled_row, "notify::active", G_CALLBACK(&SettingsDialog::on_shortcut_toggled), this); - // Free-form shortcut capture plus preset quick-picks; applies on change. - shortcut_chooser_ = std::make_unique( - parent, shortcut_group, current.hotkey, - [this](const std::string& accelerator) { apply_accelerator(accelerator); }); + // Same ShortcutChooser for open / paste / pin — only Config differs. + open_chooser_ = std::make_unique( + parent, shortcut_group, + ShortcutChooser::Config{.title = "Open CopyClip", + .tooltip = "Key combination that summons the window", + .require_modifier = true, + .picks = quick_picks()}, + current.hotkey, + [this](const std::string& accelerator) { apply_open_accelerator(accelerator); }); + + paste_chooser_ = std::make_unique( + parent, shortcut_group, + ShortcutChooser::Config{.title = "Paste clip", + .tooltip = "Paste the highlighted clip (Enter by default)", + .require_modifier = false, + .picks = paste_quick_picks()}, + current.paste_hotkey, + [this](const std::string& accelerator) { apply_paste_accelerator(accelerator); }); + + pin_chooser_ = std::make_unique( + parent, shortcut_group, + ShortcutChooser::Config{.title = "Pin / unpin", + .tooltip = + "Pin or unpin the highlighted clip (Ctrl+Enter by default)", + .require_modifier = false, + .picks = pin_quick_picks()}, + current.pin_hotkey, + [this](const std::string& accelerator) { apply_pin_accelerator(accelerator); }); AdwPreferencesGroup* behaviour_group = add_group(page, "Behaviour"); @@ -132,7 +170,10 @@ SettingsDialog::SettingsDialog(GtkWidget* parent, core::SettingsService& setting GtkWidget* toolbar = adw_toolbar_view_new(); adw_toolbar_view_add_top_bar(ADW_TOOLBAR_VIEW(toolbar), adw_header_bar_new()); adw_toolbar_view_set_content(ADW_TOOLBAR_VIEW(toolbar), GTK_WIDGET(page)); - adw_dialog_set_child(dialog, toolbar); + // Toast overlay wraps the sheet so collision warnings land on the dialog. + toast_overlay_ = ADW_TOAST_OVERLAY(adw_toast_overlay_new()); + adw_toast_overlay_set_child(toast_overlay_, toolbar); + adw_dialog_set_child(dialog, GTK_WIDGET(toast_overlay_)); g_signal_connect(dialog, "closed", G_CALLBACK(&SettingsDialog::on_dialog_closed), this); adw_dialog_present(dialog, parent); } @@ -165,14 +206,49 @@ void SettingsDialog::apply_theme(unsigned int index) { on_theme_changed_(); } -void SettingsDialog::apply_accelerator(const std::string& accelerator) { +void SettingsDialog::update_string(std::string core::Settings::*field, const std::string& value) { core::Settings updated = settings_.get().settings(); - updated.hotkey = accelerator; + updated.*field = value; settings_.get().update(updated); +} + +void SettingsDialog::show_toast(const char* title) { + if (toast_overlay_ == nullptr) { + return; + } + AdwToast* toast = adw_toast_new(title); + adw_toast_set_timeout(toast, kSettingsToastTimeoutSec); + adw_toast_overlay_add_toast(toast_overlay_, toast); +} + +void SettingsDialog::apply_open_accelerator(const std::string& accelerator) { + update_string(&core::Settings::hotkey, accelerator); // Only refresh the binding if the shortcut is currently enabled. if (is_gnome_shortcut_registered()) { - register_gnome_shortcut(executable_path(), updated.hotkey); + register_gnome_shortcut(executable_path(), accelerator); + } +} + +void SettingsDialog::apply_paste_accelerator(const std::string& accelerator) { + const core::Settings& current = settings_.get().settings(); + if (accelerators_collide(accelerator, current.pin_hotkey)) { + // Chooser already previewed the combo; roll it back and tell the user. + // Keep the toast short — AdwToast ellipsizes a single line. + paste_chooser_->set_accelerator(current.paste_hotkey); + show_toast(kToastPasteCollidesWithPin); + return; + } + update_string(&core::Settings::paste_hotkey, accelerator); +} + +void SettingsDialog::apply_pin_accelerator(const std::string& accelerator) { + const core::Settings& current = settings_.get().settings(); + if (accelerators_collide(accelerator, current.paste_hotkey)) { + pin_chooser_->set_accelerator(current.pin_hotkey); + show_toast(kToastPinCollidesWithPaste); + return; } + update_string(&core::Settings::pin_hotkey, accelerator); } void SettingsDialog::apply_shortcut_enabled(bool active) { diff --git a/src/ui/dialogs/SettingsDialog.hpp b/src/ui/dialogs/SettingsDialog.hpp index 7e5cb08..1d99310 100644 --- a/src/ui/dialogs/SettingsDialog.hpp +++ b/src/ui/dialogs/SettingsDialog.hpp @@ -2,7 +2,7 @@ // Builds and presents a plain AdwDialog (as a bottom sheet, like the welcome // dialog — an AdwPreferencesDialog floats centered on a fixed-size window) holding -// theme, the open shortcut, and behaviour rows, persisting changes through the +// theme, shortcuts, and behaviour rows, persisting changes through the // SettingsService. Applying the theme live is delegated to a callback (the window // owns the style manager). libadwaita has no C++ binding, so the dialog and its // rows are driven through its C API. @@ -43,17 +43,28 @@ class SettingsDialog { static void on_dialog_closed(AdwDialog* dialog, gpointer self); void apply_theme(unsigned int index); - void apply_accelerator(const std::string& accelerator); + void apply_open_accelerator(const std::string& accelerator); + void apply_paste_accelerator(const std::string& accelerator); + void apply_pin_accelerator(const std::string& accelerator); void apply_shortcut_enabled(bool active); void apply_auto_hide(bool active); void apply_auto_paste(bool active); void apply_panel_icon(bool active); + // Shared "read-modify-write" for string settings fields (paste/pin/open). + void update_string(std::string core::Settings::*field, const std::string& value); + // Toast on the dialog's overlay (collision warnings, etc.). + void show_toast(const char* title); + std::reference_wrapper settings_; ThemeChangedCallback on_theme_changed_; PanelIconChangedCallback on_panel_icon_changed_; ClosedCallback on_closed_; - std::unique_ptr shortcut_chooser_; + AdwToastOverlay* toast_overlay_ = nullptr; // dialog child; presents AdwToasts + // Three choosers share ShortcutChooser; kept alive for the dialog's lifetime. + std::unique_ptr open_chooser_; + std::unique_ptr paste_chooser_; + std::unique_ptr pin_chooser_; }; } // namespace copyclip::ui diff --git a/src/ui/widgets/ShortcutChooser.cpp b/src/ui/widgets/ShortcutChooser.cpp index 238857b..804f1ad 100644 --- a/src/ui/widgets/ShortcutChooser.cpp +++ b/src/ui/widgets/ShortcutChooser.cpp @@ -1,7 +1,6 @@ #include "ui/widgets/ShortcutChooser.hpp" #include "ui/Constants.hpp" -#include "ui/ShortcutText.hpp" #include #include @@ -18,8 +17,9 @@ namespace { constexpr const char* kCustomLabel = "Custom…"; // Shown in the capture sheet until keys are held. -constexpr const char* kCapturePrompt = +constexpr const char* kCapturePromptWithModifier = "Use at least one modifier, e.g. Super or Ctrl. Esc to cancel."; +constexpr const char* kCapturePromptAnyKey = "Press a key or key combination. Esc to cancel."; // The accelerator mask for a lone modifier keyval, so the live preview can show a // modifier the moment it is pressed (its bit isn't in the event state yet). @@ -81,12 +81,14 @@ constexpr const char* kCapturePrompt = } // namespace -ShortcutChooser::ShortcutChooser(GtkWidget* parent, AdwPreferencesGroup* group, std::string initial, - AcceleratorCallback on_changed) - : parent_{parent}, on_changed_{std::move(on_changed)}, accelerator_{std::move(initial)}, - combo_row_{ADW_COMBO_ROW(adw_combo_row_new())} { - adw_preferences_row_set_title(ADW_PREFERENCES_ROW(combo_row_), "Open CopyClip"); - gtk_widget_set_tooltip_text(GTK_WIDGET(combo_row_), "Key combination that summons the window"); +ShortcutChooser::ShortcutChooser(GtkWidget* parent, AdwPreferencesGroup* group, Config config, + std::string initial, AcceleratorCallback on_changed) + : parent_{parent}, config_{std::move(config)}, on_changed_{std::move(on_changed)}, + accelerator_{std::move(initial)}, combo_row_{ADW_COMBO_ROW(adw_combo_row_new())} { + adw_preferences_row_set_title(ADW_PREFERENCES_ROW(combo_row_), config_.title.c_str()); + if (!config_.tooltip.empty()) { + gtk_widget_set_tooltip_text(GTK_WIDGET(combo_row_), config_.tooltip.c_str()); + } adw_preferences_group_add(group, GTK_WIDGET(combo_row_)); g_signal_connect(combo_row_, "notify::selected", G_CALLBACK(&ShortcutChooser::on_selected), this); @@ -110,23 +112,23 @@ ShortcutChooser::~ShortcutChooser() { } bool ShortcutChooser::is_custom() const { - return current_index() == static_cast(quick_picks().size()); + return current_index() == static_cast(config_.picks.size()); } unsigned int ShortcutChooser::current_index() const { - const std::vector picks = quick_picks(); - for (unsigned int i = 0; i < picks.size(); ++i) { - if (picks.at(i).accelerator == accelerator_) { + for (unsigned int i = 0; i < config_.picks.size(); ++i) { + if (config_.picks.at(i).accelerator == accelerator_) { return i; } } - return static_cast(picks.size()); // the custom entry, listed after the presets + return static_cast( + config_.picks.size()); // the custom entry, listed after the presets } void ShortcutChooser::rebuild() { suppress_ = true; GtkStringList* model = gtk_string_list_new(nullptr); - for (const QuickPick& pick : quick_picks()) { + for (const QuickPick& pick : config_.picks) { gtk_string_list_append(model, pick.label.c_str()); } if (is_custom()) { @@ -155,10 +157,9 @@ gboolean ShortcutChooser::dispatch_selection(gpointer self_ptr) { auto* self = static_cast(self_ptr); self->pending_ = 0; const unsigned int index = adw_combo_row_get_selected(self->combo_row_); - const std::vector picks = quick_picks(); - const auto preset_count = static_cast(picks.size()); + const auto preset_count = static_cast(self->config_.picks.size()); if (index < preset_count) { - self->apply(picks.at(index).accelerator); + self->apply(self->config_.picks.at(index).accelerator); } else if (index == (self->is_custom() ? preset_count + 1 : preset_count)) { // "Custom…": restore the selection so cancelling keeps the current shortcut, // then open the capture sheet. @@ -193,7 +194,8 @@ gboolean ShortcutChooser::on_key_pressed(GtkEventControllerKey* /*controller*/, return TRUE; } const auto mods = static_cast(held); - if (held != 0 && gtk_accelerator_valid(keyval, mods) != FALSE) { + const bool mods_ok = !chooser->config_.require_modifier || held != 0; + if (mods_ok && gtk_accelerator_valid(keyval, mods) != FALSE) { // A complete, valid combo: preview and remember it, but commit on release. char* name = gtk_accelerator_name(keyval, mods); chooser->captured_ = name != nullptr ? name : std::string{}; @@ -228,7 +230,9 @@ void ShortcutChooser::show_keys(guint keyval, GdkModifierType mods) { } char* label = gtk_accelerator_get_label(keyval, mods); const bool have = label != nullptr && *label != '\0'; - adw_status_page_set_description(capture_status_, have ? label : kCapturePrompt); + const char* prompt = + config_.require_modifier ? kCapturePromptWithModifier : kCapturePromptAnyKey; + adw_status_page_set_description(capture_status_, have ? label : prompt); g_free(label); } @@ -242,7 +246,9 @@ void ShortcutChooser::open_capture() { adw_status_page_set_icon_name(ADW_STATUS_PAGE(status), "preferences-desktop-keyboard-shortcuts-symbolic"); adw_status_page_set_title(ADW_STATUS_PAGE(status), "Press your shortcut"); - adw_status_page_set_description(ADW_STATUS_PAGE(status), kCapturePrompt); + const char* prompt = + config_.require_modifier ? kCapturePromptWithModifier : kCapturePromptAnyKey; + adw_status_page_set_description(ADW_STATUS_PAGE(status), prompt); capture_status_ = ADW_STATUS_PAGE(status); // live-updated as keys are pressed captured_.clear(); @@ -266,6 +272,14 @@ void ShortcutChooser::open_capture() { adw_dialog_present(capture_dialog_, parent_); } +void ShortcutChooser::set_accelerator(const std::string& accelerator) { + if (accelerator == accelerator_) { + return; + } + accelerator_ = accelerator; + rebuild(); +} + void ShortcutChooser::apply(const std::string& accelerator) { if (accelerator == accelerator_) { return; // no change — skip the rebuild so a reselection can never cycle diff --git a/src/ui/widgets/ShortcutChooser.hpp b/src/ui/widgets/ShortcutChooser.hpp index 9d0d702..b0ae388 100644 --- a/src/ui/widgets/ShortcutChooser.hpp +++ b/src/ui/widgets/ShortcutChooser.hpp @@ -1,16 +1,23 @@ #pragma once -// A reusable open-shortcut picker for the welcome and settings dialogs. It is a -// single AdwComboRow listing the built-in presets plus a trailing "Custom…" +// A reusable shortcut picker for the welcome and settings dialogs. It is a +// single AdwComboRow listing optional built-in presets plus a trailing "Custom…" // entry; choosing "Custom…" opens a sheet that records the next key combo. A // custom shortcut appears as its own selected entry. Reports the chosen GNOME // accelerator through a callback. libadwaita has no C++ binding, so it is driven // through the C API. +// +// One chooser serves the global open shortcut and the in-window paste/pin +// shortcuts — Config sets the row title, quick-picks, and whether capture +// requires a modifier (global yes; plain Enter for paste is allowed). + +#include "ui/ShortcutText.hpp" #include #include #include +#include namespace copyclip::ui { @@ -19,10 +26,20 @@ class ShortcutChooser { // Called with the new GNOME accelerator (e.g. "v") whenever it changes. using AcceleratorCallback = std::function; + // How this row is labeled and what it offers. `picks` may be empty (custom only). + // `require_modifier` is true for the global open shortcut; false so in-window + // actions can bind plain Enter. + struct Config { + std::string title; + std::string tooltip; + bool require_modifier = true; + std::vector picks; + }; + // Builds the combo row into `group`; `parent` is the widget capture sheets are // presented on. `initial` is the accelerator shown at first. - ShortcutChooser(GtkWidget* parent, AdwPreferencesGroup* group, std::string initial, - AcceleratorCallback on_changed); + ShortcutChooser(GtkWidget* parent, AdwPreferencesGroup* group, Config config, + std::string initial, AcceleratorCallback on_changed); // Tears down the capture sheet's signal handlers (bound to this) if one is // still open, so a later key/close event can't reach a destroyed chooser. ~ShortcutChooser(); @@ -34,6 +51,11 @@ class ShortcutChooser { [[nodiscard]] const std::string& accelerator() const { return accelerator_; } + // Restore the displayed accelerator without firing on_changed — used when + // Settings rejects a binding (e.g. paste and pin collide) after the chooser + // has already previewed the new combo. + void set_accelerator(const std::string& accelerator); + private: static void on_selected(GObject* row, GParamSpec* spec, gpointer self); static gboolean dispatch_selection(gpointer self); @@ -51,6 +73,7 @@ class ShortcutChooser { [[nodiscard]] unsigned int current_index() const; GtkWidget* parent_; + Config config_; AcceleratorCallback on_changed_; std::string accelerator_; AdwComboRow* combo_row_; diff --git a/tests/core/ModelsTest.cpp b/tests/core/ModelsTest.cpp index 785f96a..dde45c4 100644 --- a/tests/core/ModelsTest.cpp +++ b/tests/core/ModelsTest.cpp @@ -50,6 +50,8 @@ TEST(ModelsTest, SettingsHaveReferenceDefaults) { const core::Settings settings{}; EXPECT_EQ(settings.theme, core::Theme::Dark); EXPECT_EQ(settings.hotkey, config::kDefaultHotkeyAccelerator); + EXPECT_EQ(settings.paste_hotkey, config::kDefaultPasteAccelerator); + EXPECT_EQ(settings.pin_hotkey, config::kDefaultPinAccelerator); EXPECT_FALSE(settings.first_run_completed); EXPECT_EQ(settings.max_history_items, config::kDefaultMaxHistoryItems); EXPECT_EQ(settings.max_history_items, 70); diff --git a/tests/storage/JsonSettingsRepositoryTest.cpp b/tests/storage/JsonSettingsRepositoryTest.cpp index 69482f9..c8e5fe5 100644 --- a/tests/storage/JsonSettingsRepositoryTest.cpp +++ b/tests/storage/JsonSettingsRepositoryTest.cpp @@ -33,6 +33,8 @@ using copyclip::testing::TempDir; void expect_settings_eq(const core::Settings& actual, const core::Settings& expected) { EXPECT_EQ(actual.theme, expected.theme); EXPECT_EQ(actual.hotkey, expected.hotkey); + EXPECT_EQ(actual.paste_hotkey, expected.paste_hotkey); + EXPECT_EQ(actual.pin_hotkey, expected.pin_hotkey); EXPECT_EQ(actual.first_run_completed, expected.first_run_completed); EXPECT_EQ(actual.max_history_items, expected.max_history_items); EXPECT_EQ(actual.auto_hide_on_copy, expected.auto_hide_on_copy); @@ -73,6 +75,8 @@ TEST_F(JsonSettingsRepositoryTest, LoadReturnsDefaultsWhenMissing) { TEST_F(JsonSettingsRepositoryTest, SaveThenLoadRoundtrip) { const core::Settings saved{.theme = core::Theme::Light, .hotkey = "v", + .paste_hotkey = "v", + .pin_hotkey = "p", .first_run_completed = true, .max_history_items = config::kDefaultMaxHistoryItems, .auto_hide_on_copy = true, diff --git a/tests/ui/CMakeLists.txt b/tests/ui/CMakeLists.txt index af314a7..a4d7a83 100644 --- a/tests/ui/CMakeLists.txt +++ b/tests/ui/CMakeLists.txt @@ -2,3 +2,4 @@ copyclip_add_test(clip_text_test SOURCES ClipTextTest.cpp LIBS copyclip::ui_text copyclip_add_test(shortcut_text_test SOURCES ShortcutTextTest.cpp LIBS copyclip::ui_text) copyclip_add_test(fuzzy_test SOURCES FuzzyTest.cpp LIBS copyclip::ui_text) copyclip_add_test(copy_action_test SOURCES CopyActionTest.cpp LIBS copyclip::ui) +copyclip_add_test(key_action_test SOURCES KeyActionTest.cpp LIBS copyclip::ui) diff --git a/tests/ui/KeyActionTest.cpp b/tests/ui/KeyActionTest.cpp new file mode 100644 index 0000000..bb21693 --- /dev/null +++ b/tests/ui/KeyActionTest.cpp @@ -0,0 +1,155 @@ +#include "ui/KeyAction.hpp" + +#include + +#include +#include + +namespace { + +using copyclip::ui::bound_accelerator; +using copyclip::ui::BoundAccelerator; +using copyclip::ui::key_action; +using copyclip::ui::KeyAction; +using copyclip::ui::KeyContext; +using copyclip::ui::WindowShortcuts; + +// Default bindings: Enter pastes, Ctrl+Enter pins. +const WindowShortcuts kDefaults{ + .paste = BoundAccelerator{.keyval = GDK_KEY_Return, .modifiers = 0}, + .pin = BoundAccelerator{.keyval = GDK_KEY_Return, .modifiers = GDK_CONTROL_MASK}, +}; + +// The window as it sits when it opens: nothing typed, focus in the search entry. +constexpr KeyContext kFresh{.search_active = false, .button_focused = false}; +// Mid-search: text sits in the filter. +constexpr KeyContext kSearching{.search_active = true, .button_focused = false}; +// Tabbed onto a button (header Settings/Clear, or a card's expand toggle). +constexpr KeyContext kOnButton{.search_active = false, .button_focused = true}; + +[[nodiscard]] KeyAction act(unsigned int keyval, unsigned int mods = 0, + const KeyContext& context = kFresh, + const WindowShortcuts& shortcuts = kDefaults) { + return key_action(keyval, mods, context, shortcuts); +} + +// Type to filter, press Enter — it must paste, not fall through to the search +// entry's own Return binding (issue #7). +TEST(KeyActionTest, EnterPastesWhileSearching) { + EXPECT_EQ(act(GDK_KEY_Return, 0, kSearching), KeyAction::Paste); +} + +TEST(KeyActionTest, EnterPastesFromEveryReturnKey) { + EXPECT_EQ(act(GDK_KEY_Return), KeyAction::Paste); + EXPECT_EQ(act(GDK_KEY_KP_Enter), KeyAction::Paste); + EXPECT_EQ(act(GDK_KEY_ISO_Enter), KeyAction::Paste); +} + +// A focused button keeps plain Enter, or tabbing to Settings would paste instead. +TEST(KeyActionTest, EnterOnAButtonIsNotOurs) { + EXPECT_EQ(act(GDK_KEY_Return, 0, kOnButton), KeyAction::None); +} + +// Ctrl+Enter pins the highlighted clip — the keyboard twin of Ctrl+click. +TEST(KeyActionTest, CtrlEnterTogglesPin) { + EXPECT_EQ(act(GDK_KEY_Return, GDK_CONTROL_MASK), KeyAction::TogglePin); + EXPECT_EQ(act(GDK_KEY_KP_Enter, GDK_CONTROL_MASK), KeyAction::TogglePin); + EXPECT_EQ(act(GDK_KEY_ISO_Enter, GDK_CONTROL_MASK), KeyAction::TogglePin); +} + +// Ctrl+Enter still pins mid-search and with a button focused: pin is about the +// list selection, not the focused widget. +TEST(KeyActionTest, CtrlEnterPinsRegardlessOfFocusOrSearch) { + EXPECT_EQ(act(GDK_KEY_Return, GDK_CONTROL_MASK, kSearching), KeyAction::TogglePin); + EXPECT_EQ(act(GDK_KEY_Return, GDK_CONTROL_MASK, kOnButton), KeyAction::TogglePin); + constexpr KeyContext both{.search_active = true, .button_focused = true}; + EXPECT_EQ(act(GDK_KEY_Return, GDK_CONTROL_MASK, both), KeyAction::TogglePin); +} + +// First Escape clears the filter, a second dismisses — narrowing is undoable +// without losing the window. (hide() separately clears the filter on every path.) +TEST(KeyActionTest, EscapeClearsSearchBeforeDismissing) { + EXPECT_EQ(act(GDK_KEY_Escape, 0, kSearching), KeyAction::ClearSearch); + EXPECT_EQ(act(GDK_KEY_Escape, 0, kFresh), KeyAction::Dismiss); +} + +// Escape dismisses from a button too — unlike Enter, no widget owns it. +TEST(KeyActionTest, EscapeIsOursEvenOnAButton) { + EXPECT_EQ(act(GDK_KEY_Escape, 0, kOnButton), KeyAction::Dismiss); +} + +// The one state where the two rules could collide: mid-search with a button +// focused. Enter is still the button's, Escape still clears. +TEST(KeyActionTest, SearchingWithAButtonFocusedKeepsBothRules) { + constexpr KeyContext both{.search_active = true, .button_focused = true}; + EXPECT_EQ(act(GDK_KEY_Return, 0, both), KeyAction::None); + EXPECT_EQ(act(GDK_KEY_Escape, 0, both), KeyAction::ClearSearch); +} + +TEST(KeyActionTest, ArrowsMoveTheSelection) { + EXPECT_EQ(act(GDK_KEY_Up), KeyAction::SelectPrevious); + EXPECT_EQ(act(GDK_KEY_KP_Up), KeyAction::SelectPrevious); + EXPECT_EQ(act(GDK_KEY_Down), KeyAction::SelectNext); + EXPECT_EQ(act(GDK_KEY_KP_Down), KeyAction::SelectNext); +} + +// The arrows stay ours mid-search: that is the whole point of navigating the +// results without leaving the search entry. +TEST(KeyActionTest, ArrowsMoveTheSelectionWhileSearching) { + EXPECT_EQ(act(GDK_KEY_Down, 0, kSearching), KeyAction::SelectNext); +} + +// Modified arrows fall through so the search entry keeps word-jump / extend. +TEST(KeyActionTest, ModifiedArrowsFallThrough) { + EXPECT_EQ(act(GDK_KEY_Down, GDK_CONTROL_MASK), KeyAction::None); + EXPECT_EQ(act(GDK_KEY_Up, GDK_SHIFT_MASK), KeyAction::None); + EXPECT_EQ(act(GDK_KEY_KP_Down, GDK_CONTROL_MASK | GDK_SHIFT_MASK), KeyAction::None); +} + +// Typing must reach the search entry untouched, or the filter stops working. +TEST(KeyActionTest, OtherKeysFallThrough) { + EXPECT_EQ(act(GDK_KEY_a, 0, kSearching), KeyAction::None); + EXPECT_EQ(act(GDK_KEY_space, 0, kSearching), KeyAction::None); + EXPECT_EQ(act(GDK_KEY_BackSpace, 0, kSearching), KeyAction::None); + EXPECT_EQ(act(GDK_KEY_Tab), KeyAction::None); +} + +// Rebinding paste/pin through settings strings is the whole point of storing +// accelerators — a custom Ctrl+P pin must win over the default Ctrl+Enter. +TEST(KeyActionTest, CustomBindingsAreHonoured) { + const WindowShortcuts custom{ + .paste = BoundAccelerator{.keyval = GDK_KEY_v, .modifiers = GDK_CONTROL_MASK}, + .pin = BoundAccelerator{.keyval = GDK_KEY_p, .modifiers = GDK_CONTROL_MASK}, + }; + EXPECT_EQ(act(GDK_KEY_v, GDK_CONTROL_MASK, kFresh, custom), KeyAction::Paste); + EXPECT_EQ(act(GDK_KEY_p, GDK_CONTROL_MASK, kFresh, custom), KeyAction::TogglePin); + // Defaults no longer apply once rebound. + EXPECT_EQ(act(GDK_KEY_Return, 0, kFresh, custom), KeyAction::None); + EXPECT_EQ(act(GDK_KEY_Return, GDK_CONTROL_MASK, kFresh, custom), KeyAction::None); +} + +// Modified paste still pastes when a button has focus — buttons only own bare +// Return/space, not Ctrl+V. +TEST(KeyActionTest, ModifiedPasteIgnoresButtonFocus) { + const WindowShortcuts custom{ + .paste = BoundAccelerator{.keyval = GDK_KEY_v, .modifiers = GDK_CONTROL_MASK}, + .pin = BoundAccelerator{.keyval = GDK_KEY_p, .modifiers = GDK_CONTROL_MASK}, + }; + EXPECT_EQ(act(GDK_KEY_v, GDK_CONTROL_MASK, kOnButton, custom), KeyAction::Paste); +} + +// bound_accelerator parses the same GNOME strings Settings stores. +TEST(KeyActionTest, BoundAcceleratorParsesStoredStrings) { + const BoundAccelerator paste = bound_accelerator("Return"); + EXPECT_EQ(paste.keyval, static_cast(GDK_KEY_Return)); + EXPECT_EQ(paste.modifiers, 0U); + + const BoundAccelerator pin = bound_accelerator("Return"); + EXPECT_EQ(pin.keyval, static_cast(GDK_KEY_Return)); + EXPECT_EQ(pin.modifiers, static_cast(GDK_CONTROL_MASK)); + + EXPECT_EQ(bound_accelerator("").keyval, 0U); + EXPECT_EQ(bound_accelerator("not-a-key").keyval, 0U); +} + +} // namespace diff --git a/tests/ui/ShortcutTextTest.cpp b/tests/ui/ShortcutTextTest.cpp index 9501f3e..c1271ef 100644 --- a/tests/ui/ShortcutTextTest.cpp +++ b/tests/ui/ShortcutTextTest.cpp @@ -9,6 +9,8 @@ namespace { using copyclip::ui::build_keybinding_array; using copyclip::ui::parse_keybinding_paths; +using copyclip::ui::paste_quick_picks; +using copyclip::ui::pin_quick_picks; using copyclip::ui::quick_picks; using copyclip::ui::QuickPick; @@ -25,6 +27,18 @@ TEST(ShortcutTextTest, QuickPicksExposePresetLabelsAndAccelerators) { } } +// In-window paste/pin choosers share the same QuickPick shape with defaults that +// match config::kDefaultPasteAccelerator / kDefaultPinAccelerator. +TEST(ShortcutTextTest, PasteAndPinQuickPicksExposeDefaults) { + const std::vector paste = paste_quick_picks(); + ASSERT_FALSE(paste.empty()); + EXPECT_EQ(paste.front().accelerator, "Return"); + + const std::vector pin = pin_quick_picks(); + ASSERT_FALSE(pin.empty()); + EXPECT_EQ(pin.front().accelerator, "Return"); +} + TEST(ShortcutTextTest, ParsesEntries) { EXPECT_EQ(parse_keybinding_paths("['/x/', '/y/']"), (std::vector{"/x/", "/y/"})); }