Refactoring Interface Builder UI to code equivalent
This commit is contained in:
@@ -266,8 +266,11 @@ static BOOL _nextUpdateIsForced = NO;
|
||||
dispatch_sync(dispatch_get_main_queue(), ^{ // sync! (thread is already in background)
|
||||
chosenURL = askUser(parsedMeta);
|
||||
});
|
||||
if (!chosenURL || chosenURL.length == 0)
|
||||
if (!chosenURL || chosenURL.length == 0) {
|
||||
// User canceled operation, show appropriate error message
|
||||
*err = [NSError errorWithDomain:NSURLErrorDomain code:NSURLErrorCancelled userInfo:@{NSLocalizedDescriptionKey: NSLocalizedString(@"Operation canceled.", nil)}];
|
||||
return NO;
|
||||
}
|
||||
[self parseFeedRequest:[self newRequestURL:chosenURL] xmlBlock:nil feedBlock:block];
|
||||
return YES;
|
||||
} feedBlock:block];
|
||||
|
||||
@@ -33,6 +33,12 @@ typedef NS_ENUM(int32_t, TimeUnitType) {
|
||||
};
|
||||
|
||||
@interface NSDate (Ext)
|
||||
+ (NSString*)dayStringISO8601;
|
||||
+ (NSString*)dayStringLocalized;
|
||||
@end
|
||||
|
||||
|
||||
@interface NSDate (Interval)
|
||||
+ (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag;
|
||||
+ (TimeUnitType)unitForInterval:(Interval)intv rounded:(BOOL)flag;
|
||||
@end
|
||||
@@ -43,3 +49,8 @@ typedef NS_ENUM(int32_t, TimeUnitType) {
|
||||
+ (void)setInterval:(Interval)intv forPopup:(NSPopUpButton*)popup andField:(NSTextField*)field animate:(BOOL)flag;
|
||||
+ (void)populateUnitsMenu:(NSPopUpButton*)popup selected:(TimeUnitType)unit;
|
||||
@end
|
||||
|
||||
|
||||
@interface NSDate (Statistics)
|
||||
+ (NSDictionary*)refreshIntervalStatistics:(NSArray<NSDate*> *)list;
|
||||
@end
|
||||
|
||||
@@ -38,6 +38,23 @@ static const TimeUnitType _values[] = {
|
||||
|
||||
@implementation NSDate (Ext)
|
||||
|
||||
/// @return Day as string in iso format: @c YYYY-MM-DD'T'hh:mm:ss'Z'
|
||||
+ (NSString*)dayStringISO8601 {
|
||||
return [[[NSISO8601DateFormatter alloc] init] stringFromDate:[NSDate date]];
|
||||
// NSDateComponents *now = [[NSCalendar currentCalendar] components:NSCalendarUnitDay | NSCalendarUnitMonth | NSCalendarUnitYear fromDate:[NSDate date]];
|
||||
// return [NSString stringWithFormat:@"%04ld-%02ld-%02ld", now.year, now.month, now.day];
|
||||
}
|
||||
|
||||
/// @return Day as string in localized short format, e.g., @c DD.MM.YY
|
||||
+ (NSString*)dayStringLocalized {
|
||||
return [NSDateFormatter localizedStringFromDate:[NSDate date] dateStyle:NSDateFormatterShortStyle timeStyle:NSDateFormatterNoStyle];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
@implementation NSDate (Interval)
|
||||
|
||||
/// If @c flag @c = @c YES, print @c 1.1f float string with single char unit: e.g., 3.3m, 1.7h.
|
||||
+ (nonnull NSString*)stringForInterval:(Interval)intv rounded:(BOOL)flag {
|
||||
if (flag) {
|
||||
@@ -139,3 +156,48 @@ static const TimeUnitType _values[] = {
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
@implementation NSDate (Statistics)
|
||||
|
||||
/**
|
||||
@return @c nil if list contains less than 2 entries. Otherwise: @{min, max, avg, median, earliest, latest}
|
||||
*/
|
||||
+ (NSDictionary*)refreshIntervalStatistics:(NSArray<NSDate*> *)list {
|
||||
if (!list || list.count == 0)
|
||||
return nil;
|
||||
|
||||
NSDate *earliest = [NSDate distantFuture];
|
||||
NSDate *latest = [NSDate distantPast];
|
||||
NSDate *prev = nil;
|
||||
NSMutableArray<NSNumber*> *differences = [NSMutableArray array];
|
||||
for (NSDate *d in list) {
|
||||
if (![d isKindOfClass:[NSDate class]]) // because valueForKeyPath: can return NSNull
|
||||
continue;
|
||||
earliest = [d earlierDate:earliest];
|
||||
latest = [d laterDate:latest];
|
||||
if (prev) {
|
||||
int dif = abs((int)[d timeIntervalSinceDate:prev]);
|
||||
[differences addObject:[NSNumber numberWithInt:dif]];
|
||||
}
|
||||
prev = d;
|
||||
}
|
||||
if (differences.count == 0)
|
||||
return nil;
|
||||
|
||||
[differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"integerValue" ascending:YES]]];
|
||||
|
||||
NSUInteger i = (differences.count/2);
|
||||
NSNumber *median = differences[i];
|
||||
if ((differences.count % 2) == 0) { // even feed count, use median of two values
|
||||
median = [NSNumber numberWithInteger:(median.integerValue + differences[i-1].integerValue) / 2];
|
||||
}
|
||||
return @{@"min" : differences.firstObject,
|
||||
@"max" : differences.lastObject,
|
||||
@"avg" : [differences valueForKeyPath:@"@avg.self"],
|
||||
@"median" : median,
|
||||
@"earliest" : earliest,
|
||||
@"latest" : latest };
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
101
baRSS/Helper/NSView+Ext.h
Normal file
101
baRSS/Helper/NSView+Ext.h
Normal file
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2019 Oleg Geier
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||
// so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
/***/ static const CGFloat PAD_WIN = 20; // window padding
|
||||
/***/ static const CGFloat PAD_L = 16;
|
||||
/***/ static const CGFloat PAD_M = 8;
|
||||
/***/ static const CGFloat PAD_S = 4;
|
||||
/***/ static const CGFloat PAD_XS = 2;
|
||||
|
||||
/***/ static const CGFloat HEIGHT_LABEL = 17;
|
||||
/***/ static const CGFloat HEIGHT_LABEL_SMALL = 14;
|
||||
/***/ static const CGFloat HEIGHT_INPUTFIELD = 21;
|
||||
/***/ static const CGFloat HEIGHT_BUTTON = 21;
|
||||
/***/ static const CGFloat HEIGHT_INLINEBUTTON = 16;
|
||||
/***/ static const CGFloat HEIGHT_POPUP = 21;
|
||||
/***/ static const CGFloat HEIGHT_SPINNER = 16;
|
||||
/***/ static const CGFloat HEIGHT_CHECKBOX = 14;
|
||||
|
||||
/// Static variable to calculate origin center coordinate in its @c superview. The value of this var isn't used.
|
||||
static const CGFloat CENTER = -0.015625;
|
||||
|
||||
/// Calculate @c origin.y going down from the top border of its @c superview
|
||||
NS_INLINE CGFloat YFromTop(NSView *view) { return NSHeight(view.superview.frame) - NSMinY(view.frame) - view.alignmentRectInsets.bottom; }
|
||||
|
||||
|
||||
/*
|
||||
Allmost all methods return @c self to allow method chaining
|
||||
*/
|
||||
|
||||
@interface NSView (Ext)
|
||||
// UI: TextFields
|
||||
+ (NSTextField*)label:(NSString*)text;
|
||||
+ (NSTextField*)inputField:(NSString*)placeholder width:(CGFloat)w;
|
||||
+ (NSView*)labelColumn:(NSArray<NSString*>*)labels rowHeight:(CGFloat)h padding:(CGFloat)pad;
|
||||
// UI: Buttons
|
||||
+ (NSButton*)button:(NSString*)text;
|
||||
+ (NSButton*)buttonImageSquare:(nonnull NSImageName)name;
|
||||
+ (NSButton*)buttonIcon:(NSImage*)img size:(CGFloat)size;
|
||||
+ (NSButton*)inlineButton:(NSString*)text;
|
||||
+ (NSPopUpButton*)popupButton:(CGFloat)w;
|
||||
// UI: Others
|
||||
+ (NSImageView*)imageView:(NSImageName)name size:(CGFloat)size;
|
||||
+ (NSButton*)checkbox:(BOOL)flag;
|
||||
+ (NSProgressIndicator*)activitySpinner;
|
||||
+ (NSView*)radioGroup:(NSArray<NSString*>*)entries target:(id)target action:(nonnull SEL)action;
|
||||
+ (NSView*)radioGroup:(NSArray<NSString*>*)entries;
|
||||
// UI: Enclosing Container
|
||||
- (NSScrollView*)wrapContent:(NSView*)content inScrollView:(NSRect)rect;
|
||||
+ (NSView*)wrapView:(NSView*)other withLabel:(NSString*)str padding:(CGFloat)pad;
|
||||
// Insert UI elements in parent view
|
||||
- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x y:(CGFloat)y;
|
||||
- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x yTop:(CGFloat)y;
|
||||
- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x y:(CGFloat)y;
|
||||
- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x yTop:(CGFloat)y;
|
||||
// Modify existing UI elements
|
||||
- (instancetype)sizableWidthAndHeight;
|
||||
- (instancetype)sizeToRight:(CGFloat)rightPadding;
|
||||
- (instancetype)sizeWidthToFit;
|
||||
- (instancetype)tooltip:(NSString*)tt;
|
||||
// Debugging
|
||||
- (instancetype)colorLayer:(NSColor*)color;
|
||||
+ (NSView*)redCube:(CGFloat)size;
|
||||
@end
|
||||
|
||||
|
||||
@interface NSControl (Ext)
|
||||
- (instancetype)action:(SEL)selector target:(id)target;
|
||||
- (instancetype)large;
|
||||
- (instancetype)small;
|
||||
- (instancetype)tiny;
|
||||
- (instancetype)bold;
|
||||
- (instancetype)textRight;
|
||||
- (instancetype)textCenter;
|
||||
@end
|
||||
|
||||
|
||||
@interface NSTextField (Ext)
|
||||
- (instancetype)gray;
|
||||
- (instancetype)selectable;
|
||||
@end
|
||||
353
baRSS/Helper/NSView+Ext.m
Normal file
353
baRSS/Helper/NSView+Ext.m
Normal file
@@ -0,0 +1,353 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2019 Oleg Geier
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||
// so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#import "NSView+Ext.h"
|
||||
|
||||
@implementation NSView (Ext)
|
||||
|
||||
#pragma mark - UI: TextFields -
|
||||
|
||||
/// Create label with non-editable text. Ensures uniform fontsize and text color. @c 17px height.
|
||||
+ (NSTextField*)label:(NSString*)text {
|
||||
NSTextField *label = [NSTextField labelWithString:text];
|
||||
[label setFrameSize: NSMakeSize(0, HEIGHT_LABEL)];
|
||||
label.font = [NSFont systemFontOfSize: NSFont.systemFontSize];
|
||||
label.textColor = [NSColor controlTextColor];
|
||||
label.lineBreakMode = NSLineBreakByTruncatingTail;
|
||||
// label.backgroundColor = [NSColor yellowColor];
|
||||
// label.drawsBackground = YES;
|
||||
return [label sizeWidthToFit];
|
||||
}
|
||||
|
||||
/// Create input text field with placeholder text. @c 21px height.
|
||||
+ (NSTextField*)inputField:(NSString*)placeholder width:(CGFloat)w {
|
||||
NSTextField *input = [NSTextField textFieldWithString:@""];
|
||||
[input setFrameSize: NSMakeSize(w, HEIGHT_INPUTFIELD)];
|
||||
input.alignment = NSTextAlignmentJustified;
|
||||
input.placeholderString = placeholder;
|
||||
input.font = [NSFont systemFontOfSize: NSFont.systemFontSize];
|
||||
input.textColor = [NSColor controlTextColor];
|
||||
return input;
|
||||
}
|
||||
|
||||
/// Create view with @c NSTextField subviews with right-aligned and row-centered text from @c labels.
|
||||
+ (NSView*)labelColumn:(NSArray<NSString*>*)labels rowHeight:(CGFloat)h padding:(CGFloat)pad {
|
||||
CGFloat w = 0, y = 0;
|
||||
CGFloat off = (h - HEIGHT_LABEL) / 2;
|
||||
NSView *parent = [[NSView alloc] init];
|
||||
for (NSUInteger i = 0; i < labels.count; i++) {
|
||||
NSTextField *lbl = [[NSView label:labels[i]] placeIn:parent xRight:0 yTop:y + off];
|
||||
if (w < NSWidth(lbl.frame))
|
||||
w = NSWidth(lbl.frame);
|
||||
y += h + pad;
|
||||
}
|
||||
[parent setFrameSize: NSMakeSize(w, y - pad)];
|
||||
return parent;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - UI: Buttons -
|
||||
|
||||
|
||||
/// Create button. @c 21px height.
|
||||
+ (NSButton*)button:(NSString*)text {
|
||||
NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, 0, HEIGHT_BUTTON)];
|
||||
btn.font = [NSFont systemFontOfSize:NSFont.systemFontSize];
|
||||
btn.bezelStyle = NSBezelStyleRounded;
|
||||
btn.title = text;
|
||||
return [btn sizeWidthToFit];
|
||||
}
|
||||
|
||||
/// Create @c NSBezelStyleSmallSquare image button. @c 25x21px
|
||||
+ (NSButton*)buttonImageSquare:(nonnull NSImageName)name {
|
||||
NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, 25, HEIGHT_BUTTON)];
|
||||
btn.bezelStyle = NSBezelStyleSmallSquare;
|
||||
btn.image = [NSImage imageNamed:name];
|
||||
if (!btn.image) btn.title = name; // fallback to text
|
||||
return btn;
|
||||
}
|
||||
|
||||
/// Create pure image button with no border.
|
||||
+ (NSButton*)buttonIcon:(NSImage*)img size:(CGFloat)size {
|
||||
NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, size, size)];
|
||||
btn.bezelStyle = NSBezelStyleRounded;
|
||||
btn.bordered = NO;
|
||||
btn.image = img;
|
||||
return btn;
|
||||
}
|
||||
|
||||
/// Create gray inline button with rounded corners. @c 16px height.
|
||||
+ (NSButton*)inlineButton:(NSString*)text {
|
||||
NSButton *btn = [[NSButton alloc] initWithFrame: NSMakeRect(0, 0, 0, HEIGHT_INLINEBUTTON)];
|
||||
btn.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight: NSFontWeightBold];
|
||||
btn.bezelStyle = NSBezelStyleInline;
|
||||
btn.controlSize = NSControlSizeSmall;
|
||||
btn.title = text;
|
||||
return [btn sizeWidthToFit];
|
||||
}
|
||||
|
||||
/// Create empty drop down button. @c 21px height.
|
||||
+ (NSPopUpButton*)popupButton:(CGFloat)w {
|
||||
return [[NSPopUpButton alloc] initWithFrame: NSMakeRect(0, 0, w, HEIGHT_POPUP) pullsDown:NO];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - UI: Others -
|
||||
|
||||
|
||||
/// Create @c ImageView with square @c size
|
||||
+ (NSImageView*)imageView:(NSImageName)name size:(CGFloat)size {
|
||||
NSImageView *imgView = [[NSImageView alloc] initWithFrame: NSMakeRect(0, 0, size, size)];
|
||||
if (name) imgView.image = [NSImage imageNamed:name];
|
||||
return imgView;
|
||||
}
|
||||
|
||||
/// Create checkbox. @c 14px height.
|
||||
+ (NSButton*)checkbox:(BOOL)flag {
|
||||
NSButton *check = [NSButton checkboxWithTitle:@"" target:nil action:nil];
|
||||
check.title = @""; // needed, otherwise will print "Button"
|
||||
check.frame = NSMakeRect(0, 0, HEIGHT_CHECKBOX, HEIGHT_CHECKBOX);
|
||||
check.state = (flag? NSControlStateValueOn : NSControlStateValueOff);
|
||||
return check;
|
||||
}
|
||||
|
||||
/// Create progress spinner. @c 16px size.
|
||||
+ (NSProgressIndicator*)activitySpinner {
|
||||
NSProgressIndicator *spin = [[NSProgressIndicator alloc] initWithFrame: NSMakeRect(0, 0, HEIGHT_SPINNER, HEIGHT_SPINNER)];
|
||||
spin.indeterminate = YES;
|
||||
spin.displayedWhenStopped = NO;
|
||||
spin.style = NSProgressIndicatorSpinningStyle;
|
||||
spin.controlSize = NSControlSizeSmall;
|
||||
return spin;
|
||||
}
|
||||
|
||||
/// Create grouping view with vertically, left-aligned radio buttons. Action is identical for all buttons (grouping).
|
||||
+ (NSView*)radioGroup:(NSArray<NSString*>*)entries target:(id)target action:(nonnull SEL)action {
|
||||
if (entries.count == 0)
|
||||
return nil;
|
||||
CGFloat w = 0, h = 0;
|
||||
NSView *parent = [[NSView alloc] init];
|
||||
for (NSUInteger i = entries.count; i > 0; i--) {
|
||||
NSButton *btn = [NSButton radioButtonWithTitle:entries[i-1] target:target action:action];
|
||||
btn.tag = (NSInteger)i-1;
|
||||
if (btn.tag == 0)
|
||||
btn.state = NSControlStateValueOn;
|
||||
if (w < NSWidth(btn.frame)) // find max width (before alignmentRect:)
|
||||
w = NSWidth(btn.frame);
|
||||
[btn placeIn:parent x:0 y:h];
|
||||
h += NSHeight([btn alignmentRectForFrame:btn.frame]) + PAD_XS;
|
||||
}
|
||||
[parent setFrameSize: NSMakeSize(w, h - PAD_XS)];
|
||||
return parent;
|
||||
}
|
||||
|
||||
/// Same as @c radioGroup:target:action: but using dummy action to ignore radio button click events.
|
||||
+ (NSView*)radioGroup:(NSArray<NSString*>*)entries {
|
||||
return [self radioGroup:entries target:self action:@selector(donothing)];
|
||||
}
|
||||
|
||||
/// Solely used to group radio buttons
|
||||
+ (void)donothing {}
|
||||
|
||||
|
||||
#pragma mark - UI: Enclosing Container -
|
||||
|
||||
|
||||
/// Insert @c scrollView, remove @c self from current view and set as @c documentView for the newly created scroll view.
|
||||
- (NSScrollView*)wrapContent:(NSView*)content inScrollView:(NSRect)rect {
|
||||
NSScrollView *scroll = [[[NSScrollView alloc] initWithFrame:rect] sizableWidthAndHeight];
|
||||
scroll.borderType = NSBezelBorder;
|
||||
scroll.hasVerticalScroller = YES;
|
||||
scroll.horizontalScrollElasticity = NSScrollElasticityNone;
|
||||
[self addSubview:scroll];
|
||||
|
||||
if (content.superview) [content removeFromSuperview]; // remove if added already (e.g., helper methods above)
|
||||
content.frame = NSMakeRect(0, 0, scroll.contentSize.width, scroll.contentSize.height);
|
||||
scroll.documentView = content;
|
||||
return scroll;
|
||||
}
|
||||
|
||||
/// Create view with @c NSTextField label in front of the view.
|
||||
+ (NSView*)wrapView:(NSView*)other withLabel:(NSString*)str padding:(CGFloat)pad {
|
||||
NSView *parent = [[NSView alloc] initWithFrame: NSZeroRect];
|
||||
NSTextField *label = [NSView label:str];
|
||||
[label placeIn:parent x:pad yTop:pad];
|
||||
[other placeIn:parent x:pad + NSWidth(label.frame) yTop:pad];
|
||||
[parent setFrameSize: NSMakeSize(NSMaxX(other.frame), NSHeight(other.frame) + 2 * pad)];
|
||||
return parent;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Insert UI elements in parent view -
|
||||
|
||||
|
||||
/**
|
||||
Set frame origin and insert @c self in @c parent view with @c frameForAlignmentRect:.
|
||||
You may use @c CENTER to automatically calculate midpoint in parent view.
|
||||
The @c autoresizingMask will be set accordingly.
|
||||
*/
|
||||
- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x y:(CGFloat)y {
|
||||
SetCenterableOrigin(self, parent, x, y);
|
||||
if (x == CENTER) self.autoresizingMask |= NSViewMinXMargin | NSViewMaxXMargin;
|
||||
if (y == CENTER) self.autoresizingMask |= NSViewMinYMargin | NSViewMaxYMargin;
|
||||
self.frame = [self frameForAlignmentRect:self.frame];
|
||||
[parent addSubview:self];
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Same as @c placeIn:x:y: but measure position from top instead of bottom. Also sets @c autoresizingMask.
|
||||
- (instancetype)placeIn:(NSView*)parent x:(CGFloat)x yTop:(CGFloat)y {
|
||||
return [[self placeIn:parent x:x y:NSHeight(parent.frame) - NSHeight(self.frame) - y] alignTop];
|
||||
}
|
||||
|
||||
/// Same as @c placeIn:x:y: but measure position from right instead of left. Also sets @c autoresizingMask.
|
||||
- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x y:(CGFloat)y {
|
||||
return [[self placeIn:parent x:NSWidth(parent.frame) - NSWidth(self.frame) - x y:y] alignRight];
|
||||
}
|
||||
|
||||
/// Set origin by measuring from top right (@c CENTER is not allowed here). Also sets @c autoresizingMask.
|
||||
- (instancetype)placeIn:(NSView*)parent xRight:(CGFloat)x yTop:(CGFloat)y {
|
||||
[self setFrameOrigin: NSMakePoint(NSWidth(parent.frame) - NSWidth(self.frame) - x,
|
||||
NSHeight(parent.frame) - NSHeight(self.frame) - y)];
|
||||
self.autoresizingMask = NSViewMinXMargin | NSViewMinYMargin;
|
||||
self.frame = [self frameForAlignmentRect:self.frame];
|
||||
[parent addSubview:self];
|
||||
return self;
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Modify existing UI elements -
|
||||
|
||||
|
||||
// Aligned Frame Origins
|
||||
// pad - view.alignmentRectInsets.left;
|
||||
// pad - view.alignmentRectInsets.bottom;
|
||||
// NSWidth(view.superview.frame) - NSWidth(view.frame) - pad + view.alignmentRectInsets.right;
|
||||
// NSHeight(view.superview.frame) - NSHeight(view.frame) - pad + view.alignmentRectInsets.top;
|
||||
|
||||
/// Modify @c .autoresizingMask; Clear @c NSViewMaxYMargin flag and set @c NSViewMinYMargin
|
||||
- (instancetype)alignTop { self.autoresizingMask = (self.autoresizingMask & ~NSViewMaxYMargin) | NSViewMinYMargin; return self; }
|
||||
|
||||
/// Modify @c .autoresizingMask; Clear @c NSViewMaxXMargin flag and set @c NSViewMinXMargin
|
||||
- (instancetype)alignRight { self.autoresizingMask = (self.autoresizingMask & ~NSViewMaxXMargin) | NSViewMinXMargin; return self; }
|
||||
|
||||
/// Modify @c .autoresizingMask; Add @c NSViewWidthSizable @c | @c NSViewHeightSizable flags
|
||||
- (instancetype)sizableWidthAndHeight { self.autoresizingMask |= NSViewWidthSizable | NSViewHeightSizable; return self; }
|
||||
|
||||
/// Extend frame in its @c superview and stick to right with padding. Adds @c NSViewWidthSizable to @c autoresizingMask
|
||||
- (instancetype)sizeToRight:(CGFloat)rightPadding {
|
||||
SetFrameWidth(self, NSWidth(self.superview.frame) - NSMinX(self.frame) - rightPadding + self.alignmentRectInsets.right);
|
||||
self.autoresizingMask |= NSViewWidthSizable;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Set @c width to @c fittingSize.width but keep original height.
|
||||
- (instancetype)sizeWidthToFit {
|
||||
SetFrameWidth(self, self.fittingSize.width);
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Set @c tooltip and @c accessibilityTitle of view and return self
|
||||
- (instancetype)tooltip:(NSString*)tt {
|
||||
self.toolTip = tt;
|
||||
if (self.accessibilityLabel.length == 0)
|
||||
self.accessibilityLabel = tt;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Helper method to get y origin point (from top) while respecting @c alignmentRectInsets and view sizes
|
||||
NS_INLINE void SetCenterableOrigin(NSView *view, NSView *parent, CGFloat x, CGFloat y) {
|
||||
if (x == CENTER) x = (NSWidth(parent.frame) - NSWidth(view.frame)) / 2;
|
||||
if (y == CENTER) y = (NSHeight(parent.frame) - NSHeight(view.frame)) / 2;
|
||||
[view setFrameOrigin: NSMakePoint(x, y)];
|
||||
}
|
||||
|
||||
/// Helper method to set frame width and keep same height
|
||||
NS_INLINE void SetFrameWidth(NSView *view, CGFloat w) {
|
||||
[view setFrameSize: NSMakeSize(w, NSHeight(view.frame))];
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Debugging -
|
||||
|
||||
|
||||
/// Set background color on @c .layer
|
||||
- (instancetype)colorLayer:(NSColor*)color {
|
||||
self.layer = [CALayer layer];
|
||||
self.layer.backgroundColor = color.CGColor;
|
||||
return self;
|
||||
}
|
||||
|
||||
+ (NSView*)redCube:(CGFloat)size {
|
||||
return [[[NSView alloc] initWithFrame: NSMakeRect(0, 0, size, size)] colorLayer:NSColor.redColor];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
#pragma mark - NSControl specific -
|
||||
|
||||
|
||||
@implementation NSControl (Ext)
|
||||
|
||||
/// Set @c target and @c action simultaneously
|
||||
- (instancetype)action:(SEL)selector target:(id)target {
|
||||
self.action = selector;
|
||||
self.target = target;
|
||||
return self;
|
||||
}
|
||||
|
||||
/// Set system font with current @c pointSize @c + @c 2. A label will be @c 19px height.
|
||||
- (instancetype)large { SetFontAndResize(self, [NSFont systemFontOfSize: self.font.pointSize + 2]); return self; }
|
||||
|
||||
/// Set system font with @c smallSystemFontSize and perform @c sizeToFit. A label will be @c 14px height.
|
||||
- (instancetype)small { SetFontAndResize(self, [NSFont systemFontOfSize: NSFont.smallSystemFontSize]); return self; }
|
||||
|
||||
/// Set monospaced font with @c labelFontSize regular and perform @c sizeToFit. A label will be @c 13px height.
|
||||
- (instancetype)tiny { SetFontAndResize(self, [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight: NSFontWeightRegular]); return self; }
|
||||
|
||||
/// Set system bold font with current @c pointSize
|
||||
- (instancetype)bold { SetFontAndResize(self, [NSFont boldSystemFontOfSize: self.font.pointSize]); return self; }
|
||||
|
||||
/// Set @c .alignment to @c NSTextAlignmentRight
|
||||
- (instancetype)textRight { self.alignment = NSTextAlignmentRight; return self; }
|
||||
|
||||
/// Set @c .alignment to @c NSTextAlignmentCenter
|
||||
- (instancetype)textCenter { self.alignment = NSTextAlignmentCenter; return self; }
|
||||
|
||||
/// Helper method to set new font, subsequently run @c sizeToFit
|
||||
NS_INLINE void SetFontAndResize(NSControl *control, NSFont *font) {
|
||||
control.font = font; [control sizeToFit];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
@implementation NSTextField (Ext)
|
||||
|
||||
/// Set text color to @c systemGrayColor
|
||||
- (instancetype)gray { self.textColor = [NSColor systemGrayColor]; return self; }
|
||||
|
||||
/// Set @c .selectable to @c YES
|
||||
- (instancetype)selectable { self.selectable = YES; return self; }
|
||||
|
||||
@end
|
||||
@@ -1,38 +0,0 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2019 Oleg Geier
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||
// so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#import <Cocoa/Cocoa.h>
|
||||
|
||||
@protocol RefreshIntervalButtonDelegate <NSObject>
|
||||
@required
|
||||
/**
|
||||
The interval-unit combination is stored as follows:
|
||||
:: @c sender.tag @c >> @c 3 (Refresh Interval)
|
||||
:: @c sender.tag @c & @c 0x7 (Refresh Unit, where 0: seconds and 4: weeks)
|
||||
*/
|
||||
- (void)refreshIntervalButtonClicked:(NSButton*)sender;
|
||||
@end
|
||||
|
||||
@interface Statistics : NSObject
|
||||
+ (NSDictionary*)refreshInterval:(NSArray<NSDate*> *)list;
|
||||
+ (NSView*)viewForRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id<RefreshIntervalButtonDelegate>)callback;
|
||||
@end
|
||||
@@ -1,188 +0,0 @@
|
||||
//
|
||||
// The MIT License (MIT)
|
||||
// Copyright (c) 2019 Oleg Geier
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
// this software and associated documentation files (the "Software"), to deal in
|
||||
// the Software without restriction, including without limitation the rights to
|
||||
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
// of the Software, and to permit persons to whom the Software is furnished to do
|
||||
// so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all
|
||||
// copies or substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
// SOFTWARE.
|
||||
|
||||
#import "Statistics.h"
|
||||
#import "NSDate+Ext.h"
|
||||
|
||||
@implementation Statistics
|
||||
|
||||
#pragma mark - Generate Refresh Interval Statistics
|
||||
|
||||
/**
|
||||
@return @c nil if list contains less than 2 entries. Otherwise: @{min, max, avg, median, earliest, latest}
|
||||
*/
|
||||
+ (NSDictionary*)refreshInterval:(NSArray<NSDate*> *)list {
|
||||
if (!list || list.count == 0)
|
||||
return nil;
|
||||
|
||||
NSDate *earliest = [NSDate distantFuture];
|
||||
NSDate *latest = [NSDate distantPast];
|
||||
NSDate *prev = nil;
|
||||
NSMutableArray<NSNumber*> *differences = [NSMutableArray array];
|
||||
for (NSDate *d in list) {
|
||||
if (![d isKindOfClass:[NSDate class]]) // because valueForKeyPath: can return NSNull
|
||||
continue;
|
||||
earliest = [d earlierDate:earliest];
|
||||
latest = [d laterDate:latest];
|
||||
if (prev) {
|
||||
int dif = abs((int)[d timeIntervalSinceDate:prev]);
|
||||
[differences addObject:[NSNumber numberWithInt:dif]];
|
||||
}
|
||||
prev = d;
|
||||
}
|
||||
if (differences.count == 0)
|
||||
return nil;
|
||||
|
||||
[differences sortUsingDescriptors:@[[NSSortDescriptor sortDescriptorWithKey:@"integerValue" ascending:YES]]];
|
||||
|
||||
NSUInteger i = (differences.count/2);
|
||||
NSNumber *median = differences[i];
|
||||
if ((differences.count % 2) == 0) { // even feed count, use median of two values
|
||||
median = [NSNumber numberWithInteger:(median.integerValue + differences[i-1].integerValue) / 2];
|
||||
}
|
||||
return @{@"min" : differences.firstObject,
|
||||
@"max" : differences.lastObject,
|
||||
@"avg" : [differences valueForKeyPath:@"@avg.self"],
|
||||
@"median" : median,
|
||||
@"earliest" : earliest,
|
||||
@"latest" : latest };
|
||||
}
|
||||
|
||||
|
||||
#pragma mark - Feed Statistics UI
|
||||
|
||||
/**
|
||||
Generate UI with buttons for min, max, avg and median. Also show number of articles and latest article date.
|
||||
|
||||
@param info The dictionary generated with @c -refreshInterval:
|
||||
@param count Article count.
|
||||
@param callback If set, @c sender will be called with @c -refreshIntervalButtonClicked:.
|
||||
If not disable button border and display as bold inline text.
|
||||
@return Centered view without autoresizing.
|
||||
*/
|
||||
+ (NSView*)viewForRefreshInterval:(NSDictionary*)info articleCount:(NSUInteger)count callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
|
||||
NSString *lbl = [NSString stringWithFormat:NSLocalizedString(@"%lu articles.", nil), count];
|
||||
if (!info || info.count == 0)
|
||||
return [self grayLabel:lbl];
|
||||
|
||||
// Subview with 4 button (min, max, avg, median)
|
||||
NSView *buttonsView = [[NSView alloc] init];
|
||||
NSPoint origin = NSZeroPoint;
|
||||
for (NSString *str in @[@"min", @"max", @"avg", @"median"]) {
|
||||
NSString *title = [str stringByAppendingString:@":"];
|
||||
NSView *v = [self viewWithLabel:title andInterval:info[str] callback:callback];
|
||||
[v setFrameOrigin:origin];
|
||||
[buttonsView addSubview:v];
|
||||
origin.x += NSWidth(v.frame);
|
||||
}
|
||||
[buttonsView setFrameSize:NSMakeSize(origin.x, NSHeight(buttonsView.subviews.firstObject.frame))];
|
||||
|
||||
// Subview with article count and latest article date
|
||||
NSDate *lastUpdate = [info valueForKey:@"latest"];
|
||||
NSString *mod = [NSDateFormatter localizedStringFromDate:lastUpdate dateStyle:NSDateFormatterMediumStyle timeStyle:NSDateFormatterShortStyle];
|
||||
NSTextField *dateView = [self grayLabel:[lbl stringByAppendingFormat:@" (latest: %@)", mod]];
|
||||
|
||||
// Feed wasn't updated in a while ...
|
||||
if ([lastUpdate timeIntervalSinceNow] < (-360 * 24 * 60 * 60)) {
|
||||
NSMutableAttributedString *as = dateView.attributedStringValue.mutableCopy;
|
||||
[as addAttribute:NSForegroundColorAttributeName value:[NSColor systemRedColor] range:NSMakeRange(lbl.length, as.length - lbl.length)];
|
||||
[dateView setAttributedStringValue:as];
|
||||
}
|
||||
|
||||
// Calculate offset and align both horizontally centered
|
||||
CGFloat maxWidth = NSWidth(buttonsView.frame);
|
||||
if (maxWidth < NSWidth(dateView.frame))
|
||||
maxWidth = NSWidth(dateView.frame);
|
||||
[buttonsView setFrameOrigin:NSMakePoint(0.5f*(maxWidth - NSWidth(buttonsView.frame)), 0)];
|
||||
[dateView setFrameOrigin:NSMakePoint(0.5f*(maxWidth - NSWidth(dateView.frame)), NSHeight(buttonsView.frame))];
|
||||
|
||||
// Dump both into single parent view and make that view centered during resize
|
||||
NSView *parent = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, maxWidth, NSMaxY(dateView.frame))];
|
||||
parent.autoresizingMask = NSViewMinXMargin | NSViewMaxXMargin;// | NSViewMinYMargin | NSViewMaxYMargin;
|
||||
parent.autoresizesSubviews = NO;
|
||||
// parent.layer = [CALayer layer];
|
||||
// parent.layer.backgroundColor = [NSColor systemYellowColor].CGColor;
|
||||
[parent addSubview:dateView];
|
||||
[parent addSubview:buttonsView];
|
||||
return parent;
|
||||
}
|
||||
|
||||
/**
|
||||
Create view with duration button, e.g., '3.4h' and label infornt of it.
|
||||
*/
|
||||
+ (NSView*)viewWithLabel:(NSString*)title andInterval:(NSNumber*)value callback:(nullable id<RefreshIntervalButtonDelegate>)callback {
|
||||
static const int buttonPadding = 5;
|
||||
NSButton *button = [self grayInlineButton:value];
|
||||
if (callback) {
|
||||
button.target = callback;
|
||||
button.action = @selector(refreshIntervalButtonClicked:);
|
||||
} else {
|
||||
button.bordered = NO;
|
||||
button.enabled = NO;
|
||||
}
|
||||
NSTextField *label;
|
||||
if (title && title.length > 0) {
|
||||
label = [self grayLabel:title];
|
||||
[label setFrameOrigin:NSMakePoint(0, button.alignmentRectInsets.bottom + 0.5f*(NSHeight(button.frame) - NSHeight(label.frame)))];
|
||||
}
|
||||
[button setFrameOrigin:NSMakePoint(NSWidth(label.frame), 0)];
|
||||
|
||||
CGFloat maxHeight = NSHeight(button.frame);
|
||||
if (maxHeight < NSHeight(label.frame))
|
||||
maxHeight = NSHeight(label.frame);
|
||||
|
||||
NSView *parent = [[NSView alloc] initWithFrame: NSMakeRect(0, 0, NSMaxX(button.frame) + buttonPadding, maxHeight + buttonPadding)];
|
||||
[parent addSubview:label];
|
||||
[parent addSubview:button];
|
||||
return parent;
|
||||
}
|
||||
|
||||
/**
|
||||
@return Rounded, gray inline button with tag equal to refresh interval.
|
||||
*/
|
||||
+ (NSButton*)grayInlineButton:(NSNumber*)num {
|
||||
NSButton *button = [NSButton buttonWithTitle:[NSDate stringForInterval:num.intValue rounded:YES] target:nil action:nil];
|
||||
button.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightBold];
|
||||
button.bezelStyle = NSBezelStyleInline;
|
||||
button.controlSize = NSControlSizeSmall;
|
||||
TimeUnitType unit = [NSDate unitForInterval:num.intValue rounded:YES];
|
||||
button.tag = (NSInteger)(roundf(num.floatValue / unit) * unit); // rounded inteval
|
||||
[button sizeToFit];
|
||||
return button;
|
||||
}
|
||||
|
||||
/**
|
||||
@return Simple Label with smaller gray text, non-editable.
|
||||
*/
|
||||
+ (NSTextField*)grayLabel:(NSString*)text {
|
||||
NSTextField *label = [NSTextField textFieldWithString:text];
|
||||
label.font = [NSFont monospacedDigitSystemFontOfSize: NSFont.labelFontSize weight:NSFontWeightRegular];
|
||||
label.textColor = [NSColor systemGrayColor];
|
||||
label.drawsBackground = NO;
|
||||
label.selectable = NO;
|
||||
label.editable = NO;
|
||||
label.bezeled = NO;
|
||||
[label sizeToFit];
|
||||
return label;
|
||||
}
|
||||
|
||||
@end
|
||||
Reference in New Issue
Block a user