diff --git a/Headers/AppKit/NSDatePickerCell.h b/Headers/AppKit/NSDatePickerCell.h index d0ea9b075..3cda2f6c2 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 1d3980499..49ecbf574 100644 --- a/Source/NSDatePicker.m +++ b/Source/NSDatePicker.m @@ -29,10 +29,21 @@ 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; +@end static id usedCellClass = nil; @@ -57,6 +68,76 @@ + (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]; + 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 bd640d356..160d5de73 100644 --- a/Source/NSDatePickerCell.m +++ b/Source/NSDatePickerCell.m @@ -29,14 +29,33 @@ Boston, MA 02110-1301, USA. */ +#import +#import +#import +#import +#import #import #import #import +#import +#import +#import +#import "AppKit/NSAttributedString.h" #import "AppKit/NSDatePickerCell.h" #import "AppKit/NSColor.h" +#import "AppKit/NSEvent.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 +194,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 +456,451 @@ - (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 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 = [super cellSize]; + + if ([self _hasStepper]) + { + size.width += [self _stepperWidth]; + } + + return size; +} + +- (void) drawInteriorWithFrame: (NSRect)cellFrame inView: (NSView *)controlView +{ + 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]; + 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 +922,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; diff --git a/Tests/gui/NSDatePicker/display.m b/Tests/gui/NSDatePicker/display.m index bd8217fad..0b5ced583 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 000000000..d89500b71 --- /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 000000000..296174f6e --- /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 000000000..0dd56f2ba --- /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; +}