Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/ui/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ add_library(
StatusNotifierItem.cpp
KeystrokePaster.cpp
CopyAction.cpp
KeyAction.cpp
widgets/ClipCard.cpp
widgets/ShortcutChooser.cpp
dialogs/SettingsDialog.cpp
Expand Down
30 changes: 30 additions & 0 deletions src/ui/KeyAction.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
#include "ui/KeyAction.hpp"

#include <gdk/gdkkeysyms.h>

namespace copyclip::ui {

KeyAction key_action(unsigned int keyval, const KeyContext& context) {
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.
return context.search_active ? KeyAction::ClearSearch : KeyAction::Dismiss;
case GDK_KEY_Up:
case GDK_KEY_KP_Up:
return KeyAction::SelectPrevious;
case GDK_KEY_Down:
case GDK_KEY_KP_Down:
return KeyAction::SelectNext;
case GDK_KEY_Return:
case GDK_KEY_KP_Enter:
case GDK_KEY_ISO_Enter:
// A focused button owns Enter: the header's Settings/Clear and a card's
// expand toggle must activate what the user tabbed to, not paste.
return context.button_focused ? KeyAction::None : KeyAction::Paste;
default:
return KeyAction::None; // typing, Tab, everything else keeps its stock behavior
}
}

} // namespace copyclip::ui
33 changes: 33 additions & 0 deletions src/ui/KeyAction.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#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. No widgets are touched here, so
// the policy unit-tests headless while MainWindow keeps only the dispatch.

#include <cstdint>

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
};

// 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
};

// Map a GDK keyval (GDK_KEY_*) to the action it asks for. Modifiers are
// deliberately ignored: Ctrl+Enter and the like read as their plain form, since
// none of them mean anything else in this window.
[[nodiscard]] KeyAction key_action(unsigned int keyval, const KeyContext& context);

} // namespace copyclip::ui
191 changes: 179 additions & 12 deletions src/ui/MainWindow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <gtkmm/adjustment.h>
#include <gtkmm/box.h>
#include <gtkmm/button.h>
#include <gtkmm/eventcontrollerkey.h>
#include <gtkmm/image.h>
#include <gtkmm/scrolledwindow.h>

Expand Down Expand Up @@ -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<Gtk::ListBoxRow*>(candidate);
if (row != nullptr && row->get_visible()) {
return row;
}
}
return nullptr;
}

} // namespace

MainWindow::MainWindow(GtkApplication* application, core::HistoryService& history,
Expand Down Expand Up @@ -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<MainWindow*>(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(); }),
Expand Down Expand Up @@ -185,17 +227,20 @@ void MainWindow::build_ui(GtkApplication* application) {
stack_ = Gtk::make_managed<Gtk::Stack>();
stack_->set_vexpand(true);

auto* scrolled = Gtk::make_managed<Gtk::ScrolledWindow>();
scrolled->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
scrolled_ = Gtk::make_managed<Gtk::ScrolledWindow>();
scrolled_->set_policy(Gtk::PolicyType::NEVER, Gtk::PolicyType::AUTOMATIC);
list_ = Gtk::make_managed<Gtk::ListBox>();
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::Box>(Gtk::Orientation::VERTICAL, kContentMargin);
empty->set_valign(Gtk::Align::CENTER);
Expand Down Expand Up @@ -232,6 +277,12 @@ void MainWindow::rebuild_cards() {
const std::vector<core::ClipboardEntry> 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<ClipCard*>(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<std::string, const core::ClipboardEntry*> wanted;
for (const core::ClipboardEntry& entry : entries) {
Expand Down Expand Up @@ -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();
}
Expand All @@ -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()) {
Expand All @@ -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();
}
}

Expand All @@ -315,7 +393,87 @@ 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.
// ponytail: 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_))};
switch (key_action(keyval, context)) {
case KeyAction::ClearSearch:
search_->set_text("");
return true;
case KeyAction::Dismiss:
hide();
return true;
case KeyAction::SelectPrevious:
move_selection(false);
return true;
case KeyAction::SelectNext:
move_selection(true);
return true;
case KeyAction::Paste:
return activate_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);
}
}

bool MainWindow::activate_selection() {
auto* card = dynamic_cast<ClipCard*>(list_->get_selected_row());
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;
}

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;
Expand All @@ -330,10 +488,19 @@ 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.
search_->set_text("");
gtk_widget_set_visible(GTK_WIDGET(window_), FALSE);
}

void MainWindow::pin(const std::string& content) {
history_.get().toggle_pin(content);
}
Expand Down Expand Up @@ -370,7 +537,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();
Expand Down
Loading