Skip to content
Closed
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
344 changes: 344 additions & 0 deletions IMPLEMENTATION.md

Large diffs are not rendered by default.

15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
# Diodon
# Diodon (Enhanced Edition)

Aiming to be the best integrated clipboard manager for the Unity desktop.
A high-performance GTK+ clipboard manager — forked from [diodon-dev/diodon](https://github.com/diodon-dev/diodon) with major image handling improvements.

## What's New in This Version

- **3× larger image thumbnails** — 200×150 previews with contain-fit scaling, centered in the menu
- **Instant 4K image paste** — lazy clipboard serving via `set_with_owner()` eliminates desktop freezes
- **90% less memory** — single-slot PNG cache (~10 MB) replaces unbounded pixbuf storage (was 200+ MB)
- **No CPU spikes** — cached PNG bytes served directly to apps requesting `image/png`, zero re-encoding
- **Correct paste-from-history** — each item retains its own PNG data, no more stale image bugs
- **Wider text labels** — 100-char labels with 4-line word wrapping

See [IMPLEMENTATION.md](IMPLEMENTATION.md) for the full technical architecture and lifecycle documentation.

## Installing

Expand Down
79 changes: 74 additions & 5 deletions libdiodon/clipboard-manager.vala
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ namespace Diodon
protected Gtk.Clipboard _clipboard = null;
protected ClipboardConfiguration _configuration;

// Refractory period: when true, check_clipboard() is suppressed.
// Engaged during execute_paste() to prevent the synthetic Ctrl+V
// keystroke from triggering a "Paste-to-Self" feedback loop.
private bool _suppressed = false;
private uint _suppress_source_id = 0;

/**
* Called when text from the clipboard has been received
*
Expand Down Expand Up @@ -119,23 +125,86 @@ namespace Diodon
}

/**
* Clear managed clipboard
* Clear managed clipboard.
*
* Releases Diodon's clipboard ownership first (triggering
* clipboard_clear_func which nulls _pixbuf), then sets empty
* text. This prevents the "Zombie Clipboard" bug where
* Ctrl+V after Clear History could still paste sensitive data
* from a lingering pixbuf reference.
*/
public void clear()
{
// clearing only works when clipboard is called by a callback
// from clipboard itself. This is not the case here
// so therefore we just set an empty text to clear the clipboard
//clipboard.clear();
// Release ownership — triggers clipboard_clear_func on the
// current owner (ImageClipboardItem), nulling its _pixbuf.
// This is safe to call even if Diodon doesn't own the clipboard.
_clipboard.clear();

// Set empty text so the clipboard isn't completely blank
// (some apps crash on truly empty selections).
_clipboard.set_text("", -1);
}

/**
* Suppress clipboard monitoring for the given duration.
*
* Used as a "refractory period" during execute_paste() to
* prevent the synthetic Ctrl+V from triggering a feedback loop
* where the target app re-announces ownership and Diodon
* re-reads and re-adds the same item.
*
* @param duration_ms suppression duration in milliseconds
*/
public void suppress_for(uint duration_ms)
{
// Cancel any existing suppression timer
if (_suppress_source_id != 0) {
GLib.Source.remove(_suppress_source_id);
}

_suppressed = true;
debug("Clipboard %d suppressed for %ums", type, duration_ms);

_suppress_source_id = GLib.Timeout.add(duration_ms, () => {
_suppressed = false;
_suppress_source_id = 0;
debug("Clipboard %d suppression lifted", type);
return GLib.Source.REMOVE;
});
}

/**
* Request text from managed clipboard. If result is valid
* on_text_received will be called.
*/
protected virtual void check_clipboard()
{
// === Refractory Period ===
// Suppressed during execute_paste() to prevent the synthetic
// keystroke from causing a "Paste-to-Self" feedback loop.
if (_suppressed) {
debug("Clipboard %d check suppressed (refractory period)", type);
return;
}

// === Ownership Check (self-loop detection) ===
// When Diodon sets the clipboard via set_with_owner(),
// the owner_change signal fires back. Instead of reading
// the clipboard data and hashing pixels (expensive!),
// we check if WE still own the clipboard. If so, this is
// our own feedback loop — skip immediately.
//
// get_owner() returns the GObject passed to set_with_owner().
// For ImageClipboardItem: returns the item instance.
// For text (set_text): returns null (no ownership tracking).
// When another app takes ownership: GTK clears the owner.
GLib.Object? owner = _clipboard.get_owner();
if (owner != null && owner is IClipboardItem) {
debug("Clipboard owned by Diodon (%s), skipping self-feedback",
owner.get_type().name());
return;
}

// on java applications such as jEdit wait_is_text_available returns
// false even when some text is available
string? text = request_text();
Expand Down
73 changes: 68 additions & 5 deletions libdiodon/clipboard-menu-item.vala
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,14 @@ namespace Diodon
/**
* A gtk menu item holding a checksum of a clipboard item. It only keeps
* the checksum as it would waste memory to keep the hole item available.
*
* For image items, also exposes is_image_item() so the menu can
* hook the `select` signal for speculative pixbuf warm-up.
*/
class ClipboardMenuItem : Gtk.ImageMenuItem
class ClipboardMenuItem : Gtk.MenuItem
{
private string _checksum;
private bool _is_image;

/**
* Clipboard item constructor
Expand All @@ -37,13 +41,61 @@ namespace Diodon
public ClipboardMenuItem(IClipboardItem item)
{
_checksum = item.get_checksum();
set_label(item.get_label());
_is_image = (item.get_category() == ClipboardCategory.IMAGES);

// check if image needs to be shown
Gtk.Image? image = item.get_image();
if(image != null) {
set_image(image);
set_always_show_image(true);
// For image items: display a large centered thumbnail
// spanning the full tile, instead of a small icon on the left

// Remove any default child widget from the menu item
var existing_child = get_child();
if (existing_child != null) {
remove(existing_child);
}

// Vertical box: thumbnail on top, dimension label below
var box = new Gtk.Box(Gtk.Orientation.VERTICAL, 2);
box.set_halign(Gtk.Align.CENTER);
box.set_valign(Gtk.Align.CENTER);
box.set_can_focus(false);
box.margin_top = 4;
box.margin_bottom = 4;
box.margin_start = 8;
box.margin_end = 8;

// Center the thumbnail within the full tile width
image.set_halign(Gtk.Align.CENTER);
image.set_valign(Gtk.Align.CENTER);
image.set_can_focus(false);

// Request explicit size so the menu allocates enough space
Gdk.Pixbuf? pix = image.get_pixbuf();
if (pix != null) {
image.set_size_request(pix.width, pix.height);
}

box.pack_start(image, false, false, 0);

add(box);

// Show image dimensions on hover
set_tooltip_text(item.get_label());
} else {
// Remove default child to replace with a wrapping label
var existing_child = get_child();
if (existing_child != null) {
remove(existing_child);
}

var label = new Gtk.Label(item.get_label());
label.set_xalign(0);
label.set_line_wrap(true);
label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR);
label.set_max_width_chars(50);
label.set_lines(4);
label.set_ellipsize(Pango.EllipsizeMode.END);
add(label);
}
}

Expand All @@ -57,6 +109,17 @@ namespace Diodon
return _checksum;
}

/**
* Check if this menu item represents an image clipboard item.
* Used for speculative pixbuf warm-up on hover.
*
* @return true if the item is an image
*/
public bool is_image_item()
{
return _is_image;
}

/**
* Highlight item by changing label to bold
* TODO: get this up and running
Expand Down
46 changes: 45 additions & 1 deletion libdiodon/clipboard-menu.vala
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ namespace Diodon
private Controller controller;
private unowned List<Gtk.Widget> static_menu_items;

// Debounce source ID for speculative warm-up.
// Prevents the "Thundering Herd" when user holds Down Arrow
// and rapidly scrolls through 50 items — only the item they
// stop on (after 150ms of no movement) triggers a decode.
private uint _warmup_source_id = 0;

/**
* Create clipboard menu
*
Expand Down Expand Up @@ -94,12 +100,39 @@ namespace Diodon
/**
* Append given clipboard item to menu.
*
* For image items, also hooks the `select` signal to trigger
* speculative pixbuf warm-up when the user hovers/navigates
* to the item. This pre-decodes the full image in an idle
* callback so paste is instant when they click.
*
* @param entry entry to be added
*/
public void append_clipboard_item(IClipboardItem item)
{
ClipboardMenuItem menu_item = new ClipboardMenuItem(item);
menu_item.activate.connect(on_clicked_item);

// Speculative decode with debounce: when user hovers an
// image item, schedule warm-up after 150ms of no movement.
// This prevents the "Thundering Herd" — holding Down Arrow
// through 50 items won't spawn 50 decode tasks. Only the
// item the user stops on actually triggers a decode.
if (menu_item.is_image_item()) {
menu_item.select.connect(() => {
// Cancel any pending warm-up from a previous item
if (_warmup_source_id != 0) {
GLib.Source.remove(_warmup_source_id);
_warmup_source_id = 0;
}
string cs = menu_item.get_item_checksum();
_warmup_source_id = GLib.Timeout.add(150, () => {
ImageCache.get_default().warm_pixbuf(cs);
_warmup_source_id = 0;
return GLib.Source.REMOVE;
});
});
}

menu_item.show();
append(menu_item);
}
Expand All @@ -110,7 +143,12 @@ namespace Diodon
// otherwise popup does not open
Timeout.add(
250,
() => { popup(null, null, null, 0, Gtk.get_current_event_time()); return false; }
() => {
popup(null, null, null, 0, Gtk.get_current_event_time());
// Focus the first item by default for keyboard navigation
select_first(true);
return false;
}
);
}

Expand All @@ -119,6 +157,12 @@ namespace Diodon
*/
public void destroy_menu()
{
// Cancel any pending warm-up before destroying
if (_warmup_source_id != 0) {
GLib.Source.remove(_warmup_source_id);
_warmup_source_id = 0;
}

foreach(Gtk.Widget item in get_children()) {
remove(item);

Expand Down
Loading