From 4b2180490f95d4a1ada7bf5e80c569874d92e0c8 Mon Sep 17 00:00:00 2001 From: MiMoHo Date: Mon, 20 Jul 2026 08:49:09 +0200 Subject: [PATCH] Cap menu item title width so wide-glyph clippings don't balloon the menu FlycutClipping truncates each clipping's display string by character count (displayLen, default 40) and appends an ellipsis. The status menu used that string verbatim as the NSMenuItem title, and NSMenu sizes itself to its widest item. In a proportional font a clipping made of wide glyphs - a pasted horizontal rule of em dashes or box-drawing characters, or CJK text - hits the character cap while rendering roughly twice as wide as the same number of average Latin characters, so that one row balloons the whole menu and leaves every other row padded with trailing space. The bezel (source-app label) and the search window are bounded by fixed-size containers; only the auto-sizing menu let the character-count string drive its width. Add -menuTitleFittingWidth: to bound each menu title to roughly displayLen characters of ordinary text - measured in the menu font, with headroom so normal clippings are untouched - trimming whole composed-character sequences so a surrogate pair or combining cluster is never split before the ellipsis. The title is display-only; selection still resolves by menu position, so which clipping gets pasted is unaffected. Co-Authored-By: Claude Opus 4.8 (cherry picked from commit 8035c5daacad0aad5f5264f8f7c080a6821b46f4) --- AppController.m | 48 +++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/AppController.m b/AppController.m index 3898ab0..7b7ea50 100755 --- a/AppController.m +++ b/AppController.m @@ -1487,6 +1487,52 @@ -(IBAction)mergeClippingList:(id)sender { [self updateMenu]; } +// Cap a menu-item title to a consistent rendered width. +// +// FlycutClipping truncates display strings by character count (displayLen), not +// by rendered width. In a proportional font a run of wide glyphs -- a pasted +// horizontal rule of em dashes or box-drawing characters, or CJK text -- reaches +// the character limit while taking up far more horizontal space than the same +// number of average Latin characters. Because NSMenu sizes itself to its widest +// item, one such clipping balloons the whole menu and leaves every other row +// padded with trailing space. Bound the rendered width here so the menu width +// stays consistent regardless of glyph width, using the same measure-and-trim +// approach the bezel already applies to its source-app label in +// -[BezelWindow setSource:]. +- (NSString *)menuTitleFittingWidth:(NSString *)title +{ + // Headroom above the nominal displayLen-character width, so normal clippings + // (already capped at displayLen characters) are not trimmed further here; + // only rows of unusually wide glyphs are. + const CGFloat kMenuTitleWidthHeadroom = 1.2; + + NSDictionary *attributes = @{NSFontAttributeName: [NSFont menuFontOfSize:0.0]}; + + NSInteger displayLen = [[NSUserDefaults standardUserDefaults] integerForKey:@"displayLen"]; + if ( displayLen <= 0 ) + displayLen = 40; + + // Estimate the width of displayLen characters of ordinary text from a pangram. + NSString *reference = @"The quick brown fox jumps over the lazy dog "; + CGFloat averageCharWidth = [reference sizeWithAttributes:attributes].width / (CGFloat)[reference length]; + CGFloat maxWidth = averageCharWidth * displayLen * kMenuTitleWidthHeadroom; + + if ( [title sizeWithAttributes:attributes].width <= maxWidth ) + return title; + + // Trim whole composed-character sequences (not UTF-16 units) so we never + // split a surrogate pair or a base+combining-mark cluster before the ellipsis. + NSString *fitted = title; + while ( [fitted length] > 0 + && [[fitted stringByAppendingString:@"…"] sizeWithAttributes:attributes].width > maxWidth ) + { + NSRange lastChar = [fitted rangeOfComposedCharacterSequenceAtIndex:[fitted length] - 1]; + fitted = [fitted substringToIndex:lastChar.location]; + } + + return [fitted stringByAppendingString:@"…"]; +} + - (void)updateMenu { dispatch_async(dispatch_get_main_queue(), ^{ if ( !statusItem || !statusItem.isEnabled ) @@ -1522,7 +1568,7 @@ - (void)updateMenuContaining:(NSString*)search { for ( int i = 0; i < newItems; i++ ) { NSMenuItem *item; - item = [[NSMenuItem alloc] initWithTitle:[clipStrings objectAtIndex:i] + item = [[NSMenuItem alloc] initWithTitle:[self menuTitleFittingWidth:[clipStrings objectAtIndex:i]] action:@selector(processMenuClippingSelection:) keyEquivalent:@""]; [item setTarget:self];