diff --git a/Headers/AppKit/NSDatePickerCell.h b/Headers/AppKit/NSDatePickerCell.h index d0ea9b0757..3cda2f6c2a 100644 --- a/Headers/AppKit/NSDatePickerCell.h +++ b/Headers/AppKit/NSDatePickerCell.h @@ -76,6 +76,9 @@ APPKIT_EXPORT_CLASS NSDatePickerMode _datePickerMode; NSDatePickerStyle _datePickerStyle; BOOL _drawsBackground; + NSInteger _selectedField; + NSInteger _typedValue; + NSInteger _typedDigits; } - (NSColor *) backgroundColor; diff --git a/Source/NSDatePicker.m b/Source/NSDatePicker.m index 1d39804993..7960c48252 100644 --- a/Source/NSDatePicker.m +++ b/Source/NSDatePicker.m @@ -29,10 +29,25 @@ Boston, MA 02110-1301, USA. */ +#import #import #import "AppKit/NSDatePickerCell.h" #import "AppKit/NSDatePicker.h" +#import "AppKit/NSEvent.h" + +@interface NSDatePickerCell (Private) +- (BOOL) _handleKeyEvent: (NSEvent *)event; +- (BOOL) _selectFieldAtPoint: (NSPoint)point inRect: (NSRect)frame; +- (NSInteger) _stepperDirectionAtPoint: (NSPoint)point + inRect: (NSRect)frame + ofView: (NSView *)view; +- (BOOL) _stepSelectedFieldBy: (NSInteger)delta; +- (BOOL) _showsCalendar; +- (BOOL) _selectDayAtPoint: (NSPoint)point + inRect: (NSRect)frame + ofView: (NSView *)view; +@end static id usedCellClass = nil; @@ -57,6 +72,92 @@ + (void) setCellClass: (Class)cellClass usedCellClass = cellClass; } +- (void) keyDown: (NSEvent *)theEvent +{ + NSDate *before = [_cell dateValue]; + + if ([(NSDatePickerCell *)_cell _handleKeyEvent: theEvent]) + { + NSDate *after = [_cell dateValue]; + + [self setNeedsDisplay: YES]; + if (before == nil || ![before isEqualToDate: after]) + { + [self sendAction: [self action] to: [self target]]; + } + return; + } + + [super keyDown: theEvent]; +} + +- (void) mouseDown: (NSEvent *)theEvent +{ + NSDatePickerCell *cell = (NSDatePickerCell *)_cell; + NSPoint point; + NSDate *before; + NSInteger direction; + + if (![self isEnabled]) + { + [super mouseDown: theEvent]; + return; + } + + point = [self convertPoint: [theEvent locationInWindow] fromView: nil]; + before = [cell dateValue]; + if ([cell _showsCalendar]) + { + if (![cell _selectDayAtPoint: point inRect: _bounds ofView: self]) + { + [super mouseDown: theEvent]; + return; + } + [[self window] makeFirstResponder: self]; + [self setNeedsDisplay: YES]; + if (before == nil || ![before isEqualToDate: [cell dateValue]]) + { + [self sendAction: [self action] to: [self target]]; + } + return; + } + + direction = [cell _stepperDirectionAtPoint: point + inRect: _bounds + ofView: self]; + if (direction == 0 && ![cell _selectFieldAtPoint: point inRect: _bounds]) + { + [super mouseDown: theEvent]; + return; + } + + if ([[self window] firstResponder] != self) + { + [[self window] makeFirstResponder: self]; + } + if (direction != 0) + { + [cell _stepSelectedFieldBy: direction]; + } + [self setNeedsDisplay: YES]; + if (before == nil || ![before isEqualToDate: [cell dateValue]]) + { + [self sendAction: [self action] to: [self target]]; + } +} + +- (BOOL) becomeFirstResponder +{ + [self setNeedsDisplay: YES]; + return YES; +} + +- (BOOL) resignFirstResponder +{ + [self setNeedsDisplay: YES]; + return YES; +} + - (NSColor *) backgroundColor { return [_cell backgroundColor]; diff --git a/Source/NSDatePickerCell.m b/Source/NSDatePickerCell.m index bd640d3562..fbf9c9b7a3 100644 --- a/Source/NSDatePickerCell.m +++ b/Source/NSDatePickerCell.m @@ -29,14 +29,38 @@ Boston, MA 02110-1301, USA. */ +#import +#import +#import +#import +#import #import #import #import +#import +#import +#import +#import + +#import "AppKit/NSAttributedString.h" +#import "AppKit/NSBezierPath.h" #import "AppKit/NSDatePickerCell.h" #import "AppKit/NSColor.h" +#import "AppKit/NSEvent.h" +#import "AppKit/NSFont.h" +#import "AppKit/NSGraphics.h" +#import "AppKit/NSImage.h" +#import "AppKit/NSStringDrawing.h" +#import "AppKit/NSWindow.h" +#import "GNUstepGUI/GSTheme.h" @interface NSDatePickerCell (Private) - (void) _updateDateFormat; +- (NSArray *) _editableFields; +- (NSCalendar *) _pickerCalendar; +- (NSRange) _rangeOfFieldAtIndex: (NSInteger)wanted + inString: (NSString *)text; +- (void) _setSelectedFieldIndex: (NSInteger)index; @end @implementation NSDatePickerCell @@ -175,6 +199,191 @@ - (void) _updateDateFormat } } [formatter setDateFormat: (format == nil) ? (NSString *)@"" : format]; + _selectedField = 0; + _typedValue = 0; + _typedDigits = 0; +} + +/* Every run of one pattern letter is a field. Only the runs standing for a + part of the date the user can change are returned, in the order the + pattern writes them. Text between quotes is a literal, not a field. +*/ +- (NSArray *) _editableFields +{ + NSDateFormatter *formatter = (NSDateFormatter *)[self formatter]; + NSString *pattern; + NSMutableArray *fields; + NSUInteger index; + NSUInteger length; + BOOL quoted = NO; + + if (![formatter isKindOfClass: [NSDateFormatter class]]) + { + return [NSArray array]; + } + + pattern = [formatter dateFormat]; + length = [pattern length]; + fields = [NSMutableArray arrayWithCapacity: 8]; + for (index = 0; index < length; index++) + { + unichar c = [pattern characterAtIndex: index]; + NSUInteger run = index; + + if (c == '\'') + { + quoted = !quoted; + continue; + } + if (quoted) + { + continue; + } + if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'))) + { + continue; + } + while (index + 1 < length && [pattern characterAtIndex: index + 1] == c) + { + index++; + } + switch (c) + { + case 'y': case 'Y': case 'u': + case 'M': case 'L': + case 'd': + case 'h': case 'H': case 'k': case 'K': + case 'm': + case 's': + case 'a': case 'b': case 'B': + [fields addObject: [pattern substringWithRange: + NSMakeRange(run, index - run + 1)]]; + break; + default: + break; + } + } + + return fields; +} + +/* The calendar the picker counts in. It has to hold the time zone of the + picker, or a day added to a date lands at the wrong hour. +*/ +- (NSCalendar *) _pickerCalendar +{ + NSCalendar *calendar = [self calendar]; + NSTimeZone *zone = [self timeZone]; + + if (calendar == nil) + { + calendar = [NSCalendar currentCalendar]; + } + calendar = AUTORELEASE([calendar copy]); + if (zone != nil) + { + [calendar setTimeZone: zone]; + } + if ([self locale] != nil) + { + [calendar setLocale: [self locale]]; + } + + return calendar; +} + +- (NSCalendarUnit) _unitOfField: (NSString *)field +{ + switch ([field characterAtIndex: 0]) + { + case 'y': case 'Y': case 'u': return NSCalendarUnitYear; + case 'M': case 'L': return NSCalendarUnitMonth; + case 'd': return NSCalendarUnitDay; + case 'h': case 'H': + case 'k': case 'K': return NSCalendarUnitHour; + case 'm': return NSCalendarUnitMinute; + case 's': return NSCalendarUnitSecond; + default: return 0; + } +} + +- (NSInteger) _selectedFieldIndex +{ + NSInteger count = (NSInteger)[[self _editableFields] count]; + + if (count == 0) + { + return -1; + } + if (_selectedField < 0 || _selectedField >= count) + { + return 0; + } + + return _selectedField; +} + +- (void) _setSelectedFieldIndex: (NSInteger)index +{ + NSInteger count = (NSInteger)[[self _editableFields] count]; + + if (count > 0) + { + while (index < 0) + { + index += count; + } + _selectedField = index % count; + } + _typedValue = 0; + _typedDigits = 0; +} + +/* The range the field covers in the text the cell shows. The fields are + looked for in the order they are written, so a number that appears twice + is still found in its own place. +*/ +- (NSRange) _rangeOfFieldAtIndex: (NSInteger)wanted + inString: (NSString *)text +{ + NSDateFormatter *formatter = (NSDateFormatter *)[self formatter]; + NSArray *fields = [self _editableFields]; + NSDateFormatter *scratch; + NSRange found = NSMakeRange(NSNotFound, 0); + NSUInteger from = 0; + NSUInteger index; + NSDate *date = [self dateValue]; + + if (date == nil || wanted < 0 || wanted >= (NSInteger)[fields count]) + { + return found; + } + + scratch = AUTORELEASE([[NSDateFormatter alloc] init]); + [scratch setLocale: [formatter locale]]; + [scratch setTimeZone: [formatter timeZone]]; + [scratch setCalendar: [formatter calendar]]; + + for (index = 0; index <= (NSUInteger)wanted; index++) + { + NSString *part; + NSRange rest = NSMakeRange(from, [text length] - from); + + [scratch setDateFormat: [fields objectAtIndex: index]]; + part = [scratch stringFromDate: date]; + if ([part length] == 0) + { + return NSMakeRange(NSNotFound, 0); + } + found = [text rangeOfString: part options: 0 range: rest]; + if (found.location == NSNotFound) + { + return found; + } + from = NSMaxRange(found); + } + + return found; } - (NSColor *) backgroundColor @@ -252,7 +461,887 @@ - (NSDate *) _clampedDate: (NSDate *)date - (void) setDateValue: (NSDate *)date { - [self setObjectValue: [self _clampedDate: date]]; + NSDate *proposed = [self _clampedDate: date]; + + if (proposed != nil && [_delegate respondsToSelector: + @selector(datePickerCell:validateProposedDateValue:timeInterval:)]) + { + NSTimeInterval interval = _timeInterval; + + [_delegate datePickerCell: self + validateProposedDateValue: &proposed + timeInterval: &interval]; + _timeInterval = interval; + } + [self setObjectValue: proposed]; +} + +/* One step of the field the user is on. The step is a step of the calendar, + so it carries into the fields above it, and the day of a month that is too + short for it moves back to the last day of that month. +*/ +- (BOOL) _stepSelectedFieldBy: (NSInteger)delta +{ + NSArray *fields = [self _editableFields]; + NSInteger index = [self _selectedFieldIndex]; + NSCalendar *calendar = [self _pickerCalendar]; + NSDate *date = [self dateValue]; + NSDateComponents *step; + NSCalendarUnit unit; + NSDate *stepped; + + if (date == nil || index < 0) + { + return NO; + } + + step = AUTORELEASE([[NSDateComponents alloc] init]); + unit = [self _unitOfField: [fields objectAtIndex: index]]; + switch (unit) + { + case NSCalendarUnitYear: [step setYear: delta]; break; + case NSCalendarUnitMonth: [step setMonth: delta]; break; + case NSCalendarUnitDay: [step setDay: delta]; break; + case NSCalendarUnitHour: [step setHour: delta]; break; + case NSCalendarUnitMinute: [step setMinute: delta]; break; + case NSCalendarUnitSecond: [step setSecond: delta]; break; + default: + { + /* The morning and afternoon marker has one other state. */ + NSDateComponents *now = [calendar components: NSCalendarUnitHour + fromDate: date]; + + [step setHour: ([now hour] < 12) ? 12 : -12]; + } + break; + } + + stepped = [calendar dateByAddingComponents: step toDate: date options: 0]; + if (stepped == nil) + { + return NO; + } + _typedValue = 0; + _typedDigits = 0; + [self setDateValue: stepped]; + + return YES; +} + +/* How large the number in a field can grow, so that a digit that cannot be + the first of a larger number is taken on its own. +*/ +- (NSInteger) _highestValueOfField: (NSString *)field +{ + NSCalendar *calendar = [self _pickerCalendar]; + NSDate *date = [self dateValue]; + + switch ([self _unitOfField: field]) + { + case NSCalendarUnitYear: + return 9999; + case NSCalendarUnitMonth: + return [calendar rangeOfUnit: NSCalendarUnitMonth + inUnit: NSCalendarUnitYear + forDate: date].length; + case NSCalendarUnitDay: + return NSMaxRange([calendar rangeOfUnit: NSCalendarUnitDay + inUnit: NSCalendarUnitMonth + forDate: date]) - 1; + case NSCalendarUnitHour: + return ([field characterAtIndex: 0] == 'h' + || [field characterAtIndex: 0] == 'K') ? 12 : 23; + case NSCalendarUnitMinute: + case NSCalendarUnitSecond: + return 59; + default: + return 0; + } +} + +- (BOOL) _setSelectedFieldToValue: (NSInteger)value +{ + NSArray *fields = [self _editableFields]; + NSInteger index = [self _selectedFieldIndex]; + NSCalendar *calendar = [self _pickerCalendar]; + NSDate *date = [self dateValue]; + NSString *field; + NSDateComponents *parts; + NSDate *edited; + NSUInteger units = NSCalendarUnitEra | NSCalendarUnitYear + | NSCalendarUnitMonth | NSCalendarUnitDay | NSCalendarUnitHour + | NSCalendarUnitMinute | NSCalendarUnitSecond; + + if (date == nil || index < 0) + { + return NO; + } + + field = [fields objectAtIndex: index]; + parts = [calendar components: units fromDate: date]; + switch ([self _unitOfField: field]) + { + case NSCalendarUnitYear: + [parts setYear: value]; + break; + case NSCalendarUnitMonth: + [parts setMonth: value]; + break; + case NSCalendarUnitDay: + [parts setDay: value]; + break; + case NSCalendarUnitHour: + if ([field characterAtIndex: 0] == 'h' + || [field characterAtIndex: 0] == 'K') + { + /* A twelve hour field keeps the half of the day it is in. */ + [parts setHour: (value % 12) + (([parts hour] < 12) ? 0 : 12)]; + } + else + { + [parts setHour: value]; + } + break; + case NSCalendarUnitMinute: + [parts setMinute: value]; + break; + case NSCalendarUnitSecond: + [parts setSecond: value]; + break; + default: + return NO; + } + + edited = [calendar dateFromComponents: parts]; + if (edited == nil) + { + return NO; + } + [self setDateValue: edited]; + + return YES; +} + +/* A digit joins the digits already typed into the same field while the + number they make can still grow. Once it cannot, the field takes it. +*/ +- (BOOL) _typeDigit: (NSInteger)digit +{ + NSArray *fields = [self _editableFields]; + NSInteger index = [self _selectedFieldIndex]; + NSInteger highest; + NSInteger value; + + if (index < 0) + { + return NO; + } + + highest = [self _highestValueOfField: [fields objectAtIndex: index]]; + if (highest == 0) + { + return NO; + } + + value = (_typedDigits > 0) ? (_typedValue * 10 + digit) : digit; + if (value > highest) + { + value = digit; + } + _typedValue = value; + _typedDigits++; + + if (value == 0 || value * 10 <= highest) + { + /* Another digit could still follow, so wait for it. */ + return YES; + } + + _typedValue = 0; + _typedDigits = 0; + + return [self _setSelectedFieldToValue: value]; +} + +- (BOOL) _hasMeridiemField +{ + NSEnumerator *fields = [[self _editableFields] objectEnumerator]; + NSString *field; + + while ((field = [fields nextObject]) != nil) + { + if ([self _unitOfField: field] == 0) + { + return YES; + } + } + + return NO; +} + +- (BOOL) _setMeridiemAfterNoon: (BOOL)afternoon +{ + NSCalendar *calendar = [self _pickerCalendar]; + NSDate *date = [self dateValue]; + NSDateComponents *now; + NSDateComponents *step; + NSDate *edited; + + if (date == nil) + { + return NO; + } + now = [calendar components: NSCalendarUnitHour fromDate: date]; + if (([now hour] >= 12) == afternoon) + { + return YES; + } + + step = AUTORELEASE([[NSDateComponents alloc] init]); + [step setHour: afternoon ? 12 : -12]; + edited = [calendar dateByAddingComponents: step toDate: date options: 0]; + if (edited == nil) + { + return NO; + } + [self setDateValue: edited]; + + return YES; +} + +/* The clock and calendar style draws a month of days, a clock, or both, + depending on the elements it is asked for. +*/ +- (BOOL) _showsCalendar +{ + return (_datePickerStyle == NSClockAndCalendarDatePickerStyle + && (_datePickerElements & NSYearMonthDatePickerElementFlag) != 0); +} + +- (BOOL) _showsClock +{ + return (_datePickerStyle == NSClockAndCalendarDatePickerStyle + && (_datePickerElements & NSHourMinuteDatePickerElementFlag) != 0); +} + +- (NSFont *) _drawingFont +{ + NSFont *font = [self font]; + + return (font == nil) ? [NSFont userFontOfSize: 0.0] : font; +} + +- (NSDictionary *) _dayAttributes +{ + NSColor *color = [self textColor]; + + if (color == nil) + { + color = [NSColor controlTextColor]; + } + + return [NSDictionary dictionaryWithObjectsAndKeys: + [self _drawingFont], NSFontAttributeName, + color, NSForegroundColorAttributeName, + nil]; +} + +/* One row of the month, and one column of it. Wide enough for two digits + and for the initial of a weekday. +*/ +- (NSSize) _dayCellSize +{ + NSFont *font = [self _drawingFont]; + NSSize size = [@"88" sizeWithAttributes: + [NSDictionary dictionaryWithObject: font forKey: NSFontAttributeName]]; + + size.width = ceil(size.width) + 8.0; + size.height = ceil([font boundingRectForFont].size.height) + 2.0; + + return size; +} + +/* The month takes a row for its name, a row for the initials of the + weekdays and six rows of days. +*/ +- (NSSize) _calendarSize +{ + NSSize day = [self _dayCellSize]; + + return NSMakeSize(day.width * 7.0, day.height * 8.0); +} + +- (NSRect) _calendarFrameForFrame: (NSRect)frame +{ + NSRect calendar = frame; + + if ([self _showsClock]) + { + calendar.size.width = [self _calendarSize].width; + } + + return calendar; +} + +- (NSRect) _clockFrameForFrame: (NSRect)frame +{ + NSRect clock = frame; + + if ([self _showsCalendar]) + { + CGFloat used = [self _calendarSize].width; + + clock.origin.x += used; + clock.size.width -= used; + } + + return clock; +} + +/* Where a day of the month sits in the grid. Row zero is the first week + under the initials of the weekdays. +*/ +- (NSRect) _dayCellRectAtRow: (NSInteger)row + column: (NSInteger)column + inFrame: (NSRect)frame + ofView: (NSView *)view +{ + NSRect calendar = [self _calendarFrameForFrame: frame]; + NSSize day = [self _dayCellSize]; + NSRect cell; + + cell.size = day; + cell.origin.x = NSMinX(calendar) + column * day.width; + if ([view isFlipped]) + { + cell.origin.y = NSMinY(calendar) + (row + 2) * day.height; + } + else + { + cell.origin.y = NSMaxY(calendar) - (row + 3) * day.height; + } + + return cell; +} + +/* The day of the month in a cell of the grid, or zero when the cell falls + outside the month. +*/ +- (NSInteger) _dayAtRow: (NSInteger)row column: (NSInteger)column +{ + NSCalendar *calendar = [self _pickerCalendar]; + NSDate *date = [self dateValue]; + NSDateComponents *parts; + NSDate *first; + NSInteger lead; + NSInteger day; + NSInteger length; + + if (date == nil) + { + return 0; + } + + parts = [calendar components: NSCalendarUnitEra | NSCalendarUnitYear + | NSCalendarUnitMonth fromDate: date]; + [parts setDay: 1]; + first = [calendar dateFromComponents: parts]; + if (first == nil) + { + return 0; + } + + lead = [[calendar components: NSCalendarUnitWeekday fromDate: first] weekday] + - (NSInteger)[calendar firstWeekday]; + while (lead < 0) + { + lead += 7; + } + length = [calendar rangeOfUnit: NSCalendarUnitDay + inUnit: NSCalendarUnitMonth + forDate: date].length; + day = row * 7 + column - lead + 1; + if (day < 1 || day > length) + { + return 0; + } + + return day; +} + +- (NSString *) _monthTitle +{ + NSDateFormatter *formatter = (NSDateFormatter *)[self formatter]; + NSDateFormatter *scratch = AUTORELEASE([[NSDateFormatter alloc] init]); + NSString *pattern; + + if (![formatter isKindOfClass: [NSDateFormatter class]] + || [self dateValue] == nil) + { + return @""; + } + + [scratch setLocale: [formatter locale]]; + [scratch setTimeZone: [formatter timeZone]]; + [scratch setCalendar: [formatter calendar]]; + pattern = [NSDateFormatter dateFormatFromTemplate: @"yMMMM" + options: 0 + locale: [formatter locale]]; + [scratch setDateFormat: (pattern == nil) ? (NSString *)@"MMMM y" : pattern]; + + return [scratch stringFromDate: [self dateValue]]; +} + +- (NSArray *) _weekdayInitials +{ + NSDateFormatter *formatter = (NSDateFormatter *)[self formatter]; + NSArray *symbols = nil; + + if ([formatter isKindOfClass: [NSDateFormatter class]]) + { + symbols = [formatter veryShortWeekdaySymbols]; + if ([symbols count] != 7) + { + symbols = [formatter shortWeekdaySymbols]; + } + } + if ([symbols count] != 7) + { + symbols = [NSArray arrayWithObjects: @"S", @"M", @"T", @"W", @"T", + @"F", @"S", nil]; + } + + return symbols; +} + +static void +drawCentred(NSString *text, NSRect rect, NSDictionary *attributes) +{ + NSSize size = [text sizeWithAttributes: attributes]; + NSPoint at; + + at.x = NSMinX(rect) + (NSWidth(rect) - size.width) / 2.0; + at.y = NSMinY(rect) + (NSHeight(rect) - size.height) / 2.0; + [text drawAtPoint: at withAttributes: attributes]; +} + +- (void) _drawCalendarInFrame: (NSRect)frame ofView: (NSView *)view +{ + NSDictionary *attributes = [self _dayAttributes]; + NSRect calendar = [self _calendarFrameForFrame: frame]; + NSSize day = [self _dayCellSize]; + NSArray *initials = [self _weekdayInitials]; + NSCalendar *cal = [self _pickerCalendar]; + NSInteger today = [[cal components: NSCalendarUnitDay + fromDate: [self dateValue]] day]; + NSInteger first = (NSInteger)[cal firstWeekday]; + NSRect row; + NSInteger index; + + row = NSMakeRect(NSMinX(calendar), 0.0, NSWidth(calendar), day.height); + row.origin.y = [view isFlipped] ? NSMinY(calendar) + : NSMaxY(calendar) - day.height; + drawCentred([self _monthTitle], row, attributes); + + for (index = 0; index < 7; index++) + { + NSRect cell = [self _dayCellRectAtRow: -1 column: index + inFrame: frame ofView: view]; + + drawCentred([initials objectAtIndex: (first - 1 + index) % 7], + cell, attributes); + } + + for (index = 0; index < 42; index++) + { + NSInteger number = [self _dayAtRow: index / 7 column: index % 7]; + NSRect cell; + + if (number == 0) + { + continue; + } + cell = [self _dayCellRectAtRow: index / 7 column: index % 7 + inFrame: frame ofView: view]; + if (number == today) + { + NSMutableDictionary *marked = AUTORELEASE([attributes mutableCopy]); + + [[NSColor selectedTextBackgroundColor] set]; + NSRectFill(cell); + [marked setObject: [NSColor selectedTextColor] + forKey: NSForegroundColorAttributeName]; + drawCentred([NSString stringWithFormat: @"%ld", (long)number], + cell, marked); + } + else + { + drawCentred([NSString stringWithFormat: @"%ld", (long)number], + cell, attributes); + } + } +} + +- (void) _drawClockInFrame: (NSRect)frame +{ + NSRect clock = [self _clockFrameForFrame: frame]; + CGFloat size = MIN(NSWidth(clock), NSHeight(clock)) - 4.0; + NSPoint centre = NSMakePoint(NSMidX(clock), NSMidY(clock)); + NSCalendar *calendar = [self _pickerCalendar]; + NSDateComponents *parts; + NSBezierPath *face; + CGFloat hour; + CGFloat minute; + NSInteger index; + + if (size <= 0.0 || [self dateValue] == nil) + { + return; + } + + face = [NSBezierPath bezierPathWithOvalInRect: + NSMakeRect(centre.x - size / 2.0, centre.y - size / 2.0, size, size)]; + [[NSColor controlBackgroundColor] set]; + [face fill]; + [[NSColor controlDarkShadowColor] set]; + [face stroke]; + + for (index = 0; index < 12; index++) + { + CGFloat angle = index * M_PI / 6.0; + NSPoint from = NSMakePoint(centre.x + sin(angle) * size * 0.45, + centre.y + cos(angle) * size * 0.45); + NSPoint to = NSMakePoint(centre.x + sin(angle) * size * 0.40, + centre.y + cos(angle) * size * 0.40); + + [NSBezierPath strokeLineFromPoint: from toPoint: to]; + } + + parts = [calendar components: NSCalendarUnitHour | NSCalendarUnitMinute + fromDate: [self dateValue]]; + minute = [parts minute] * M_PI / 30.0; + hour = ([parts hour] % 12) * M_PI / 6.0 + minute / 12.0; + [NSBezierPath strokeLineFromPoint: centre + toPoint: NSMakePoint( + centre.x + sin(hour) * size * 0.25, + centre.y + cos(hour) * size * 0.25)]; + [NSBezierPath strokeLineFromPoint: centre + toPoint: NSMakePoint( + centre.x + sin(minute) * size * 0.38, + centre.y + cos(minute) * size * 0.38)]; +} + +/* Picks the day the point falls on. Returns NO for a point that is not on + a day of this month. +*/ +- (BOOL) _selectDayAtPoint: (NSPoint)point + inRect: (NSRect)frame + ofView: (NSView *)view +{ + NSCalendar *calendar = [self _pickerCalendar]; + NSDateComponents *parts; + NSInteger index; + + if ([self dateValue] == nil) + { + return NO; + } + + for (index = 0; index < 42; index++) + { + NSRect cell = [self _dayCellRectAtRow: index / 7 column: index % 7 + inFrame: frame ofView: view]; + NSInteger number = [self _dayAtRow: index / 7 column: index % 7]; + NSDate *picked; + + if (number == 0 || !NSMouseInRect(point, cell, [view isFlipped])) + { + continue; + } + parts = [calendar components: NSCalendarUnitEra | NSCalendarUnitYear + | NSCalendarUnitMonth | NSCalendarUnitDay | NSCalendarUnitHour + | NSCalendarUnitMinute | NSCalendarUnitSecond + fromDate: [self dateValue]]; + [parts setDay: number]; + picked = [calendar dateFromComponents: parts]; + if (picked == nil) + { + return NO; + } + [self setDateValue: picked]; + return YES; + } + + return NO; +} + +/* In the calendar the arrow keys walk the grid, a day across and a week up + or down. +*/ +- (BOOL) _stepDaysBy: (NSInteger)days +{ + NSCalendar *calendar = [self _pickerCalendar]; + NSDateComponents *step = AUTORELEASE([[NSDateComponents alloc] init]); + NSDate *stepped; + + if ([self dateValue] == nil) + { + return NO; + } + [step setDay: days]; + stepped = [calendar dateByAddingComponents: step + toDate: [self dateValue] + options: 0]; + if (stepped == nil) + { + return NO; + } + [self setDateValue: stepped]; + + return YES; +} + +/* The style with a stepper keeps room for it at the trailing edge, and the + text is drawn in what is left. +*/ +- (BOOL) _hasStepper +{ + return (_datePickerStyle == NSTextFieldAndStepperDatePickerStyle); +} + +- (CGFloat) _stepperWidth +{ + NSImage *image = [NSImage imageNamed: @"common_StepperUp"]; + + return (image == nil) ? 0.0 : [image size].width; +} + +- (NSRect) _stepperFrameForFrame: (NSRect)frame +{ + NSRect stepper = frame; + + stepper.size.width = [self _stepperWidth]; + stepper.origin.x = NSMaxX(frame) - NSWidth(stepper); + + return stepper; +} + +- (NSRect) _textFrameForFrame: (NSRect)frame +{ + if ([self _hasStepper]) + { + frame.size.width -= [self _stepperWidth]; + } + + return frame; +} + +- (NSSize) cellSize +{ + NSSize size; + + if ([self _showsCalendar] || [self _showsClock]) + { + size = NSMakeSize(0.0, [self _calendarSize].height); + if ([self _showsCalendar]) + { + size.width += [self _calendarSize].width; + } + if ([self _showsClock]) + { + size.width += size.height; + } + + return size; + } + + size = [super cellSize]; + if ([self _hasStepper]) + { + size.width += [self _stepperWidth]; + } + + return size; +} + +- (void) drawInteriorWithFrame: (NSRect)cellFrame inView: (NSView *)controlView +{ + if ([self _showsCalendar] || [self _showsClock]) + { + if ([self _showsCalendar]) + { + [self _drawCalendarInFrame: cellFrame ofView: controlView]; + } + if ([self _showsClock]) + { + [self _drawClockInFrame: cellFrame]; + } + + return; + } + + if ([self _hasStepper] && NSWidth(cellFrame) > [self _stepperWidth]) + { + [[GSTheme theme] drawStepperCell: self + withFrame: [self _stepperFrameForFrame: cellFrame] + inView: controlView + highlightUp: NO + highlightDown: NO]; + } + + [super drawInteriorWithFrame: [self _textFrameForFrame: cellFrame] + inView: controlView]; +} + +/* Where the text starts inside the frame it is drawn in. */ +- (CGFloat) _textOriginForFrame: (NSRect)frame width: (CGFloat)width +{ + NSRect title = [self titleRectForBounds: [self _textFrameForFrame: frame]]; + + switch ([self alignment]) + { + case NSRightTextAlignment: + return NSMaxX(title) - width; + case NSCenterTextAlignment: + return NSMidX(title) - width / 2.0; + default: + return NSMinX(title); + } +} + +/* Picks the part of the date the point falls in. Returns NO when the point + is not over the text at all. +*/ +- (BOOL) _selectFieldAtPoint: (NSPoint)point inRect: (NSRect)frame +{ + NSAttributedString *text = [self attributedStringValue]; + NSString *string = [text string]; + NSArray *fields = [self _editableFields]; + NSUInteger count = [fields count]; + NSUInteger index; + CGFloat origin; + + if ([string length] == 0 || count == 0) + { + return NO; + } + if ([self _hasStepper] && point.x >= NSMaxX([self _textFrameForFrame: frame])) + { + return NO; + } + + origin = [self _textOriginForFrame: frame width: [text size].width]; + for (index = 0; index < count; index++) + { + NSRange range = [self _rangeOfFieldAtIndex: index inString: string]; + CGFloat end; + + if (range.location == NSNotFound) + { + continue; + } + end = origin + [[text attributedSubstringFromRange: + NSMakeRange(0, NSMaxRange(range))] size].width; + if (point.x < end || index + 1 == count) + { + [self _setSelectedFieldIndex: index]; + return YES; + } + } + + return NO; +} + +/* Which half of the stepper the point is in: one for the upper button, + minus one for the lower one, zero for anywhere else. +*/ +- (NSInteger) _stepperDirectionAtPoint: (NSPoint)point + inRect: (NSRect)frame + ofView: (NSView *)view +{ + NSRect stepper; + BOOL flipped = [view isFlipped]; + + if (![self _hasStepper] || NSWidth(frame) <= [self _stepperWidth]) + { + return 0; + } + + stepper = [self _stepperFrameForFrame: frame]; + if (!NSMouseInRect(point, stepper, flipped)) + { + return 0; + } + if (flipped) + { + return (point.y < NSMidY(stepper)) ? 1 : -1; + } + + return (point.y > NSMidY(stepper)) ? 1 : -1; +} + +/* Returns YES when the key belongs to the picker. The caller looks at the + date to see whether it changed. +*/ +- (BOOL) _handleKeyEvent: (NSEvent *)event +{ + NSString *characters = [event charactersIgnoringModifiers]; + NSArray *fields; + unichar c; + + if ([characters length] == 0) + { + return NO; + } + + fields = [self _editableFields]; + if ([fields count] == 0 || [self dateValue] == nil || ![self isEnabled]) + { + return NO; + } + + c = [characters characterAtIndex: 0]; + if ([self _showsCalendar]) + { + switch (c) + { + case NSUpArrowFunctionKey: return [self _stepDaysBy: -7]; + case NSDownArrowFunctionKey: return [self _stepDaysBy: 7]; + case NSLeftArrowFunctionKey: return [self _stepDaysBy: -1]; + case NSRightArrowFunctionKey: return [self _stepDaysBy: 1]; + default: break; + } + } + else + { + switch (c) + { + case NSUpArrowFunctionKey: + return [self _stepSelectedFieldBy: 1]; + case NSDownArrowFunctionKey: + return [self _stepSelectedFieldBy: -1]; + case NSLeftArrowFunctionKey: + [self _setSelectedFieldIndex: [self _selectedFieldIndex] - 1]; + return YES; + case NSRightArrowFunctionKey: + [self _setSelectedFieldIndex: [self _selectedFieldIndex] + 1]; + return YES; + default: + break; + } + } + + if (c >= '0' && c <= '9') + { + return [self _typeDigit: c - '0']; + } + if ((c == 'a' || c == 'A' || c == 'p' || c == 'P') + && [self _hasMeridiemField]) + { + return [self _setMeridiemAfterNoon: (c == 'p' || c == 'P')]; + } + + return NO; } /* NSCell keeps the text its formatter made when the value was set, which is @@ -274,6 +1363,46 @@ - (NSString *) stringValue return [super stringValue]; } +/* The field the user is on is shown the way selected text is, and only while + the picker holds the keyboard. +*/ +- (BOOL) _showsSelectedField +{ + NSView *view = [self controlView]; + + return (view != nil && [[view window] firstResponder] == view + && [[view window] isKeyWindow] && [self isEnabled]); +} + +- (NSAttributedString *) attributedStringValue +{ + NSAttributedString *text = [super attributedStringValue]; + NSMutableAttributedString *marked; + NSRange range; + + if (![self _showsSelectedField]) + { + return text; + } + + range = [self _rangeOfFieldAtIndex: [self _selectedFieldIndex] + inString: [text string]]; + if (range.location == NSNotFound) + { + return text; + } + + marked = AUTORELEASE([text mutableCopy]); + [marked addAttribute: NSBackgroundColorAttributeName + value: [NSColor selectedTextBackgroundColor] + range: range]; + [marked addAttribute: NSForegroundColorAttributeName + value: [NSColor selectedTextColor] + range: range]; + + return marked; +} + - (id) delegate { return _delegate; @@ -357,14 +1486,38 @@ - (void) setTimeZone: (NSTimeZone *)zone [[self formatter] setTimeZone: zone]; } +/* NSCell copies its own object ivars as bare pointers and then retains them, + leaving the ones added here held by two cells but retained by one. +*/ +- (id) copyWithZone: (NSZone *)zone +{ + NSDatePickerCell *copy = [super copyWithZone: zone]; + + copy->_backgroundColor = TEST_RETAIN(_backgroundColor); + copy->_textColor = TEST_RETAIN(_textColor); + copy->_minDate = TEST_RETAIN(_minDate); + copy->_maxDate = TEST_RETAIN(_maxDate); + + return copy; +} + - (void) encodeWithCoder: (NSCoder *)aCoder { + [super encodeWithCoder: aCoder]; if ([aCoder allowsKeyedCoding]) { [aCoder encodeDouble: [self timeInterval] forKey: @"NSTimeInterval"]; [aCoder encodeInt: [self datePickerElements] forKey: @"NSDatePickerElements"]; [aCoder encodeInt: [self datePickerStyle] forKey: @"NSDatePickerType"]; + [aCoder encodeInt: [self datePickerMode] forKey: @"NSDatePickerMode"]; [aCoder encodeObject: [self backgroundColor] forKey: @"NSBackgroundColor"]; + [aCoder encodeObject: [self textColor] forKey: @"NSTextColor"]; + [aCoder encodeBool: [self drawsBackground] forKey: @"NSDrawsBackground"]; + [aCoder encodeObject: [self minDate] forKey: @"NSMinDate"]; + [aCoder encodeObject: [self maxDate] forKey: @"NSMaxDate"]; + /* NSCell writes the text of its value, not the value, and the text of + a date does not read back as one. */ + [aCoder encodeObject: [self dateValue] forKey: @"NSDateValue"]; } else { @@ -387,7 +1540,28 @@ - (id) initWithCoder: (NSCoder *)aDecoder [self setTimeInterval: [aDecoder decodeDoubleForKey: @"NSTimeInterval"]]; [self setDatePickerElements: [aDecoder decodeIntForKey: @"NSDatePickerElements"]]; [self setDatePickerStyle: [aDecoder decodeIntForKey: @"NSDatePickerType"]]; + if ([aDecoder containsValueForKey: @"NSDatePickerMode"]) + { + [self setDatePickerMode: + [aDecoder decodeIntForKey: @"NSDatePickerMode"]]; + } [self setBackgroundColor: [aDecoder decodeObjectForKey: @"NSBackgroundColor"]]; + if ([aDecoder containsValueForKey: @"NSTextColor"]) + { + [self setTextColor: [aDecoder decodeObjectForKey: @"NSTextColor"]]; + } + if ([aDecoder containsValueForKey: @"NSDrawsBackground"]) + { + [self setDrawsBackground: + [aDecoder decodeBoolForKey: @"NSDrawsBackground"]]; + } + [self setMinDate: [aDecoder decodeObjectForKey: @"NSMinDate"]]; + [self setMaxDate: [aDecoder decodeObjectForKey: @"NSMaxDate"]]; + if ([aDecoder containsValueForKey: @"NSDateValue"]) + { + [self setDateValue: + [aDecoder decodeObjectForKey: @"NSDateValue"]]; + } } else { diff --git a/Tests/gui/NSDatePicker/calendar.m b/Tests/gui/NSDatePicker/calendar.m new file mode 100644 index 0000000000..c01fdbabfe --- /dev/null +++ b/Tests/gui/NSDatePicker/calendar.m @@ -0,0 +1,186 @@ +/* The clock and calendar style shows a month of days and a clock face, and + the days can be picked with the mouse or walked with the arrow keys, a day + across and a week up or down, which is how AppKit walks them. The grid is + seven columns wide and eight rows tall, a row for the name of the month, a + row for the initials of the weekdays and six weeks, so a test can work out + where a day sits from the size of the cell. The set needs a display. +*/ +#include "Testing.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +static NSDateFormatter *fmt = nil; +static NSWindow *window = nil; + +static NSDate * +clickCell(NSDatePicker *dp, int row, int column) +{ + NSSize size = [dp frame].size; + CGFloat columnWidth = size.width / 7.0; + CGFloat rowHeight = size.height / 8.0; + NSPoint where; + + where.x = [dp frame].origin.x + (column + 0.5) * columnWidth; + where.y = [dp frame].origin.y + size.height - (row + 2.5) * rowHeight; + [dp mouseDown: [NSEvent mouseEventWithType: NSLeftMouseDown + location: where + modifierFlags: 0 + timestamp: 0 + windowNumber: [window windowNumber] + context: nil + eventNumber: 1 + clickCount: 1 + pressure: 1.0]]; + return [dp dateValue]; +} + +static void +key(NSDatePicker *dp, unichar c) +{ + NSString *characters = [NSString stringWithCharacters: &c length: 1]; + + [dp keyDown: [NSEvent keyEventWithType: NSKeyDown + location: NSZeroPoint + modifierFlags: 0 + timestamp: 0 + windowNumber: 0 + context: nil + characters: characters + charactersIgnoringModifiers: characters + isARepeat: NO + keyCode: 0]]; +} + +static NSString * +value(NSDatePicker *dp) +{ + return [fmt stringFromDate: [dp dateValue]]; +} + +int +main(int argc, char **argv) +{ + NSDatePicker *dp; + NSSize text; + NSSize grid; + NSDate *first; + NSDate *second; + + START_SET("NSDatePicker calendar") + + NS_DURING + { + [NSApplication sharedApplication]; + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException]) + SKIP("It looks like GNUstep backend is not yet installed") + } + NS_ENDHANDLER + + NS_DURING + { + fmt = [[NSDateFormatter alloc] init]; + [fmt setDateFormat: @"yyyy-MM-dd HH:mm:ss"]; + [fmt setTimeZone: [NSTimeZone timeZoneWithName: @"GMT"]]; + [fmt setLocale: [NSLocale localeWithLocaleIdentifier: @"en_US_POSIX"]]; + + dp = AUTORELEASE([[NSDatePicker alloc] + initWithFrame: NSMakeRect(0, 0, 200, 26)]); + [dp setLocale: [NSLocale localeWithLocaleIdentifier: @"en_US"]]; + [dp setTimeZone: [NSTimeZone timeZoneWithName: @"GMT"]]; + [dp setDatePickerElements: NSYearMonthDayDatePickerElementFlag]; + [dp setDateValue: [fmt dateFromString: @"2023-03-08 20:26:40"]]; + + text = [[dp cell] cellSize]; + [dp setDatePickerStyle: NSClockAndCalendarDatePickerStyle]; + grid = [[dp cell] cellSize]; + PASS(grid.height > text.height * 4.0 && grid.width > text.width, + "the calendar style asks for room for a month of days"); + + /* A frame the size of the cell puts the grid where the test looks + for it. */ + [dp setFrameSize: grid]; + window = AUTORELEASE([[NSWindow alloc] + initWithContentRect: NSMakeRect(0, 0, grid.width + 20, + grid.height + 20) + styleMask: NSWindowStyleMaskTitled + backing: NSBackingStoreBuffered + defer: NO]); + [[window contentView] addSubview: dp]; + [window makeKeyAndOrderFront: nil]; + + /* Drawing a month must work before anything is asked of the grid. */ + [dp lockFocus]; + [dp drawRect: [dp bounds]]; + [dp unlockFocus]; + + /* The third row of the grid is inside the month whichever weekday + the month starts on. */ + first = clickCell(dp, 2, 2); + PASS(first != nil && [[value(dp) substringFromIndex: 11] + isEqualToString: @"20:26:40"], + "picking a day keeps the time of day"); + + second = clickCell(dp, 2, 3); + PASS([second timeIntervalSinceDate: first] == 86400.0, + "the day to the right of one is the next day"); + + first = second; + second = clickCell(dp, 3, 3); + PASS([second timeIntervalSinceDate: first] == 7.0 * 86400.0, + "the day below one is a week later"); + + /* The arrow keys walk the same grid. */ + [dp setDateValue: [fmt dateFromString: @"2023-03-08 20:26:40"]]; + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-01 20:26:40", + "the up arrow moves back a week"); + key(dp, NSDownArrowFunctionKey); + key(dp, NSRightArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-09 20:26:40", + "the right arrow moves on a day"); + key(dp, NSLeftArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-08 20:26:40", + "the left arrow moves back a day"); + + /* With the time elements as well there is a clock beside the month. */ + [dp setDatePickerElements: NSYearMonthDayDatePickerElementFlag + | NSHourMinuteSecondDatePickerElementFlag]; + PASS([[dp cell] cellSize].width > grid.width, + "the clock takes room of its own beside the month"); + [dp setFrameSize: [[dp cell] cellSize]]; + [dp lockFocus]; + [dp drawRect: [dp bounds]]; + [dp unlockFocus]; + PASS_EQUAL(value(dp), @"2023-03-08 20:26:40", + "drawing the clock leaves the date alone"); + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException] + || [[localException name] isEqualToString: @"NSWindowServerCommunicationException"]) + SKIP("No display available") + else + [localException raise]; + } + NS_ENDHANDLER + + END_SET("NSDatePicker calendar") + + return 0; +} diff --git a/Tests/gui/NSDatePicker/display.m b/Tests/gui/NSDatePicker/display.m index bd8217fade..0b5ced583c 100644 --- a/Tests/gui/NSDatePicker/display.m +++ b/Tests/gui/NSDatePicker/display.m @@ -30,7 +30,6 @@ int main(int argc, char **argv) { - CREATE_AUTORELEASE_POOL(arp); NSDatePicker *dp; NSString *text; NSString *american; @@ -120,6 +119,5 @@ END_SET("NSDatePicker display") - DESTROY(arp); return 0; } diff --git a/Tests/gui/NSDatePicker/keyboard.m b/Tests/gui/NSDatePicker/keyboard.m new file mode 100644 index 0000000000..d89500b711 --- /dev/null +++ b/Tests/gui/NSDatePicker/keyboard.m @@ -0,0 +1,288 @@ +/* Editing a date picker from the keyboard. The arrow keys move between the + parts of the date and step the part they are on, and a digit types into + it. The parts are those the locale writes, in the order it writes them, + which for en_US is month, day, year, hour, minute, second and the + morning/afternoon marker. Every value here was checked against AppKit on + a macOS runner. The picker uses the theme and font backend, so the set is + skipped when the backend is unavailable. +*/ +#include "Testing.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +@interface Counter : NSObject +{ +@public + int actions; + NSDate *substitute; +} +@end + +@implementation Counter +- (void) count: (id)sender +{ + actions++; +} +- (void) datePickerCell: (NSDatePickerCell *)cell +validateProposedDateValue: (NSDate **)proposed + timeInterval: (NSTimeInterval *)interval +{ + if (substitute != nil) + { + *proposed = substitute; + } +} +@end + +static NSDateFormatter *fmt = nil; + +static NSDatePicker * +picker(NSDatePickerElementFlags elements, NSString *when) +{ + NSDatePicker *dp = AUTORELEASE([[NSDatePicker alloc] + initWithFrame: NSMakeRect(0, 0, 260, 26)]); + + [dp setLocale: [NSLocale localeWithLocaleIdentifier: @"en_US"]]; + [dp setTimeZone: [NSTimeZone timeZoneWithName: @"GMT"]]; + [dp setDatePickerElements: elements]; + [dp setDateValue: [fmt dateFromString: when]]; + return dp; +} + +static void +type(NSDatePicker *dp, NSString *characters) +{ + [dp keyDown: [NSEvent keyEventWithType: NSKeyDown + location: NSZeroPoint + modifierFlags: 0 + timestamp: 0 + windowNumber: 0 + context: nil + characters: characters + charactersIgnoringModifiers: characters + isARepeat: NO + keyCode: 0]]; +} + +static void +key(NSDatePicker *dp, unichar c) +{ + type(dp, [NSString stringWithCharacters: &c length: 1]); +} + +static void +keys(NSDatePicker *dp, unichar c, int times) +{ + int i; + + for (i = 0; i < times; i++) + { + key(dp, c); + } +} + +static NSString * +value(NSDatePicker *dp) +{ + return [fmt stringFromDate: [dp dateValue]]; +} + +int +main(int argc, char **argv) +{ + NSDatePicker *dp; + NSDatePickerElementFlags all; + BOOL generator; + + START_SET("NSDatePicker keyboard") + + NS_DURING + { + [NSApplication sharedApplication]; + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException]) + SKIP("It looks like GNUstep backend is not yet installed") + } + NS_ENDHANDLER + + NS_DURING + { + fmt = [[NSDateFormatter alloc] init]; + [fmt setDateFormat: @"yyyy-MM-dd HH:mm:ss"]; + [fmt setTimeZone: [NSTimeZone timeZoneWithName: @"GMT"]]; + [fmt setLocale: [NSLocale localeWithLocaleIdentifier: @"en_US_POSIX"]]; + + generator = ([NSDateFormatter dateFormatFromTemplate: @"yMd" + options: 0 + locale: [NSLocale localeWithLocaleIdentifier: @"en_US"]] + != nil); + all = NSYearMonthDayDatePickerElementFlag + | NSHourMinuteSecondDatePickerElementFlag; + + /* The up arrow steps the part the picker is on, starting at the first + part the locale writes. */ + dp = picker(all, @"2023-03-08 20:26:40"); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-04-08 20:26:40", + "the up arrow steps the first part of the date"); + key(dp, NSDownArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-08 20:26:40", + "the down arrow steps it back"); + + if (generator) + { + dp = picker(all, @"2023-03-08 20:26:40"); + keys(dp, NSRightArrowFunctionKey, 1); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-09 20:26:40", + "the second part of an American date is the day"); + + dp = picker(all, @"2023-03-08 20:26:40"); + keys(dp, NSRightArrowFunctionKey, 2); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2024-03-08 20:26:40", + "the third part is the year"); + + dp = picker(all, @"2023-03-08 20:26:40"); + keys(dp, NSRightArrowFunctionKey, 3); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-08 21:26:40", + "the fourth part is the hour"); + + dp = picker(all, @"2023-03-08 20:26:40"); + keys(dp, NSRightArrowFunctionKey, 4); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-08 20:27:40", + "the fifth part is the minute"); + + dp = picker(all, @"2023-03-08 20:26:40"); + keys(dp, NSRightArrowFunctionKey, 5); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-08 20:26:41", + "the sixth part is the second"); + + dp = picker(all, @"2023-03-08 20:26:40"); + keys(dp, NSRightArrowFunctionKey, 6); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-08 08:26:40", + "the seventh part of an American time is the marker"); + + dp = picker(all, @"2023-03-08 20:26:40"); + keys(dp, NSRightArrowFunctionKey, 7); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-04-08 20:26:40", + "moving past the last part comes back to the first"); + + dp = picker(all, @"2023-03-08 20:26:40"); + key(dp, NSLeftArrowFunctionKey); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-08 08:26:40", + "moving left from the first part goes to the last"); + + dp = picker(NSYearMonthDayDatePickerElementFlag, + @"2023-03-08 20:26:40"); + [dp setLocale: [NSLocale localeWithLocaleIdentifier: @"de_DE"]]; + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-09 20:26:40", + "the first part of a German date is the day"); + } + + /* A step is a step of the calendar, so it carries. */ + dp = picker(all, @"2023-12-08 20:26:40"); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2024-01-08 20:26:40", + "a step past December carries into the year"); + + dp = picker(all, @"2023-01-08 20:26:40"); + key(dp, NSDownArrowFunctionKey); + PASS_EQUAL(value(dp), @"2022-12-08 20:26:40", + "a step before January carries into the year"); + + dp = picker(all, @"2023-03-31 20:26:40"); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-04-30 20:26:40", + "a day the next month is too short for moves to its last day"); + + /* Typing digits. */ + dp = picker(all, @"2023-03-08 20:26:40"); + type(dp, @"5"); + PASS_EQUAL(value(dp), @"2023-05-08 20:26:40", + "a digit no second digit can follow is taken on its own"); + + dp = picker(all, @"2023-03-08 20:26:40"); + type(dp, @"1"); + PASS_EQUAL(value(dp), @"2023-03-08 20:26:40", + "a digit a second digit could follow waits for it"); + type(dp, @"2"); + PASS_EQUAL(value(dp), @"2023-12-08 20:26:40", + "the second digit completes the month"); + + dp = picker(all, @"2023-03-08 20:26:40"); + type(dp, @"0"); + PASS_EQUAL(value(dp), @"2023-03-08 20:26:40", + "a leading zero leaves the date alone"); + + /* The date limits hold for an edit from the keyboard. */ + dp = picker(all, @"2023-03-08 20:26:40"); + [dp setMaxDate: [fmt dateFromString: @"2023-03-20 00:00:00"]]; + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-20 00:00:00", + "a step beyond the maximum date stops at it"); + + dp = picker(all, @"2023-03-08 20:26:40"); + [dp setMinDate: [fmt dateFromString: @"2023-03-01 00:00:00"]]; + key(dp, NSDownArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-03-01 00:00:00", + "a step before the minimum date stops at it"); + + /* The action goes out for an edit, and not for a date set in code. */ + { + Counter *counter = AUTORELEASE([[Counter alloc] init]); + + dp = picker(all, @"2023-03-08 20:26:40"); + [dp setTarget: counter]; + [dp setAction: @selector(count:)]; + [dp setDateValue: [fmt dateFromString: @"2023-05-08 20:26:40"]]; + PASS(counter->actions == 0, + "setting the date in code sends no action"); + key(dp, NSUpArrowFunctionKey); + PASS(counter->actions == 1, "an edit sends the action once"); + type(dp, @"q"); + PASS(counter->actions == 1, + "a key that means nothing to the picker sends no action"); + + /* The delegate has the last word on the value. */ + [dp setDelegate: counter]; + counter->substitute = [fmt dateFromString: @"1999-09-09 09:09:09"]; + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"1999-09-09 09:09:09", + "the delegate can put another date in place of the edit"); + } + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException] + || [[localException name] isEqualToString: @"NSWindowServerCommunicationException"]) + SKIP("No display available") + else + [localException raise]; + } + NS_ENDHANDLER + + END_SET("NSDatePicker keyboard") + + return 0; +} diff --git a/Tests/gui/NSDatePicker/mouse.m b/Tests/gui/NSDatePicker/mouse.m new file mode 100644 index 0000000000..296174f6ee --- /dev/null +++ b/Tests/gui/NSDatePicker/mouse.m @@ -0,0 +1,179 @@ +/* Clicking a date picker takes the keyboard, picks the part of the date + under the pointer, and steps that part when the click is on the stepper of + the text field and stepper style. The picker has to be in a key window, + so the set needs a display. +*/ +#include "Testing.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +@interface Counter : NSObject +{ +@public + int actions; +} +@end + +@implementation Counter +- (void) count: (id)sender +{ + actions++; +} +@end + +static NSDateFormatter *fmt = nil; +static NSWindow *window = nil; + +static void +click(NSDatePicker *dp, NSPoint inside) +{ + NSPoint where = NSMakePoint([dp frame].origin.x + inside.x, + [dp frame].origin.y + inside.y); + + [dp mouseDown: [NSEvent mouseEventWithType: NSLeftMouseDown + location: where + modifierFlags: 0 + timestamp: 0 + windowNumber: [window windowNumber] + context: nil + eventNumber: 1 + clickCount: 1 + pressure: 1.0]]; +} + +static void +key(NSDatePicker *dp, unichar c) +{ + NSString *characters = [NSString stringWithCharacters: &c length: 1]; + + [dp keyDown: [NSEvent keyEventWithType: NSKeyDown + location: NSZeroPoint + modifierFlags: 0 + timestamp: 0 + windowNumber: 0 + context: nil + characters: characters + charactersIgnoringModifiers: characters + isARepeat: NO + keyCode: 0]]; +} + +static NSString * +value(NSDatePicker *dp) +{ + return [fmt stringFromDate: [dp dateValue]]; +} + +int +main(int argc, char **argv) +{ + NSDatePicker *dp; + Counter *counter; + CGFloat stepperWidth; + CGFloat height = 26.0; + CGFloat width = 200.0; + + START_SET("NSDatePicker mouse") + + NS_DURING + { + [NSApplication sharedApplication]; + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException]) + SKIP("It looks like GNUstep backend is not yet installed") + } + NS_ENDHANDLER + + NS_DURING + { + fmt = [[NSDateFormatter alloc] init]; + [fmt setDateFormat: @"yyyy-MM-dd HH:mm:ss"]; + [fmt setTimeZone: [NSTimeZone timeZoneWithName: @"GMT"]]; + [fmt setLocale: [NSLocale localeWithLocaleIdentifier: @"en_US_POSIX"]]; + + stepperWidth = [[NSImage imageNamed: @"common_StepperUp"] size].width; + PASS(stepperWidth > 0.0, "the theme has a stepper image"); + + window = AUTORELEASE([[NSWindow alloc] + initWithContentRect: NSMakeRect(0, 0, 300, 60) + styleMask: NSWindowStyleMaskTitled + backing: NSBackingStoreBuffered + defer: NO]); + counter = AUTORELEASE([[Counter alloc] init]); + dp = AUTORELEASE([[NSDatePicker alloc] + initWithFrame: NSMakeRect(10, 10, width, height)]); + [dp setLocale: [NSLocale localeWithLocaleIdentifier: @"en_US"]]; + [dp setTimeZone: [NSTimeZone timeZoneWithName: @"GMT"]]; + [dp setDatePickerElements: NSYearMonthDayDatePickerElementFlag]; + [dp setDateValue: [fmt dateFromString: @"2023-03-08 20:26:40"]]; + [dp setTarget: counter]; + [dp setAction: @selector(count:)]; + [[window contentView] addSubview: dp]; + [window makeKeyAndOrderFront: nil]; + + /* A click in the text takes the keyboard and changes nothing. */ + click(dp, NSMakePoint(4, height / 2)); + if ([window firstResponder] != dp) + { + SKIP("The window did not take the keyboard") + } + PASS_EQUAL(value(dp), @"2023-03-08 20:26:40", + "a click in the text leaves the date alone"); + PASS(counter->actions == 0, "a click in the text sends no action"); + + /* The part under the pointer is the one the arrow keys act on. */ + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2023-04-08 20:26:40", + "a click at the left edge picks the first part of the date"); + + click(dp, NSMakePoint(width - stepperWidth - 2, height / 2)); + key(dp, NSUpArrowFunctionKey); + PASS_EQUAL(value(dp), @"2024-04-08 20:26:40", + "a click at the right of the text picks the last part"); + + /* The stepper steps the part that is picked. */ + counter->actions = 0; + click(dp, NSMakePoint(width - stepperWidth / 2, height - 4)); + PASS_EQUAL(value(dp), @"2025-04-08 20:26:40", + "the upper half of the stepper steps the part up"); + PASS(counter->actions == 1, "a step from the stepper sends the action"); + + click(dp, NSMakePoint(width - stepperWidth / 2, 4)); + PASS_EQUAL(value(dp), @"2024-04-08 20:26:40", + "the lower half of the stepper steps the part down"); + + /* The style without a stepper has none to click. */ + [dp setDatePickerStyle: NSTextFieldDatePickerStyle]; + click(dp, NSMakePoint(width - stepperWidth / 2, height - 4)); + PASS_EQUAL(value(dp), @"2024-04-08 20:26:40", + "the text field style has no stepper to step with"); + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException] + || [[localException name] isEqualToString: @"NSWindowServerCommunicationException"]) + SKIP("No display available") + else + [localException raise]; + } + NS_ENDHANDLER + + END_SET("NSDatePicker mouse") + + return 0; +} diff --git a/Tests/gui/NSDatePicker/selection.m b/Tests/gui/NSDatePicker/selection.m new file mode 100644 index 0000000000..0dd56f2ba4 --- /dev/null +++ b/Tests/gui/NSDatePicker/selection.m @@ -0,0 +1,154 @@ +/* While a date picker holds the keyboard it shows the part of the date the + arrow keys act on the way selected text is shown, and the marking follows + the arrow keys. The picker has to be in a key window for that, so the set + needs a display. +*/ +#include "Testing.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +static void +key(NSDatePicker *dp, unichar c) +{ + NSString *characters = [NSString stringWithCharacters: &c length: 1]; + + [dp keyDown: [NSEvent keyEventWithType: NSKeyDown + location: NSZeroPoint + modifierFlags: 0 + timestamp: 0 + windowNumber: 0 + context: nil + characters: characters + charactersIgnoringModifiers: characters + isARepeat: NO + keyCode: 0]]; +} + +/* The range the cell marks as selected, or a range of length zero. */ +static NSRange +marked(NSDatePicker *dp) +{ + NSAttributedString *text = [[dp cell] attributedStringValue]; + NSRange range = NSMakeRange(0, 0); + + if ([text length] > 0) + { + NSUInteger index; + + for (index = 0; index < [text length]; index++) + { + if ([text attribute: NSBackgroundColorAttributeName + atIndex: index + effectiveRange: NULL] != nil) + { + NSUInteger end = index; + + while (end < [text length] + && [text attribute: NSBackgroundColorAttributeName + atIndex: end + effectiveRange: NULL] != nil) + { + end++; + } + range = NSMakeRange(index, end - index); + break; + } + } + } + + return range; +} + +int +main(int argc, char **argv) +{ + NSWindow *window; + NSDatePicker *dp; + NSString *text; + NSRange range; + + START_SET("NSDatePicker selection") + + NS_DURING + { + [NSApplication sharedApplication]; + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException]) + SKIP("It looks like GNUstep backend is not yet installed") + } + NS_ENDHANDLER + + NS_DURING + { + window = AUTORELEASE([[NSWindow alloc] + initWithContentRect: NSMakeRect(0, 0, 300, 60) + styleMask: NSWindowStyleMaskTitled + backing: NSBackingStoreBuffered + defer: NO]); + dp = AUTORELEASE([[NSDatePicker alloc] + initWithFrame: NSMakeRect(10, 10, 260, 26)]); + [dp setLocale: [NSLocale localeWithLocaleIdentifier: @"en_US"]]; + [dp setTimeZone: [NSTimeZone timeZoneWithName: @"GMT"]]; + [dp setDatePickerElements: NSYearMonthDayDatePickerElementFlag]; + [dp setDateValue: [NSDate dateWithTimeIntervalSinceReferenceDate: + 700000000.0]]; + [[window contentView] addSubview: dp]; + + PASS(marked(dp).length == 0, + "a picker outside a key window marks nothing"); + + [window makeKeyAndOrderFront: nil]; + [window makeFirstResponder: dp]; + if (![window isKeyWindow] || [window firstResponder] != dp) + { + SKIP("The window did not take the keyboard") + } + + text = [[dp cell] stringValue]; + range = marked(dp); + PASS(range.length > 0 && NSMaxRange(range) <= [text length], + "the picker marks the part the arrow keys act on"); + PASS_EQUAL([text substringWithRange: range], @"3", + "the marked part of an American date is the month"); + + key(dp, NSRightArrowFunctionKey); + range = marked(dp); + PASS_EQUAL([text substringWithRange: range], @"8", + "the right arrow marks the day"); + + key(dp, NSRightArrowFunctionKey); + range = marked(dp); + PASS_EQUAL([text substringWithRange: range], @"2023", + "another right arrow marks the year"); + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException] + || [[localException name] isEqualToString: @"NSWindowServerCommunicationException"]) + SKIP("No display available") + else + [localException raise]; + } + NS_ENDHANDLER + + END_SET("NSDatePicker selection") + + return 0; +} diff --git a/Tests/gui/NSDatePickerCell/coding.m b/Tests/gui/NSDatePickerCell/coding.m new file mode 100644 index 0000000000..b8a48068c0 --- /dev/null +++ b/Tests/gui/NSDatePickerCell/coding.m @@ -0,0 +1,98 @@ +/* Everything a date picker cell is set to has to survive an archive: the + settings of the class itself, the dates that bound it, its colours, and + the state NSCell holds, which needs -encodeWithCoder: to reach super. +*/ +#include "Testing.h" + +#include +#include +#include +#include +#include + +#include +#include +#include + +int +main(int argc, char **argv) +{ + NSDatePickerCell *cell; + NSDatePickerCell *decoded; + NSData *data; + NSDate *value; + NSDate *low; + NSDate *high; + + START_SET("NSDatePickerCell coding") + + NS_DURING + { + [NSApplication sharedApplication]; + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException]) + SKIP("It looks like GNUstep backend is not yet installed") + } + NS_ENDHANDLER + + NS_DURING + { + value = [NSDate dateWithTimeIntervalSinceReferenceDate: 700000000.0]; + low = [NSDate dateWithTimeIntervalSinceReferenceDate: 600000000.0]; + high = [NSDate dateWithTimeIntervalSinceReferenceDate: 800000000.0]; + + cell = AUTORELEASE([[NSDatePickerCell alloc] initTextCell: @""]); + [cell setDateValue: value]; + [cell setMinDate: low]; + [cell setMaxDate: high]; + [cell setTextColor: [NSColor blueColor]]; + [cell setBackgroundColor: [NSColor redColor]]; + [cell setDrawsBackground: YES]; + [cell setDatePickerMode: NSRangeDateMode]; + [cell setDatePickerStyle: NSClockAndCalendarDatePickerStyle]; + [cell setDatePickerElements: NSYearMonthDayDatePickerElementFlag]; + [cell setTimeInterval: 3600.0]; + + data = [NSKeyedArchiver archivedDataWithRootObject: cell]; + decoded = [NSKeyedUnarchiver unarchiveObjectWithData: data]; + + PASS([decoded isKindOfClass: [NSDatePickerCell class]], + "a date picker cell comes back from an archive"); + PASS([decoded datePickerStyle] == NSClockAndCalendarDatePickerStyle, + "the style survives the archive"); + PASS([decoded datePickerMode] == NSRangeDateMode, + "the mode survives the archive"); + PASS([decoded datePickerElements] == NSYearMonthDayDatePickerElementFlag, + "the elements survive the archive"); + PASS([decoded timeInterval] == 3600.0, + "the time interval survives the archive"); + PASS([decoded drawsBackground] == YES, + "drawing the background survives the archive"); + PASS_EQUAL([decoded dateValue], value, "the date survives the archive"); + PASS_EQUAL([decoded minDate], low, + "the minimum date survives the archive"); + PASS_EQUAL([decoded maxDate], high, + "the maximum date survives the archive"); + PASS_EQUAL([decoded textColor], [NSColor blueColor], + "the text colour survives the archive"); + PASS_EQUAL([decoded backgroundColor], [NSColor redColor], + "the background colour survives the archive"); + PASS([decoded isBezeled] == [cell isBezeled], + "the state NSCell holds survives the archive"); + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException] + || [[localException name] isEqualToString: @"NSWindowServerCommunicationException"]) + SKIP("No display available") + else + [localException raise]; + } + NS_ENDHANDLER + + END_SET("NSDatePickerCell coding") + + return 0; +} diff --git a/Tests/gui/NSDatePickerCell/copying.m b/Tests/gui/NSDatePickerCell/copying.m new file mode 100644 index 0000000000..a68c6bb110 --- /dev/null +++ b/Tests/gui/NSDatePickerCell/copying.m @@ -0,0 +1,107 @@ +/* A copy of a date picker cell has to hold the colours and the dates the + original holds, and hold them itself: NSCell copies its object ivars as + bare pointers and retains only the ones it knows about, so the ones this + class adds are given back twice when the two cells go away. +*/ +#include "Testing.h" + +#include +#include + +#include +#include +#include + +int +main(int argc, char **argv) +{ + NSDatePickerCell *cell; + NSDatePickerCell *copy; + NSColor *colour; + NSDate *low; + NSDate *high; + NSUInteger held; + + START_SET("NSDatePickerCell copying") + + NS_DURING + { + [NSApplication sharedApplication]; + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException]) + SKIP("It looks like GNUstep backend is not yet installed") + } + NS_ENDHANDLER + + NS_DURING + { + low = [NSDate dateWithTimeIntervalSinceReferenceDate: 600000000.0]; + high = [NSDate dateWithTimeIntervalSinceReferenceDate: 800000000.0]; + colour = RETAIN([NSColor colorWithCalibratedRed: 0.1 + green: 0.2 + blue: 0.3 + alpha: 1.0]); + + cell = [[NSDatePickerCell alloc] initTextCell: @""]; + [cell setDateValue: [NSDate dateWithTimeIntervalSinceReferenceDate: + 700000000.0]]; + [cell setMinDate: low]; + [cell setMaxDate: high]; + [cell setBackgroundColor: colour]; + [cell setTextColor: [NSColor blueColor]]; + [cell setDrawsBackground: YES]; + [cell setDatePickerMode: NSRangeDateMode]; + [cell setDatePickerStyle: NSClockAndCalendarDatePickerStyle]; + [cell setDatePickerElements: NSYearMonthDayDatePickerElementFlag]; + [cell setTimeInterval: 3600.0]; + + held = [colour retainCount]; + copy = [cell copy]; + + PASS([copy isKindOfClass: [NSDatePickerCell class]] && copy != cell, + "a date picker cell copies to another one"); + PASS([colour retainCount] == held + 1, + "the copy holds the colours it shares with the original"); + + PASS_EQUAL([copy minDate], low, "the copy has the same minimum date"); + PASS_EQUAL([copy maxDate], high, "the copy has the same maximum date"); + PASS_EQUAL([copy dateValue], [cell dateValue], + "the copy has the same date"); + PASS_EQUAL([copy backgroundColor], colour, + "the copy has the same background colour"); + PASS_EQUAL([copy textColor], [cell textColor], + "the copy has the same text colour"); + PASS([copy drawsBackground] == YES + && [copy datePickerMode] == NSRangeDateMode + && [copy datePickerStyle] == NSClockAndCalendarDatePickerStyle + && [copy datePickerElements] == NSYearMonthDayDatePickerElementFlag + && [copy timeInterval] == 3600.0, + "the copy has the same settings"); + + RELEASE(copy); + PASS([colour retainCount] == held, + "letting the copy go gives back only what the copy held"); + PASS_EQUAL([cell backgroundColor], colour, + "the original still has its background colour"); + + RELEASE(cell); + PASS([colour retainCount] == held - 1, + "letting the original go gives back the last hold on the colour"); + RELEASE(colour); + } + NS_HANDLER + { + if ([[localException name] isEqualToString: NSInternalInconsistencyException] + || [[localException name] isEqualToString: @"NSWindowServerCommunicationException"]) + SKIP("No display available") + else + [localException raise]; + } + NS_ENDHANDLER + + END_SET("NSDatePickerCell copying") + + return 0; +}