1/**
2   Implementation of the GSToolTips class
3
4   Copyright (C) 2006 Free Software Foundation, Inc.
5
6   Author: Richard Frith-Macdonald <richard@brainstorm.co.uk>
7   Date: 2006
8
9   This file is part of the GNUstep GUI Library.
10
11   This library is free software; you can redistribute it and/or
12   modify it under the terms of the GNU Lesser General Public
13   License as published by the Free Software Foundation; either
14   version 2 of the License, or (at your option) any later version.
15
16   This library is distributed in the hope that it will be useful,
17   but WITHOUT ANY WARRANTY; without even the implied warranty of
18   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.	 See the GNU
19   Lesser General Public License for more details.
20
21   You should have received a copy of the GNU Lesser General Public
22   License along with this library; see the file COPYING.LIB.
23   If not, see <http://www.gnu.org/licenses/> or write to the
24   Free Software Foundation, 51 Franklin Street, Fifth Floor,
25   Boston, MA 02110-1301, USA.
26*/
27
28#import <Foundation/NSGeometry.h>
29#import <Foundation/NSRunLoop.h>
30#import <Foundation/NSString.h>
31#import <Foundation/NSTimer.h>
32#import <Foundation/NSIndexSet.h>
33#import <Foundation/NSUserDefaults.h>
34
35#import "AppKit/NSApplication.h"
36#import "AppKit/NSAttributedString.h"
37#import "AppKit/NSBezierPath.h"
38#import "AppKit/NSEvent.h"
39#import "AppKit/NSScreen.h"
40#import "AppKit/NSStringDrawing.h"
41#import "AppKit/NSView.h"
42#import "AppKit/NSPanel.h"
43#import "GNUstepGUI/GSTrackingRect.h"
44#import "GSToolTips.h"
45#import "GSFastEnumeration.h"
46
47@interface NSWindow (GNUstepPrivate)
48
49+ (void) _setToolTipVisible: (GSToolTips*)t;
50+ (GSToolTips*) _toolTipVisible;
51
52@end
53
54
55@interface	NSObject (ToolTips)
56- (NSString*) view: (NSView*)v stringForToolTip: (NSToolTipTag)t
57 point: (NSPoint)p userData: (void*)d;
58@end
59
60/* A trivial class to hold information about the provider of the tooltip
61 * string.  Instance allocation/deallocation is managed by GSToolTip and
62 * our instances are stored in the user data field of tracking rectangles.
63 */
64@interface	GSTTProvider : NSObject
65{
66  id		object;
67  void		*data;
68}
69- (void*) data;
70- (id) initWithObject: (id)o userData: (void*)d;
71- (id) object;
72- (void) setObject: (id)o;
73@end
74
75@implementation	GSTTProvider
76- (void*) data
77{
78  return data;
79}
80- (void) dealloc
81{
82  [self setObject: nil];
83  [super dealloc];
84}
85- (id) initWithObject: (id)o userData: (void*)d
86{
87  data = d;
88  [self setObject: o];
89  return self;
90}
91- (id) object
92{
93  return object;
94}
95- (void) setObject: (id)o
96{
97  /* Experimentation on MacOS-X shows that the object is not retained.
98   * However, if the object does not provide a string, we must use a
99   * copy of its description ... and we have to retain that until we
100   * are done with it.
101   */
102  if ([object respondsToSelector:
103    @selector(view:stringForToolTip:point:userData:)] == NO)
104    {
105      /* Object must be a string rather than something which provides one */
106      RELEASE(object);
107    }
108  object = o;
109  if ([object respondsToSelector:
110    @selector(view:stringForToolTip:point:userData:)] == NO)
111    {
112      /* Object does not provide a string ... so we take a copy of it
113       * as the string to be used.
114       */
115      object = [[object description] copy];
116    }
117}
118@end
119
120@interface GSTTView : NSView
121{
122  NSAttributedString *_text;
123}
124
125- (void)setText: (NSAttributedString *)text;
126@end
127
128@implementation GSTTView
129- (id) initWithFrame: (NSRect)frameRect
130{
131  self = [super initWithFrame: frameRect];
132  if (self)
133    {
134      [self setAutoresizingMask: NSViewWidthSizable | NSViewHeightSizable];
135    }
136  return self;
137}
138
139- (void) setText: (NSAttributedString *)text
140{
141  if (_text != text)
142    {
143      ASSIGN(_text, text);
144      [self setNeedsDisplay: YES];
145    }
146}
147
148- (void) drawRect: (NSRect)dirtyRect
149{
150  if (_text)
151    {
152      NSRectEdge sides[] = {NSMinXEdge, NSMaxYEdge, NSMaxXEdge, NSMinYEdge};
153      NSColor *black = [NSColor blackColor];
154      NSColor *colors[] = {black, black, black, black};
155      NSRect bounds = [self bounds];
156      NSRect frame = [self frame];
157      NSRect textRect = NSInsetRect(frame, 2, 2);
158
159      NSDrawColorTiledRects(bounds, bounds, sides, colors, 4);
160      [_text drawInRect: textRect];
161    }
162}
163@end
164
165@interface GSTTPanel : NSPanel
166// Tooltip panel that will not try to become main or key
167- (BOOL) canBecomeKeyWindow;
168- (BOOL) canBecomeMainWindow;
169
170@end
171
172@implementation GSTTPanel
173
174- (id) initWithContentRect: (NSRect)contentRect
175                 styleMask: (NSUInteger)aStyle
176                   backing: (NSBackingStoreType)bufferingType
177                     defer: (BOOL)flag;
178{
179  self = [super initWithContentRect: contentRect
180                          styleMask: aStyle
181                            backing: bufferingType
182                              defer: flag];
183  if (self)
184    {
185      [self setContentView: [[[GSTTView alloc] initWithFrame: contentRect] autorelease]];
186    }
187  return self;
188}
189
190- (BOOL) canBecomeKeyWindow
191{
192  return NO;
193}
194
195- (BOOL) canBecomeMainWindow
196{
197  return NO;
198}
199
200@end
201
202
203@interface	GSToolTips (Private)
204- (void) _endDisplay;
205- (void) _endDisplay: (NSTrackingRectTag)tag;
206- (void) _timedOut: (NSTimer *)timer;
207@end
208/*
209typedef struct NSView_struct
210{
211  @defs(NSView)
212} *NSViewPtr;
213*/
214typedef NSView* NSViewPtr;
215
216@implementation GSToolTips
217
218static NSMapTable	*viewsMap = 0;
219static NSTimer		*timer = nil;
220static GSToolTips       *timedObject = nil;
221static NSTrackingRectTag timedTag = NSNotFound;
222// Having a single stored panel for tooltips greatly reduces callback interaction from MS-Windows
223static GSTTPanel	*window = nil;
224// Prevent Windows callback API from attempting to dismiss tooltip as its in the process of appearing
225static BOOL   isOpening = NO;
226static NSSize		offset;
227static BOOL		restoreMouseMoved;
228
229+ (void) initialize
230{
231  viewsMap = NSCreateMapTable(NSNonOwnedPointerMapKeyCallBacks,
232			     NSObjectMapValueCallBacks, 8);
233
234  window = [[GSTTPanel alloc] initWithContentRect: NSMakeRect(0,0,100,25)
235				       styleMask: NSBorderlessWindowMask
236					 backing: NSBackingStoreRetained
237					   defer: YES];
238
239  [window setBackgroundColor: [NSColor toolTipColor]];
240  [window setReleasedWhenClosed: NO];
241  [window setExcludedFromWindowsMenu: YES];
242  [window setLevel: NSPopUpMenuWindowLevel];
243  [window setAutodisplay: NO];
244}
245
246+ (void) removeTipsForView: (NSView*)aView
247{
248  GSToolTips	*tt = (GSToolTips*)NSMapGet(viewsMap, (void*)aView);
249
250  if (tt != nil)
251    {
252      [tt removeAllToolTips];
253      NSMapRemove(viewsMap, (void*)aView);
254    }
255}
256
257+ (GSToolTips*) tipsForView: (NSView*)aView
258{
259  GSToolTips	*tt = (GSToolTips*)NSMapGet(viewsMap, (void*)aView);
260
261  if (tt == nil)
262    {
263      tt = [[GSToolTips alloc] initForView: aView];
264      NSMapInsert(viewsMap, (void*)aView, (void*)tt);
265      RELEASE(tt);
266    }
267  return tt;
268}
269
270
271
272- (NSToolTipTag) addToolTipRect: (NSRect)aRect
273                          owner: (id)anObject
274                       userData: (void *)data
275{
276  NSTrackingRectTag	tag;
277  GSTTProvider		*provider;
278
279  if (timer != nil)
280    {
281      return -1;	// A tip is already in progress.
282    }
283  aRect = NSIntersectionRect(aRect, [view bounds]);
284  if (NSEqualRects(aRect, NSZeroRect))
285    {
286      return -1;	// No rectangle.
287    }
288  if (anObject == nil)
289    {
290      return -1;	// No provider object.
291    }
292
293  provider = [[GSTTProvider alloc] initWithObject: anObject
294					 userData: data];
295  tag = [view addTrackingRect: aRect
296                        owner: self
297                     userData: provider
298                 assumeInside: NO];
299  return tag;
300}
301
302- (unsigned) count
303{
304  NSEnumerator		*enumerator;
305  GSTrackingRect	*rect;
306  unsigned		count = 0;
307
308  enumerator = [((NSViewPtr)view)->_tracking_rects objectEnumerator];
309  while ((rect = [enumerator nextObject]) != nil)
310    {
311      if (rect->owner == self)
312        {
313	  count++;
314	}
315    }
316  return count;
317}
318
319- (void) dealloc
320{
321  [self _endDisplay];
322  [self removeAllToolTips];
323  [super dealloc];
324}
325
326- (id) initForView: (NSView*)aView
327{
328  view = aView;
329  toolTipTag = -1;
330  return self;
331}
332
333- (void) mouseEntered: (NSEvent *)theEvent
334{
335  GSTTProvider	*provider;
336  NSString	*toolTipString;
337
338  if (timer != nil)
339    {
340      /* Moved from one tooltip view to another, so reset the timer.
341       */
342      [timer invalidate];
343      timer = nil;
344      timedObject = nil;
345      timedTag = NSNotFound;
346    }
347
348  provider = (GSTTProvider*)[theEvent userData];
349  if ([[provider object] respondsToSelector:
350    @selector(view:stringForToolTip:point:userData:)] == YES)
351    {
352      // From testing on OS X, point is in the view's coordinate system
353      // The locationInWindow has been converted to this in
354      // [NSWindow _checkTrackingRectangles:forEvent:]
355      NSPoint p = [theEvent locationInWindow];
356
357      toolTipString = [[provider object] view: view
358			     stringForToolTip: [theEvent trackingNumber]
359					point: p
360				     userData: [provider data]];
361    }
362  else
363    {
364      toolTipString = [provider object];
365    }
366
367  timer = [NSTimer scheduledTimerWithTimeInterval: 0.5
368                                           target: self
369                                         selector: @selector(_timedOut:)
370                                         userInfo: toolTipString
371                                          repeats: YES];
372  [[NSRunLoop currentRunLoop] addTimer: timer forMode: NSModalPanelRunLoopMode];
373  timedObject = self;
374  timedTag = [theEvent trackingNumber];
375  if ([[view window] acceptsMouseMovedEvents] == YES)
376    {
377      restoreMouseMoved = NO;
378    }
379  else
380    {
381      restoreMouseMoved = YES;
382      [[view window] setAcceptsMouseMovedEvents: YES];
383    }
384  [NSWindow _setToolTipVisible: self];
385}
386
387- (void) mouseExited: (NSEvent *)theEvent
388{
389  [self _endDisplay:[theEvent trackingNumber]];
390}
391
392- (void) mouseDown: (NSEvent *)theEvent
393{
394  [self _endDisplay];
395}
396
397- (void) mouseMoved: (NSEvent *)theEvent
398{
399  NSPoint mouseLocation;
400  NSPoint origin;
401
402  if (window == nil)
403    {
404      return;
405    }
406
407  mouseLocation = [NSEvent mouseLocation];
408
409  origin = NSMakePoint(mouseLocation.x + offset.width,
410    mouseLocation.y + offset.height);
411
412  [window setFrameOrigin: origin];
413}
414
415- (void) removeAllToolTips
416{
417  NSEnumerator		*enumerator;
418  GSTrackingRect	*rect;
419
420  [self _endDisplay];
421
422  enumerator = [((NSViewPtr)view)->_tracking_rects objectEnumerator];
423  while ((rect = [enumerator nextObject]) != nil)
424    {
425      if (rect->owner == self)
426        {
427	  RELEASE((GSTTProvider*)rect->user_data);
428	  rect->user_data = 0;
429          [view removeTrackingRect: rect->tag];
430	}
431    }
432  toolTipTag = -1;
433}
434
435- (void)removeToolTipsInRect: (NSRect)aRect
436{
437  NSUInteger idx = 0;
438  NSMutableIndexSet *indexes = [NSMutableIndexSet new];
439  id tracking_rects = ((NSViewPtr)view)->_tracking_rects;
440  FOR_IN(GSTrackingRect*, rect, tracking_rects)
441    if ((rect->owner == self) && NSContainsRect(aRect, rect->rectangle))
442      {
443        RELEASE((GSTTProvider*)rect->user_data);
444        rect->user_data = 0;
445        [indexes addIndex: idx];
446        [rect invalidate];
447      }
448      idx++;
449  END_FOR_IN(tracking_rects)
450  [((NSViewPtr)view)->_tracking_rects removeObjectsAtIndexes: indexes];
451  if ([((NSViewPtr)view)->_tracking_rects count] == 0)
452    {
453      ((NSViewPtr)view)->_rFlags.has_trkrects = 0;
454    }
455  [indexes release];
456}
457
458- (void) removeToolTip: (NSToolTipTag)tag
459{
460  NSEnumerator   	*enumerator;
461  GSTrackingRect	*rect;
462
463  enumerator = [((NSViewPtr)view)->_tracking_rects objectEnumerator];
464  while ((rect = [enumerator nextObject]) != nil)
465    {
466      if (rect->tag == tag && rect->owner == self)
467	{
468	  RELEASE((GSTTProvider*)rect->user_data);
469	  rect->user_data = 0;
470	  [view removeTrackingRect: tag];
471          return;
472	}
473    }
474}
475
476- (void) setToolTip: (NSString *)string
477{
478  if ([string length] == 0)
479    {
480      if (toolTipTag != -1)
481        {
482	  [self _endDisplay];
483	  [self removeToolTip: toolTipTag];
484	  toolTipTag = -1;
485	}
486    }
487  else
488    {
489      GSTTProvider	*provider;
490
491      if (toolTipTag == -1)
492        {
493	  NSRect	rect;
494
495	  rect = [view bounds];
496	  provider = [[GSTTProvider alloc] initWithObject: string
497						 userData: nil];
498	  toolTipTag = [view addTrackingRect: rect
499				       owner: self
500				    userData: provider
501				assumeInside: NO];
502	}
503      else
504        {
505	  NSEnumerator   	*enumerator;
506	  GSTrackingRect	*rect;
507
508	  enumerator = [((NSViewPtr)view)->_tracking_rects objectEnumerator];
509	  while ((rect = [enumerator nextObject]) != nil)
510	    {
511	      if (rect->tag == toolTipTag && rect->owner == self)
512		{
513		  [((GSTTProvider*)rect->user_data) setObject: string];
514		}
515	    }
516	}
517    }
518}
519
520- (NSString *) toolTip
521{
522  NSEnumerator		*enumerator;
523  GSTrackingRect	*rect;
524
525  enumerator = [((NSViewPtr)view)->_tracking_rects objectEnumerator];
526  while ((rect = [enumerator nextObject]) != nil)
527    {
528      if (rect->tag == toolTipTag)
529	{
530	  return [((GSTTProvider*)rect->user_data) object];
531	}
532    }
533  return nil;
534}
535
536@end
537
538@implementation	GSToolTips (Private)
539
540- (void) _endDisplay
541{
542  [self _endDisplay:NSNotFound];
543}
544
545- (void) _endDisplay: (NSTrackingRectTag)tag
546{
547  if (isOpening)
548    return;
549  if ([NSWindow _toolTipVisible] == self)
550    {
551      [NSWindow _setToolTipVisible: nil];
552    }
553  /* If there is currently a timer running for this object and it is the target tag,
554   * cancel it. Always remove if the target tag is NSNotFound
555   */
556  if (timer != nil && timedObject == self && (timedTag == tag || tag == NSNotFound))
557    {
558      if ([timer isValid])
559	{
560	  [timer invalidate];
561	}
562      timer = nil;
563      timedObject = nil;
564      timedTag = NSNotFound;
565    }
566  if (window != nil)
567    {
568      [window setFrame: NSZeroRect display: NO];
569      [window orderOut:self];
570    }
571  if (restoreMouseMoved == YES)
572    {
573      restoreMouseMoved = NO;
574      [[view window] setAcceptsMouseMovedEvents: NO];
575    }
576}
577
578/* The delay timed out -- display the tooltip */
579- (void) _timedOut: (NSTimer *)aTimer
580{
581  CGFloat               size;
582  NSString		*toolTipString;
583  NSAttributedString	*toolTipText = nil;
584  NSSize		textSize;
585  NSPoint		mouseLocation = [NSEvent mouseLocation];
586  NSRect		visible;
587  NSRect		rect;
588  NSMutableDictionary	*attributes;
589
590  // retain and autorelease the timer's userinfo because we
591  // may  invalidate the timer (which releases the userinfo),
592  // but need the userinfo object to remain valid for the
593  // remainder of this method.
594  toolTipString = [[[aTimer userInfo] retain] autorelease];
595  if ( (nil == toolTipString) ||
596       ([toolTipString isEqualToString: @""]) )
597    {
598      return;
599    }
600
601  if (timer != nil)
602    {
603      if ([timer isValid])
604	{
605	  [timer invalidate];
606	}
607      timer = nil;
608      timedObject = nil;
609      timedTag = NSNotFound;
610    }
611
612  if ([window isVisible])
613    {
614      /* Moved from one tooltip view to another ... so stop displaying
615       * the old tool tip before we start the new one.
616       * This is similar to the case in -mouseEntered: where we cancel
617       * the timer for one tooltip view because we have entered another
618       * one.
619       * To think about ... if we entered a tooltip rectangle without
620       * having left the previous one, then when we leave this rectangle
621       * we are probably back in the other one and should really restart
622       * the timer for the original view.  However, this is a rare case
623       * so it's probably better to ignore it than add a lot of code to
624       * keep track of all entry and exit.
625       */
626      [self _endDisplay];
627    }
628
629  size = [[NSUserDefaults standardUserDefaults]
630	   floatForKey: @"NSToolTipsFontSize"];
631
632  if (size <= 0)
633    {
634      size = 10.0;
635    }
636
637  attributes = [NSMutableDictionary dictionary];
638  [attributes setObject: [NSFont toolTipsFontOfSize: size]
639		 forKey: NSFontAttributeName];
640  [attributes setObject: [NSColor toolTipTextColor]
641                  forKey: NSForegroundColorAttributeName];
642  toolTipText =
643    [[NSAttributedString alloc] initWithString: toolTipString
644				    attributes: attributes];
645  textSize = [toolTipText size];
646  if (textSize.width > 300)
647    {
648      NSRect rect;
649      rect = [toolTipText boundingRectWithSize: NSMakeSize(300, 1e7)
650                                       options: 0];
651      textSize = rect.size;
652      // This extra pixel is needed, otherwise the last line gets cut off.
653      textSize.height += 1;
654    }
655
656  /* Create window just off the current mouse position
657   * Constrain it to be on screen, shrinking if necessary.
658   */
659  rect = NSMakeRect(mouseLocation.x + 8,
660    mouseLocation.y - 16 - (textSize.height+3),
661    textSize.width + 4, textSize.height + 4);
662  visible = [[NSScreen mainScreen] visibleFrame];
663  if (NSMaxY(rect) > NSMaxY(visible))
664    {
665      rect.origin.y -= (NSMaxY(rect) - NSMaxY(visible));
666    }
667  if (NSMinY(rect) < NSMinY(visible))
668    {
669      rect.origin.y += (NSMinY(visible) - NSMinY(rect));
670    }
671  if (NSMaxY(rect) > NSMaxY(visible))
672    {
673      rect.origin.y = visible.origin.y;
674      rect.size.height = visible.size.height;
675    }
676
677  if (NSMaxX(rect) > NSMaxX(visible))
678    {
679      rect.origin.x -= (NSMaxX(rect) - NSMaxX(visible));
680    }
681  if (NSMinX(rect) < NSMinX(visible))
682    {
683      rect.origin.x += (NSMinX(visible) - NSMinX(rect));
684    }
685  if (NSMaxX(rect) > NSMaxX(visible))
686    {
687      rect.origin.x = visible.origin.x;
688      rect.size.width = visible.size.width;
689    }
690  offset.height = rect.origin.y - mouseLocation.y;
691  offset.width = rect.origin.x - mouseLocation.x;
692
693  isOpening = YES;
694  [(GSTTView*)([window contentView]) setText: toolTipText];
695  [window setFrame: rect display: NO];
696  [window orderFront: nil];
697  isOpening = NO;
698
699  RELEASE(toolTipText);
700}
701
702@end
703
704