/* xscreensaver, Copyright (c) 2006-2009 Jamie Zawinski * * Permission to use, copy, modify, distribute, and sell this software and its * documentation for any purpose is hereby granted without fee, provided that * the above copyright notice appear in all copies and that both that * copyright notice and this permission notice appear in supporting * documentation. No representations are made about the suitability of this * software for any purpose. It is provided "as is" without express or * implied warranty. */ /* XScreenSaver uses XML files to describe the user interface for configuring the various screen savers. These files live in .../hacks/config/ and say relatively high level things like: "there should be a checkbox labelled "Leave Trails", and when it is checked, add the option '-trails' to the command line when launching the program." This code reads that XML and constructs a Cocoa interface from it. The Cocoa controls are hooked up to NSUserDefaultsController to save those settings into the MacOS preferences system. The Cocoa preferences names are the same as the resource names specified in the screenhack's 'options' array (we use that array to map the command line switches specified in the XML to the resource names to use). */ #import "XScreenSaverConfigSheet.h" #import "jwxyz.h" #import "InvertedSlider.h" #import @implementation XScreenSaverConfigSheet #define LEFT_MARGIN 20 // left edge of window #define COLUMN_SPACING 10 // gap between e.g. labels and text fields #define LEFT_LABEL_WIDTH 70 // width of all left labels #define LINE_SPACING 10 // leading between each line // redefine these since they don't work when not inside an ObjC method #undef NSAssert #undef NSAssert1 #undef NSAssert2 #undef NSAssert3 #define NSAssert(CC,S) do { if (!(CC)) { NSLog(S); }} while(0) #define NSAssert1(CC,S,A) do { if (!(CC)) { NSLog(S,A); }} while(0) #define NSAssert2(CC,S,A,B) do { if (!(CC)) { NSLog(S,A,B); }} while(0) #define NSAssert3(CC,S,A,B,C) do { if (!(CC)) { NSLog(S,A,B,C); }} while(0) /* Given a command-line option, returns the corresponding resource name. Any arguments in the switch string are ignored (e.g., "-foo x"). */ static NSString * switch_to_resource (NSString *cmdline_switch, const XrmOptionDescRec *opts, NSString **val_ret) { char buf[255]; char *tail = 0; NSAssert(cmdline_switch, @"cmdline switch is null"); if (! [cmdline_switch getCString:buf maxLength:sizeof(buf) encoding:NSUTF8StringEncoding]) { NSAssert1(0, @"unable to convert %@", cmdline_switch); abort(); } char *s = strpbrk(buf, " \t\r\n"); if (s && *s) { *s = 0; tail = s+1; while (*tail && (*tail == ' ' || *tail == '\t')) tail++; } while (opts[0].option) { if (!strcmp (opts[0].option, buf)) { const char *ret = 0; if (opts[0].argKind == XrmoptionNoArg) { if (tail && *tail) NSAssert1 (0, @"expected no args to switch: \"%@\"", cmdline_switch); ret = opts[0].value; } else { if (!tail || !*tail) NSAssert1 (0, @"expected args to switch: \"%@\"", cmdline_switch); ret = tail; } if (val_ret) *val_ret = (ret ? [NSString stringWithCString:ret encoding:NSUTF8StringEncoding] : 0); const char *res = opts[0].specifier; while (*res && (*res == '.' || *res == '*')) res++; return [NSString stringWithCString:res encoding:NSUTF8StringEncoding]; } opts++; } NSAssert1 (0, @"\"%@\" not present in options", cmdline_switch); abort(); } /* Connects a control (checkbox, etc) to the corresponding preferences key. */ static void bind_resource_to_preferences (NSUserDefaultsController *prefs, NSObject *control, NSString *pref_key, const XrmOptionDescRec *opts) { NSString *bindto = ([control isKindOfClass:[NSPopUpButton class]] ? @"selectedObject" : ([control isKindOfClass:[NSMatrix class]] ? @"selectedIndex" : @"value")); [control bind:bindto toObject:prefs withKeyPath:[@"values." stringByAppendingString: pref_key] options:nil]; # if 0 // #### NSObject *def = [[prefs defaults] objectForKey:pref_key]; NSString *s = [NSString stringWithFormat:@"bind: \"%@\"", pref_key]; s = [s stringByPaddingToLength:18 withString:@" " startingAtIndex:0]; s = [NSString stringWithFormat:@"%@ = \"%@\"", s, def]; s = [s stringByPaddingToLength:28 withString:@" " startingAtIndex:0]; NSLog (@"%@ %@/%@", s, [def class], [control class]); # endif } static void bind_switch_to_preferences (NSUserDefaultsController *prefs, NSObject *control, NSString *cmdline_switch, const XrmOptionDescRec *opts) { NSString *pref_key = switch_to_resource (cmdline_switch, opts, 0); bind_resource_to_preferences (prefs, control, pref_key, opts); } /* Parse the attributes of an XML tag into a dictionary. For input, the dictionary should have as attributes the keys, each with @"" as their value. On output, the dictionary will set the keys to the values specified, and keys that were not specified will not be present in the dictionary. Warnings are printed if there are duplicate or unknown attributes. */ static void parse_attrs (NSMutableDictionary *dict, NSXMLNode *node) { NSArray *attrs = [(NSXMLElement *) node attributes]; int n = [attrs count]; int i; // For each key in the dictionary, fill in the dict with the corresponding // value. The value @"" is assumed to mean "un-set". Issue a warning if // an attribute is specified twice. // for (i = 0; i < n; i++) { NSXMLNode *attr = [attrs objectAtIndex:i]; NSString *key = [attr name]; NSString *val = [attr objectValue]; NSString *old = [dict objectForKey:key]; if (! old) { NSAssert2 (0, @"unknown attribute \"%@\" in \"%@\"", key, [node name]); } else if ([old length] != 0) { NSAssert2 (0, @"duplicate %@: \"%@\", \"%@\"", old, val); } else { [dict setValue:val forKey:key]; } } // Remove from the dictionary any keys whose value is still @"", // meaning there was no such attribute specified. // NSArray *keys = [dict allKeys]; n = [keys count]; for (i = 0; i < n; i++) { NSString *key = [keys objectAtIndex:i]; NSString *val = [dict objectForKey:key]; if ([val length] == 0) [dict removeObjectForKey:key]; } } /* Creates a label: an un-editable NSTextField displaying the given text. */ static NSTextField * make_label (NSString *text) { NSRect rect; rect.origin.x = rect.origin.y = 0; rect.size.width = rect.size.height = 10; NSTextField *lab = [[NSTextField alloc] initWithFrame:rect]; [lab setSelectable:NO]; [lab setEditable:NO]; [lab setBezeled:NO]; [lab setDrawsBackground:NO]; [lab setStringValue:text]; [lab sizeToFit]; return lab; } static NSView * last_child (NSView *parent) { NSArray *kids = [parent subviews]; int nkids = [kids count]; if (nkids == 0) return 0; else return [kids objectAtIndex:nkids-1]; } /* Add the child as a subview of the parent, positioning it immediately below or to the right of the previously-added child of that view. */ static void place_child (NSView *parent, NSView *child, BOOL right_p) { NSRect rect = [child frame]; NSView *last = last_child (parent); if (!last) { rect.origin.x = LEFT_MARGIN; rect.origin.y = [parent frame].size.height - rect.size.height - LINE_SPACING; } else if (right_p) { rect = [last frame]; rect.origin.x += rect.size.width + COLUMN_SPACING; } else { rect = [last frame]; rect.origin.x = LEFT_MARGIN; rect.origin.y -= [child frame].size.height + LINE_SPACING; } [child setFrameOrigin:rect.origin]; [parent addSubview:child]; } static void traverse_children (NSUserDefaultsController *, const XrmOptionDescRec *, NSView *, NSXMLNode *); /* Creates the checkbox (NSButton) described by the given XML node. */ static void make_checkbox (NSUserDefaultsController *prefs, const XrmOptionDescRec *opts, NSView *parent, NSXMLNode *node) { NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithObjectsAndKeys: @"", @"id", @"", @"_label", @"", @"arg-set", @"", @"arg-unset", nil]; parse_attrs (dict, node); NSString *label = [dict objectForKey:@"_label"]; NSString *arg_set = [dict objectForKey:@"arg-set"]; NSString *arg_unset = [dict objectForKey:@"arg-unset"]; if (!label) { NSAssert1 (0, @"no _label in %@", [node name]); return; } if (!arg_set && !arg_unset) { NSAssert1 (0, @"neither arg-set nor arg-unset provided in \"%@\"", label); } if (arg_set && arg_unset) { NSAssert1 (0, @"only one of arg-set and arg-unset may be used in \"%@\"", label); } // sanity-check the choice of argument names. // if (arg_set && ([arg_set hasPrefix:@"-no-"] || [arg_set hasPrefix:@"--no-"])) NSLog (@"arg-set should not be a \"no\" option in \"%@\": %@", label, arg_set); if (arg_unset && (![arg_unset hasPrefix:@"-no-"] && ![arg_unset hasPrefix:@"--no-"])) NSLog(@"arg-unset should be a \"no\" option in \"%@\": %@", label, arg_unset); NSRect rect; rect.origin.x = rect.origin.y = 0; rect.size.width = rect.size.height = 10; NSButton *button = [[NSButton alloc] initWithFrame:rect]; [button setButtonType:([[node name] isEqualToString:@"radio"] ? NSRadioButton : NSSwitchButton)]; [button setTitle:label]; [button sizeToFit]; place_child (parent, button, NO); bind_switch_to_preferences (prefs, button, (arg_set ? arg_set : arg_unset), opts); [button release]; } /* Creates the NSTextField described by the given XML node. */ static void make_text_field (NSUserDefaultsController *prefs, const XrmOptionDescRec *opts, NSView *parent, NSXMLNode *node, BOOL no_label_p) { NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithObjectsAndKeys: @"", @"id", @"", @"_label", @"", @"arg", nil]; parse_attrs (dict, node); NSString *label = [dict objectForKey:@"_label"]; NSString *arg = [dict objectForKey:@"arg"]; if (!label && !no_label_p) { NSAssert1 (0, @"no _label in %@", [node name]); return; } NSAssert1 (arg, @"no arg in %@", label); NSRect rect; rect.origin.x = rect.origin.y = 0; rect.size.width = rect.size.height = 10; NSTextField *txt = [[NSTextField alloc] initWithFrame:rect]; // make the default size be around 30 columns; a typical value for // these text fields is "xscreensaver-text --cols 40". // [txt setStringValue:@"123456789 123456789 123456789 "]; [txt sizeToFit]; [[txt cell] setWraps:NO]; [[txt cell] setScrollable:YES]; [txt setStringValue:@""]; if (label) { NSTextField *lab = make_label (label); place_child (parent, lab, NO); [lab release]; } place_child (parent, txt, (label ? YES : NO)); bind_switch_to_preferences (prefs, txt, arg, opts); [txt release]; } /* Creates the NSTextField described by the given XML node, and hooks it up to a Choose button and a file selector widget. */ static void make_file_selector (NSUserDefaultsController *prefs, const XrmOptionDescRec *opts, NSView *parent, NSXMLNode *node, BOOL dirs_only_p, BOOL no_label_p) { NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithObjectsAndKeys: @"", @"id", @"", @"_label", @"", @"arg", nil]; parse_attrs (dict, node); NSString *label = [dict objectForKey:@"_label"]; NSString *arg = [dict objectForKey:@"arg"]; if (!label && !no_label_p) { NSAssert1 (0, @"no _label in %@", [node name]); return; } NSAssert1 (arg, @"no arg in %@", label); NSRect rect; rect.origin.x = rect.origin.y = 0; rect.size.width = rect.size.height = 10; NSTextField *txt = [[NSTextField alloc] initWithFrame:rect]; // make the default size be around 20 columns. // [txt setStringValue:@"123456789 123456789 "]; [txt sizeToFit]; [txt setSelectable:YES]; [txt setEditable:NO]; [txt setBezeled:NO]; [txt setDrawsBackground:NO]; [[txt cell] setWraps:NO]; [[txt cell] setScrollable:YES]; [[txt cell] setLineBreakMode:NSLineBreakByTruncatingHead]; [txt setStringValue:@""]; NSTextField *lab = 0; if (label) { lab = make_label (label); place_child (parent, lab, NO); [lab release]; } place_child (parent, txt, (label ? YES : NO)); bind_switch_to_preferences (prefs, txt, arg, opts); [txt release]; // Make the text field be the same height as the label. if (lab) { rect = [txt frame]; rect.size.height = [lab frame].size.height; [txt setFrame:rect]; } // Now put a "Choose" button next to it. // rect.origin.x = rect.origin.y = 0; rect.size.width = rect.size.height = 10; NSButton *choose = [[NSButton alloc] initWithFrame:rect]; [choose setTitle:@"Choose..."]; [choose setBezelStyle:NSRoundedBezelStyle]; [choose sizeToFit]; place_child (parent, choose, YES); // center the Choose button around the midpoint of the text field. rect = [choose frame]; rect.origin.y = ([txt frame].origin.y + (([txt frame].size.height - rect.size.height) / 2)); [choose setFrameOrigin:rect.origin]; [choose setTarget:[parent window]]; if (dirs_only_p) [choose setAction:@selector(chooseClickedDirs:)]; else [choose setAction:@selector(chooseClicked:)]; [choose release]; } /* Runs a modal file selector and sets the text field's value to the selected file or directory. */ static void do_file_selector (NSTextField *txt, BOOL dirs_p) { NSOpenPanel *panel = [NSOpenPanel openPanel]; [panel setAllowsMultipleSelection:NO]; [panel setCanChooseFiles:!dirs_p]; [panel setCanChooseDirectories:dirs_p]; NSString *file = [txt stringValue]; if ([file length] <= 0) { file = NSHomeDirectory(); if (dirs_p) file = [file stringByAppendingPathComponent:@"Pictures"]; } // NSString *dir = [file stringByDeletingLastPathComponent]; int result = [panel runModalForDirectory:file //dir file:nil //[file lastPathComponent] types:nil]; if (result == NSOKButton) { NSArray *files = [panel filenames]; file = ([files count] > 0 ? [files objectAtIndex:0] : @""); file = [file stringByAbbreviatingWithTildeInPath]; [txt setStringValue:file]; // Fuck me! Just setting the value of the NSTextField does not cause // that to end up in the preferences! // NSDictionary *dict = [txt infoForBinding:@"value"]; NSUserDefaultsController *prefs = [dict objectForKey:@"NSObservedObject"]; NSString *path = [dict objectForKey:@"NSObservedKeyPath"]; if ([path hasPrefix:@"values."]) // WTF. path = [path substringFromIndex:7]; [[prefs values] setValue:file forKey:path]; #if 0 // make sure the end of the string is visible. NSText *fe = [[txt window] fieldEditor:YES forObject:txt]; NSRange range; range.location = [file length]-3; range.length = 1; if (! [[txt window] makeFirstResponder:[txt window]]) [[txt window] endEditingFor:nil]; // [[txt window] makeFirstResponder:nil]; [fe setSelectedRange:range]; // [tv scrollRangeToVisible:range]; // [txt setNeedsDisplay:YES]; // [[txt cell] setNeedsDisplay:YES]; // [txt selectAll:txt]; #endif } } /* Returns the NSTextField that is to the left of or above the NSButton. */ static NSTextField * find_text_field_of_button (NSButton *button) { NSView *parent = [button superview]; NSArray *kids = [parent subviews]; int nkids = [kids count]; int i; NSTextField *f = 0; for (i = 0; i < nkids; i++) { NSObject *kid = [kids objectAtIndex:i]; if ([kid isKindOfClass:[NSTextField class]]) { f = (NSTextField *) kid; } else if (kid == button) { if (! f) abort(); return f; } } abort(); } - (void) chooseClicked:(NSObject *)arg { NSButton *choose = (NSButton *) arg; NSTextField *txt = find_text_field_of_button (choose); do_file_selector (txt, NO); } - (void) chooseClickedDirs:(NSObject *)arg { NSButton *choose = (NSButton *) arg; NSTextField *txt = find_text_field_of_button (choose); do_file_selector (txt, YES); } /* Creates the number selection control described by the given XML node. If "type=slider", it's an NSSlider. If "type=spinbutton", it's a text field with up/down arrows next to it. */ static void make_number_selector (NSUserDefaultsController *prefs, const XrmOptionDescRec *opts, NSView *parent, NSXMLNode *node) { NSMutableDictionary *dict = [NSMutableDictionary dictionaryWithObjectsAndKeys: @"", @"id", @"", @"_label", @"", @"_low-label", @"", @"_high-label", @"", @"type", @"", @"arg", @"", @"low", @"", @"high", @"", @"default", @"", @"convert", nil]; parse_attrs (dict, node); NSString *label = [dict objectForKey:@"_label"]; NSString *low_label = [dict objectForKey:@"_low-label"]; NSString *high_label = [dict objectForKey:@"_high-label"]; NSString *type = [dict objectForKey:@"type"]; NSString *arg = [dict objectForKey:@"arg"]; NSString *low = [dict objectForKey:@"low"]; NSString *high = [dict objectForKey:@"high"]; NSString *def = [dict objectForKey:@"default"]; NSString *cvt = [dict objectForKey:@"convert"]; NSAssert1 (arg, @"no arg in %@", label); NSAssert1 (type, @"no type in %@", label); if (! low) { NSAssert1 (0, @"no low in %@", [node name]); return; } if (! high) { NSAssert1 (0, @"no high in %@", [node name]); return; } if (! def) { NSAssert1 (0, @"no default in %@", [node name]); return; } if (cvt && ![cvt isEqualToString:@"invert"]) { NSAssert1 (0, @"if provided, \"convert\" must be \"invert\" in %@", label); } // If either the min or max field contains a decimal point, then this // option may have a floating point value; otherwise, it is constrained // to be an integer. // NSCharacterSet *dot = [NSCharacterSet characterSetWithCharactersInString:@"."]; BOOL float_p = ([low rangeOfCharacterFromSet:dot].location != NSNotFound || [high rangeOfCharacterFromSet:dot].location != NSNotFound); if ([type isEqualToString:@"slider"]) { NSRect rect; rect.origin.x = rect.origin.y = 0; rect.size.width = 150; rect.size.height = 23; // apparent min height for slider with ticks... NSSlider *slider; if (cvt) slider = [[InvertedSlider alloc] initWithFrame:rect]; else slider = [[NSSlider alloc] initWithFrame:rect]; [slider setMaxValue:[high doubleValue]]; [slider setMinValue:[low doubleValue]]; int range = [slider maxValue] - [slider minValue] + 1; int range2 = range; int max_ticks = 21; while (range2 > max_ticks) range2 /= 10; // If we have elided ticks, leave it at the max number of ticks. if (range != range2 && range2 < max_ticks) range2 = max_ticks; // If it's a float, always display the max number of ticks. if (float_p && range2 < max_ticks) range2 = max_ticks; [slider setNumberOfTickMarks:range2]; [slider setAllowsTickMarkValuesOnly: (range == range2 && // we are showing the actual number of ticks !float_p)]; // and we want integer results // #### Note: when the slider's range is large enough that we aren't // showing all possible ticks, the slider's value is not constrained // to be an integer, even though it should be... // Maybe we need to use a value converter or something? if (label) { NSTextField *lab = make_label (label); place_child (parent, lab, NO); [lab release]; } if (low_label) { NSTextField *lab = make_label (low_label); [lab setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; [lab setAlignment:1]; // right aligned rect = [lab frame]; if (rect.size.width < LEFT_LABEL_WIDTH) rect.size.width = LEFT_LABEL_WIDTH; // make all left labels same size rect.size.height = [slider frame].size.height; [lab setFrame:rect]; place_child (parent, lab, NO); [lab release]; } place_child (parent, slider, (low_label ? YES : NO)); if (! low_label) { rect = [slider frame]; if (rect.origin.x < LEFT_LABEL_WIDTH) rect.origin.x = LEFT_LABEL_WIDTH; // make unlabelled sliders line up too [slider setFrame:rect]; } if (high_label) { NSTextField *lab = make_label (high_label); [lab setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; rect = [lab frame]; rect.size.height = [slider frame].size.height; [lab setFrame:rect]; place_child (parent, lab, YES); [lab release]; } bind_switch_to_preferences (prefs, slider, arg, opts); [slider release]; } else if ([type isEqualToString:@"spinbutton"]) { if (! label) { NSAssert1 (0, @"no _label in spinbutton %@", [node name]); return; } NSAssert1 (!low_label, @"low-label not allowed in spinbutton \"%@\"", [node name]); NSAssert1 (!high_label, @"high-label not allowed in spinbutton \"%@\"", [node name]); NSAssert1 (!cvt, @"convert not allowed in spinbutton \"%@\"", [node name]); NSRect rect; rect.origin.x = rect.origin.y = 0; rect.size.width = rect.size.height = 10; NSTextField *txt = [[NSTextField alloc] initWithFrame:rect]; [txt setStringValue:@"0000.0"]; [txt sizeToFit]; [txt setStringValue:@""]; if (label) { NSTextField *lab = make_label (label); //[lab setFont:[NSFont systemFontOfSize:[NSFont smallSystemFontSize]]]; [lab setAlignment:1]; // right aligned rect = [lab frame]; if (rect.size.width < LEFT_LABEL_WIDTH) rect.size.width = LEFT_LABEL_WIDTH; // make all left labels same size rect.size.height = [txt frame].size.height; [lab setFrame:rect]; place_child (parent, lab, NO); [lab release]; } place_child (parent, txt, (label ? YES : NO)); if (! label) { rect = [txt frame]; if (rect.origin.x < LEFT_LABEL_WIDTH) rect.origin.x = LEFT_LABEL_WIDTH; // make unlabelled spinbtns line up [txt setFrame:rect]; } rect.size.width = rect.size.height = 10; NSStepper *step = [[NSStepper alloc] initWithFrame:rect]; [step sizeToFit]; place_child (parent, step, YES); rect = [step frame]; rect.origin.x -= COLUMN_SPACING; // this one goes close rect.origin.y += ([txt frame].size.height - rect.size.height) / 2; [step setFrame:rect]; [step setMinValue:[low doubleValue]]; [step setMaxValue:[high doubleValue]]; [step setAutorepeat:YES]; [step setValueWraps:NO]; double range = [high doubleValue] - [low doubleValue]; if (range < 1.0) [step setIncrement:range / 10.0]; else if (range >= 500) [step setIncrement:range / 100.0]; else [step setIncrement:1.0]; NSNumberFormatter *fmt = [[[NSNumberFormatter alloc] init] autorelease]; [fmt setFormatterBehavior:NSNumberFormatterBehavior10_4]; [fmt setNumberStyle:NSNumberFormatterDecimalStyle]; [fmt setMinimum:[NSNumber numberWithDouble:[low doubleValue]]]; [fmt setMaximum:[NSNumber numberWithDouble:[high doubleValue]]]; [fmt setMinimumFractionDigits: (float_p ? 1 : 0)]; [fmt setMaximumFractionDigits: (float_p ? 2 : 0)]; [fmt setGeneratesDecimalNumbers:float_p]; [[txt cell] setFormatter:fmt]; bind_switch_to_preferences (prefs, step, arg, opts); bind_switch_to_preferences (prefs, txt, arg, opts); [step release]; [txt release]; } else { NSAssert2 (0, @"unknown type \"%@\" in \"%@\"", type, label); } } static void set_menu_item_object (NSMenuItem *item, NSObject *obj) { /* If the object associated with this menu item looks like a boolean, store an NSNumber instead of an NSString, since that's what will be in the preferences (due to similar logic in PrefsReader). */ if ([obj isKindOfClass:[NSString class]]) { NSString *string = (NSString *) obj; if (NSOrderedSame == [string caseInsensitiveCompare:@"true"] || NSOrderedSame == [string caseInsensitiveCompare:@"yes"]) obj = [NSNumber numberWithBool:YES]; else if (NSOrderedSame == [string caseInsensitiveCompare:@"false"] || NSOrderedSame == [string caseInsensitiveCompare:@"no"]) obj = [NSNumber numberWithBool:NO]; else obj = string; } [item setRepresentedObject:obj]; //NSLog (@"menu item \"%@\" = \"%@\" %@", [item title], obj, [obj class]); } /* Creates the popup menu described by the given XML node (and its children). */ static void make_option_menu (NSUserDefaultsController *prefs, const XrmOptionDescRec *opts, NSView *parent, NSXMLNode *node) { NSArray *children = [node children]; int i, count = [children count]; if (count <= 0) { NSAssert1 (0, @"no menu items in \"%@\"", [node name]); return; } // get the "id" attribute off the