From d03840757a5f5e4facac6249b1325a02c9a164aa Mon Sep 17 00:00:00 2001 From: relikd Date: Thu, 11 Dec 2025 15:51:10 +0100 Subject: [PATCH] feat: Appearance settings v2 --- baRSS/Helper/UserPrefs.m | 4 +- .../Appearance Tab/SettingsAppearance.m | 10 +- .../Appearance Tab/SettingsAppearanceView.h | 2 +- .../Appearance Tab/SettingsAppearanceView.m | 292 ++++++++++++------ 4 files changed, 212 insertions(+), 96 deletions(-) diff --git a/baRSS/Helper/UserPrefs.m b/baRSS/Helper/UserPrefs.m index 2d559c6..b169d1d 100644 --- a/baRSS/Helper/UserPrefs.m +++ b/baRSS/Helper/UserPrefs.m @@ -28,8 +28,8 @@ void UserPrefsInit(void) { ]); // Display limits & truncation ( defaults write de.relikd.baRSS {KEY} -int 10 ) [defs setObject:[NSNumber numberWithUnsignedInteger:10] forKey:Pref_openFewLinksLimit]; - [defs setObject:[NSNumber numberWithUnsignedInteger:60] forKey:Pref_shortArticleNamesLimit]; - [defs setObject:[NSNumber numberWithUnsignedInteger:40] forKey:Pref_articlesInMenuLimit]; + [defs setObject:[NSNumber numberWithInteger:-1] forKey:Pref_articlesInMenuLimit]; + [defs setObject:[NSNumber numberWithInteger:-1] forKey:Pref_shortArticleNamesLimit]; [defs setObject:[NSNumber numberWithInteger:2000] forKey:Pref_articleTooltipLimit]; [defs setObject:[NSNumber numberWithUnsignedInteger:1] forKey:Pref_prefSelectedTab]; // feed tab [[NSUserDefaults standardUserDefaults] registerDefaults:defs]; diff --git a/baRSS/Preferences/Appearance Tab/SettingsAppearance.m b/baRSS/Preferences/Appearance Tab/SettingsAppearance.m index b41e151..0208073 100644 --- a/baRSS/Preferences/Appearance Tab/SettingsAppearance.m +++ b/baRSS/Preferences/Appearance Tab/SettingsAppearance.m @@ -8,10 +8,12 @@ - (void)loadView { self.view = [SettingsAppearanceView new]; - for (NSButton *button in self.view.subviews) { - if ([button isKindOfClass:[NSButton class]]) { // for all checkboxes - [button setAction:@selector(didSelectCheckbox:)]; - [button setTarget:self]; + NSScrollView *scroll = self.view.subviews[0]; + NSView *contentView = scroll.documentView.subviews[0]; + for (NSControl *control in contentView.subviews) { + if ([control isKindOfClass:[NSButton class]]) { // for all checkboxes + [control setAction:@selector(didSelectCheckbox:)]; + [control setTarget:self]; } } } diff --git a/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.h b/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.h index b8bae96..6449628 100644 --- a/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.h +++ b/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.h @@ -1,6 +1,6 @@ @import Cocoa; @class SettingsAppearance; -@interface SettingsAppearanceView : NSView +@interface SettingsAppearanceView : NSView @end diff --git a/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.m b/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.m index 5898595..c155b0a 100644 --- a/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.m +++ b/baRSS/Preferences/Appearance Tab/SettingsAppearanceView.m @@ -2,139 +2,253 @@ #import "NSView+Ext.h" #import "Constants.h" // column icons #import "UserPrefs.h" // preference constants & UserPrefsBool() +#import "DrawImage.h" // DrawSeparator + +@interface FlippedView : NSView @end @interface SettingsAppearanceView() @property (assign) CGFloat y; +@property (assign) NSView *content; +@property (strong) NSMutableArray *columns; @end /***/ static CGFloat const IconSize = 18; /***/ static CGFloat const colWidth = (IconSize + PAD_M); // checkbox column width -/***/ static CGFloat const X__ = PAD_WIN + 0 * colWidth; -/***/ static CGFloat const _X_ = PAD_WIN + 1 * colWidth; -/***/ static CGFloat const __X = PAD_WIN + 2 * colWidth; +/***/ static CGFloat const X___ = PAD_WIN + 0 * colWidth; +/***/ static CGFloat const _X__ = PAD_WIN + 1 * colWidth; +/***/ static CGFloat const __X_ = PAD_WIN + 2 * colWidth; +/***/ static CGFloat const ___X = PAD_WIN + 3 * colWidth; +/***/ static CGFloat const lbl_start = PAD_WIN + 4 * colWidth; @implementation SettingsAppearanceView - (instancetype)init { self = [super initWithFrame:NSMakeRect(0, 0, 320, 327)]; - // Insert matrix header (icons above checkbox matrix) - ColumnIcon(self, X__, RSSImageSettingsGlobalMenu); - ColumnIcon(self, _X_, RSSImageSettingsGroup); - ColumnIcon(self, __X, RSSImageSettingsFeed); - // Generate checkbox matrix - self.y = PAD_WIN + IconSize + PAD_S; - [self entry:NSLocalizedString(@"Tint menu bar icon on unread", nil) - help:NSLocalizedString(@"If active, a color will indicate if there are unread articles.", nil) - tip:nil - c1:Pref_globalTintMenuIcon c1tt:NSLocalizedString(@"menu bar icon", nil) - c2:nil c2tt:nil - c3:nil c3tt:nil]; + self.y = PAD_WIN; + // stupidly complex UI generation just because you cant top-align `.documentView` + NSScrollView *scroll = [[[FlippedView new] wrapInScrollView:self.frame.size] placeIn:self x:0 y:0]; + self.content = [[[NSView alloc] initWithFrame:scroll.documentView.frame] placeIn:scroll.documentView x:0 y:0]; - [self entry:NSLocalizedString(@"Update all feeds", nil) - help:NSLocalizedString(@"Show button in main menu to reload all feeds. This will force fetch new online content regardless of next-update timer.", nil) - tip:nil - c1:Pref_globalUpdateAll c1tt:NSLocalizedString(@"in main menu", nil) - c2:nil c2tt:nil - c3:nil c3tt:nil]; - - [self entry:NSLocalizedString(@"Toggle “Show hidden articles”", nil) - help:NSLocalizedString(@"Show button in main menu to quickly toggle whether hidden articles should be shown. See option “Show only unread”.", nil) - tip:nil - c1:Pref_globalToggleHidden c1tt:NSLocalizedString(@"in main menu", nil) - c2:nil c2tt:nil - c3:nil c3tt:nil]; - - [self entry:NSLocalizedString(@"Open all unread", nil) - help:NSLocalizedString(@"Show button to open unread articles.", nil) - tip:NSLocalizedString(@"If you hold down option-key, this will become an “open a few” unread articles button.", nil) - c1:Pref_globalOpenUnread c1tt: NSLocalizedString(@"in main menu", nil) - c2:Pref_groupOpenUnread c2tt: NSLocalizedString(@"in group menu", nil) - c3:Pref_feedOpenUnread c3tt: NSLocalizedString(@"in feed menu", nil)]; - - [self entry:NSLocalizedString(@"Mark all read", nil) - help:NSLocalizedString(@"Show button to mark articles read.", nil) - tip:nil - c1:Pref_globalMarkRead c1tt: NSLocalizedString(@"in main menu", nil) - c2:Pref_groupMarkRead c2tt: NSLocalizedString(@"in group menu", nil) - c3:Pref_feedMarkRead c3tt: NSLocalizedString(@"in feed menu", nil)]; - - [self entry:NSLocalizedString(@"Mark all unread", nil) - help:NSLocalizedString(@"Show button to mark articles unread.", nil) - tip:NSLocalizedString(@"You can hold down option-key and click on an article to toggle that item (un-)read.", nil) - c1:Pref_globalMarkUnread c1tt: NSLocalizedString(@"in main menu", nil) - c2:Pref_groupMarkUnread c2tt: NSLocalizedString(@"in group menu", nil) - c3:Pref_feedMarkUnread c3tt: NSLocalizedString(@"in feed menu", nil)]; + // repeat with another column header +// [self section:NSLocalizedString(@"Modify", nil)]; + [self columns:@[ + RSSImageSettingsGlobalIcon, NSLocalizedString(@"Menu bar icon", nil), + RSSImageSettingsGroup, NSLocalizedString(@"Group menu item", nil), + RSSImageSettingsFeed, NSLocalizedString(@"Feed menu item", nil), + RSSImageSettingsArticle, NSLocalizedString(@"Article menu item", nil), + ]]; [self entry:NSLocalizedString(@"Number of unread articles", nil) help:NSLocalizedString(@"Show count of unread articles in parenthesis.", nil) tip:nil - c1:Pref_globalUnreadCount c1tt:NSLocalizedString(@"on menu bar icon", nil) - c2:Pref_groupUnreadCount c2tt:NSLocalizedString(@"on group folder", nil) - c3:Pref_feedUnreadCount c3tt:NSLocalizedString(@"on feed folder", nil)]; + c1:Pref_globalUnreadCount c2:Pref_groupUnreadCount c3:Pref_feedUnreadCount c4:nil]; - [self entry:NSLocalizedString(@"Indicator for unread articles", nil) - help:NSLocalizedString(@"Show blue dot on menu items with unread articles.", nil) + [self entry:NSLocalizedString(@"Color for unread articles", nil) + help:NSLocalizedString(@"Show color marker on menu items with unread articles.", nil) tip:nil - c1:nil c1tt:nil - c2:Pref_groupUnreadIndicator c2tt:NSLocalizedString(@"on group & feed folder", nil) - c3:Pref_feedUnreadIndicator c3tt:NSLocalizedString(@"on article entry", nil)]; + c1:Pref_globalTintMenuIcon c2:Pref_groupUnreadIndicator c3:Pref_feedUnreadIndicator c4:Pref_articleUnreadIndicator]; [self entry:NSLocalizedString(@"Show only unread", nil) help:NSLocalizedString(@"Hide articles which have been read.", nil) tip:NSLocalizedString(@"You can hold down option-key before opening the main menu to temporarily disable this setting.", nil) - c1:nil c1tt:nil - c2:Pref_groupUnreadOnly c2tt:NSLocalizedString(@"hide group & feed folders with 0 unread articles", nil) - c3:Pref_feedUnreadOnly c3tt:NSLocalizedString(@"hide articles inside of feed folder", nil)]; + c1:nil c2:Pref_groupUnreadOnly c3:Pref_feedUnreadOnly c4:Pref_articleUnreadOnly]; - [self entry:NSLocalizedString(@"Truncate article title", nil) - help:NSLocalizedString(@"Truncate article title after 60 characters. If a title is longer than that, show an ellipsis character “…” instead.", nil) +// self.y += PAD_M; +// [self note:NSLocalizedString(@"Hold down option-key before opening the main menu to temporarily show hidden feeds.", nil)]; + + + // Menu Buttons + + [self section:NSLocalizedString(@"Menu buttons", nil)]; + [self columns:@[ + RSSImageSettingsGlobalMenu, NSLocalizedString(@"Main menu", nil), + RSSImageSettingsGroup, NSLocalizedString(@"Group menu", nil), + RSSImageSettingsFeed, NSLocalizedString(@"Feed menu", nil), + ]]; + + [self entry:NSLocalizedString(@"“Show hidden feeds”", nil) + help:NSLocalizedString(@"Show button to quickly toggle whether hidden articles should be shown. See option “Show only unread”.", nil) tip:nil - c1:nil c1tt:nil - c2:nil c2tt:nil - c3:Pref_feedTruncateTitle c3tt:NSLocalizedString(@"article title", nil)]; + c1:Pref_globalToggleHidden c2:nil c3:nil c4:nil]; - [self entry:NSLocalizedString(@"Limit number of articles", nil) - help:NSLocalizedString(@"Display at most 40 articles in feed menu. Remaining articles will be hidden from view but are still there. Unread count may be confusing as it will also count unread and hidden articles.", nil) + [self entry:NSLocalizedString(@"“Update all feeds”", nil) + help:NSLocalizedString(@"Show button to reload all feeds. This will force fetch new online content regardless of next-update timer.", nil) tip:nil - c1:nil c1tt:nil - c2:nil c2tt:nil - c3:Pref_feedLimitArticles c3tt:NSLocalizedString(@"in feed menu", nil)]; + c1:Pref_globalUpdateAll c2:nil c3:nil c4:nil]; - [[[[[NSView label:@"Note: you can hover over all options to display explanatory tooltips."] - multiline:NSMakeSize(100, 2 * HEIGHT_LABEL)] gray] - placeIn:self x:PAD_WIN yTop:self.y + PAD_L] sizeToRight:PAD_WIN]; + [self entry:NSLocalizedString(@"“Open all unread”", nil) + help:NSLocalizedString(@"Show button to open unread articles.", nil) + tip:NSLocalizedString(@"If you hold down option-key, this will become an “open a few” unread articles button.", nil) + c1:Pref_globalOpenUnread c2:Pref_groupOpenUnread c3:Pref_feedOpenUnread c4:nil]; + + [self entry:NSLocalizedString(@"“Mark all read”", nil) + help:NSLocalizedString(@"Show button to mark articles read.", nil) + tip:nil + c1:Pref_globalMarkRead c2:Pref_groupMarkRead c3:Pref_feedMarkRead c4:nil]; + + [self entry:NSLocalizedString(@"“Mark all unread”", nil) + help:NSLocalizedString(@"Show button to mark articles unread.", nil) + tip:NSLocalizedString(@"You can hold down option-key and click on an article to toggle that item (un-)read.", nil) + c1:Pref_globalMarkUnread c2:Pref_groupMarkUnread c3:Pref_feedMarkUnread c4:nil]; + +// self.y += PAD_M; +// [self note:NSLocalizedString(@"Hold down option-key and click on an article to toggle that item (un-)read.", nil)]; + + + // Other UI elements + + [self section:NSLocalizedString(@"Text manipulation", nil)]; + + [self intInput:Pref_articlesInMenuLimit // Pref_feedLimitArticles + unit:NSLocalizedString(@"%ld entries", nil) + label:NSLocalizedString(@"Limit number of articles", nil) + help:NSLocalizedString(@"Display at most X articles in feed menu. Remaining articles will be hidden from view but are still there. Unread count may be confusing because hidden articles are counted too.", nil)]; + + [self intInput:Pref_shortArticleNamesLimit // Pref_feedTruncateTitle + unit:NSLocalizedString(@"%ld chars", nil) + label:NSLocalizedString(@"Truncate article title", nil) + help:NSLocalizedString(@"Truncate article title after X characters. If a title is longer than that, show an ellipsis character “…”.", nil)]; + + [self intInput:Pref_articleTooltipLimit + unit:NSLocalizedString(@"%ld chars", nil) + label:NSLocalizedString(@"Truncate article tooltip", nil) + help:NSLocalizedString(@"Truncate article tooltip after X characters. This tooltip shows the whole article content (if provided by the server).", nil)]; + + self.y += PAD_WIN; + + // sest final view size + [[self.content sizableWidth] setFrameSize:NSMakeSize(NSWidth(self.content.frame), self.y)]; + [[scroll.documentView sizableWidth] setFrame:self.content.frame]; return self; } -/// Helper method for matrix table header icons -static inline void ColumnIcon(id this, CGFloat x, const NSImageName img) { - [[NSView imageView:img size:IconSize] placeIn:this x:x yTop:PAD_WIN]; + +// MARK: - Section Header + + +- (void)section:(NSString*)title { + self.y += PAD_L; + NSTextField *label = [[[NSView label:title] placeIn:self.content x:PAD_WIN yTop:self.y] large]; +// [[DrawSeparator withSize:NSMakeSize(lbl_start - PAD_S, NSHeight(label.frame))] placeIn:self.content x:0 yTop:self.y] +// .invert = YES; + [[[DrawSeparator withSize:NSMakeSize(100, NSHeight(label.frame))] placeIn:self.content x:NSMaxX(label.frame) + PAD_S yTop:self.y] sizeToRight:0]; + self.y += NSHeight(label.frame) + PAD_M; } + +// MARK: - Column Icons + + +/// Helper method for matrix table header icons +- (void)columns:(NSArray*)columns { + self.columns = [NSMutableArray arrayWithCapacity:4]; + for (NSUInteger i = 0; i < columns.count / 2; i++) { + NSString *img = columns[i*2]; + NSString *ttip = columns[i*2 + 1]; + [[[NSView imageView:img size:IconSize] tooltip:ttip] + placeIn:self.content x:PAD_WIN + i * colWidth yTop:self.y] + .accessibilityLabel = NSLocalizedString(@"Column header:", nil); + [self.columns addObject:ttip ? ttip : @""]; + } + self.y += IconSize + PAD_S; +} + + +// MARK: - Notes + + +- (void)note:(NSString*)text { + NSTextField *lbl = [[[NSView label:text] multiline:NSMakeSize(320 - 2*PAD_WIN, 7 * HEIGHT_LABEL)] gray]; + NSSize bestSize = [lbl sizeThatFits:lbl.frame.size]; + [lbl setFrameSize:bestSize]; + [[lbl placeIn:self.content x:PAD_WIN yTop:self.y] sizeToRight:PAD_WIN]; + self.y += NSHeight(lbl.frame); +} + + +// MARK: - Checkboxes + /// Helper method for generating a checkbox -static inline NSButton* Checkbox(id this, CGFloat x, CGFloat y, NSString *key) { - NSButton *check = [[NSView checkbox: UserPrefsBool(key)] placeIn:this x:x yTop:y]; +static inline NSButton* Checkbox(SettingsAppearanceView *self, CGFloat x, NSString *key) { + NSButton *check = [[NSView checkbox:UserPrefsBool(key)] placeIn:self.content x:x+2 yTop:self.y+2]; check.identifier = key; return check; } /// Create new entry with 1-3 checkboxes and a descriptive label - (NSTextField*)entry:(NSString*)label help:(NSString*)ttip tip:(NSString*)extraTip - c1:(NSString*)pref1 c1tt:(NSString*)ttip1 - c2:(NSString*)pref2 c2tt:(NSString*)ttip2 - c3:(NSString*)pref3 c3tt:(NSString*)ttip3 + c1:(NSString*)pref1 c2:(NSString*)pref2 c3:(NSString*)pref3 c4:(NSString*)pref4 { - CGFloat y = self.y; - self.y += (PAD_S + HEIGHT_LABEL); - // TODO: localize: global, group, feed - if (pref1) [Checkbox(self, X__ + 2, y + 2, pref1) tooltip:ttip1].accessibilityLabel = [label stringByAppendingString:@" (global)"]; - if (pref2) [Checkbox(self, _X_ + 2, y + 2, pref2) tooltip:ttip2].accessibilityLabel = [label stringByAppendingString:@" (group)"]; - if (pref3) [Checkbox(self, __X + 2, y + 2, pref3) tooltip:ttip3].accessibilityLabel = [label stringByAppendingString:@" (feed)"]; + if (pref1) Checkbox(self, X___, pref1).accessibilityLabel = [NSString stringWithFormat:@"%@: %@", _columns[0], label]; + if (pref2) Checkbox(self, _X__, pref2).accessibilityLabel = [NSString stringWithFormat:@"%@: %@", _columns[1], label]; + if (pref3) Checkbox(self, __X_, pref3).accessibilityLabel = [NSString stringWithFormat:@"%@: %@", _columns[2], label]; + if (pref4) Checkbox(self, ___X, pref4).accessibilityLabel = [NSString stringWithFormat:@"%@: %@", _columns[3], label]; if (extraTip != nil) { - label = [label stringByAppendingString:@" *"]; + //label = [label stringByAppendingString:@" *"]; ttip = [ttip stringByAppendingFormat:@"\n\n* Tip: %@", extraTip]; } - return [[[[NSView label:label] placeIn:self x:PAD_WIN + 3 * colWidth yTop:y] sizeToRight:PAD_WIN] tooltip:ttip]; + NSTextField *lbl = [[[[NSView label:label] tooltip:ttip] placeIn:self.content x:lbl_start yTop:self.y] sizeToRight:PAD_WIN]; + self.y += (PAD_S + HEIGHT_LABEL); + return lbl; +} + + +// MARK: - Int Input Field + + +/// Create input field for integer numbers +- (NSTextField*)intInput:(NSString*)pref unit:(NSString*)unit label:(NSString*)label help:(NSString*)ttip { + // input field + NSTextField *rv = [[NSView integerField:@"" unit:unit width:3 * colWidth + IconSize] placeIn:self.content x:PAD_WIN yTop:self.y]; + rv.placeholderString = NSLocalizedString(@"no limit", nil); + // sadly, setting `accessibilityLabel` will break VoiceOver on empty input. + // keep disabled so VoceOver will read the placeholder string if empty. + rv.accessibilityLabel = label; + rv.identifier = pref; + rv.delegate = self; + NSInteger val = UserPrefsInt(pref); + if (val > 0) { + rv.stringValue = [NSString stringWithFormat:@"%ld", val]; + } else { + rv.accessibilityValueDescription = rv.placeholderString; + } + // label + [[[[NSView label:label] tooltip:ttip] placeIn:self.content x:lbl_start yTop:self.y] sizeToRight:PAD_WIN]; + self.y += HEIGHT_INPUTFIELD + PAD_S; + return rv; +} + +- (void)controlTextDidEndEditing:(NSNotification *)obj { + NSTextField *sender = obj.object; + NSString *pref = sender.identifier; + + NSInteger newVal = sender.integerValue; + if (newVal == 0 && sender.stringValue.length > 0) { + sender.stringValue = @""; // clear input to show placeholder text + } + sender.accessibilityValueDescription = newVal > 0 ? nil : sender.placeholderString; + UserPrefsSetInt(pref, newVal > 0 ? newVal : -1); + + BOOL hitReturn = [[obj.userInfo valueForKey:NSTextMovementUserInfoKey] integerValue] == NSTextMovementReturn; + if (hitReturn) { + // Allow to deselect NSTextField (when pressing enter to confirm change) + [self.window performSelector:@selector(makeFirstResponder:) withObject:nil afterDelay:0]; + } +} + +// Allow to deselect all NSTextFields (via tab focus cycling) +// Also: opens view with no NSTextField selected. +- (BOOL)acceptsFirstResponder { + return YES; } @end + +@implementation FlippedView +- (BOOL)isFlipped { return YES; } +- (void)mouseDown:(NSEvent *)event { + // Allow to deselect all NSTextFields (by clicking outside / somewhere on the window) + [self.window performSelector:@selector(makeFirstResponder:) withObject:nil afterDelay:0]; + // perform selector because otherwise it will raise an issue of different QoS levels +} +@end