[comp.windows.news] Pie Menus for NeWS

don@BRILLIG.UMD.EDU (Don Hopkins) (07/08/87)

Date: Wed, 1 Jul 87 23:04:47 EDT
From: Don Hopkins <don@brillig.umd.edu>
To: NeWS-makers@brillig.umd.edu
Subject: Pie Menus for NeWS

This is my initial implementation of Pie Menus for NeWS, in
PostScript.  The choices of a Pie Menu are positioned in a circle
around the cursor, so that the direction of movement makes the
selection.  There is an inactive region in the menu center where the
cursor starts out, and the regions corresponding to the choices are
wedge shaped, like the slices of a pie: each slice is adjacent to the
cursor, but in a different direction.  Moving the cursor further out
from the menu center increases the angular precision.  Pie Menu
choices may be positioned in intuitivly correct directions, with
opposite choices in opposite directions, and other natural
arrangments. Pie Menus are easy to learn, using "muscle memory",
because you remember directions, not order. Because you don't need to
look at the menu to choose a direction, you can mouse ahead dependably
with menus you're familiar with.

More information about Pie Menus if forthcoming, but right now I want
to get the initial implementation out so that people can use them.
Just load the following file into a NeWS server with psh, and try them
out.  They replace the default menu class, so that subsequently made
menus will be Pie Menus.  I welcome your questions, comments, and
suggestions.

    -Don

    Spoken: Don Hopkins
    Net: don@brillig.umd.edu, ...!seismo!mimsy!don
    Mail: 5819 Ruatan St., College Park, Md. 20740, USA

---Clip-here--8X--------------------------------------------------------
%!
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%
%  @(#)piemenu.ps
%
%  Pie menu class implementation.
%  Copyright (C) 1987.
%  By Don Hopkins.
%  All rights reserved.
%
%    Simple Simon popped a Pie Men-
%	u upon the screen;
%    With directional selection,
%	all is peachy keen!
%
%  Pie Menus are provided for UNRESTRICTED use provided that this
%  copyright message is preserved on all copies and derivitive works.
%  This is provided without any warranty. No author or distributor
%  accepts any responsibility whatsoever to any person or any entity
%  with respect to any loss or damage caused or alleged to be caused
%  directly or indirectly by this program. This includes, but is not
%  limited to, any interruption of service, loss of business, loss of
%  information, loss of anticipated profits, core dumps, abuses of the
%  virtual memory system, or any consequential or incidental damages
%  resulting from the use of this program.
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%
% May 28 1987	Don Hopkins
%   First cut, based on LitePullRightMenu.
%
% May 30 1987	Don Hopkins
%   Uses "Thing"s from liteitem.ps for key labels. A thing can be a
%     string, or a keyword. The string is shown in MenuFont. The
%     keyword can be either the name of an icon in icondict, or bound
%     on the dict stack to an executable function. The function takes
%     a boolean as input; if true, it draws itsself; if false, it
%     returns its width and height.
%   Better label positioning scheme: top or bottom justify labels at
%     at the very bottom or top of the menu, and left or right justify
%     labels on the right or left sides of the menu. The points
%     relative to which the labels are justified are positioned at
%     evenly spaced angles in a circle around the menu center. The
%     instance variable PieInitialAngle is the angle of the first
%     point. LabelRadius is the distance from the menu center to each
%     point, calculated as:
%       LabelMinRadius + LabelRadiusPerKey * <the number of menu keys>
%   If the menu can't be centered on the location of the button
%     event that invoked it, then warp the cursor to the menu center
%     plus how much it has moved since the button down event, so that
%     pop up menus near the screen edge and static menus work
%     correctly. But ARRRGH FOO: setcursorlocation is broken!!! It
%     moves the cursor, but next time you move the mouse, the cursor
%     pops back to where it used to be! The Sun X server used to have
%     the same problem with XWarpMouse. Makes you wonder. Well,
%     anyway, I commented it out, because it's more confusing with
%     setcursorlocation broken than it is not warping at all.
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%
% Things to do:
%
% Use blockinputqueue to avoid missing button up events that happen
% immediatly after button down events, when popping up the first menu.
% I tried it, putting it in the beginning of /show, and making a /fork
% method that went /fork super send, and then unblockinputqueue, but
% still it sometimes misses them, especially when something is slowing
% the system down. But only on the root menu ...
%
% Uncomment setcursorlocation code that moves mouse to menu center
%   when setcursorlocation is fixed.
%
% Don't bother putting up a menu (or even moving the menu to be
%   completely on screen), if the button event that would make
%   the selection is already in the input queue. 
%  
% Teach it to use items as menu keys. Create PieItems like buttons,
%   cycles, sliders and pull-out menus based on the distance,
%   etc...
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

systemdict /Item known not {
  (NeWS/liteitem.ps) run
} if

systemdict /LiteMenu known not {
  (NeWS/litemenu.ps) run
} if

systemdict begin

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%
% Utilities
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

% Coerce an angle to be >=0 and <360.
% Note: mod returns integers, so's no good.
/NormalAngle { % angle => angle
  dup 0 lt {
    dup 360 sub 360 idiv 360 mul sub
  } if
  dup 360 ge {
    dup 360 idiv 360 mul sub
  } if
} def

% From demomenu.ps

% Fake method to send to a menu that returns a copy of the menu in the
% new menu style. Recursivly changes all sub-menus. One thing to look
% out for is that it does not change variables bound to the sub-menus
% that were changed, so setting /rootmenu to the result of sending
% /flipstyle to rootmenu will give you a new root menu, with a new
% terminal sub-menu, but /terminalmenu will still be bound to the old
% one, so sending messages to terminalmenu will not change the
% terminal menu you get under the new rootmenu. But sending /flipstyle
% to terminalwindow would not update the terminal menu under rootmenu.
% So get your changes in before you flip styles!

/flipstyle { % - => newmenu
    0 1 MenuActions length 1 sub {
	MenuActions 1 index get		% i ithAction
	dup type /dicttype eq {
	    /flipstyle exch send	% i menu'
	    MenuActions 3 1 roll put	% -
	} {pop pop} ifelse
    } for
    MenuKeys MenuActions /new DefaultMenu send
} def

% Override flipdefaultmenustyle, a function invoked from the user
% interface menu.

/flipdefaultmenustyle { % - => - (Flips default menu style)
  /DefaultMenu
    DefaultMenu SunViewMenu eq {PieMenu} {SunViewMenu} ifelse
  store
} def

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%
% PieMenu class
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

/PieMenu LiteMenu

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%
% Instance variables
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

dictbegin
% The slice currently painted.
    /PaintedValue	null def
% Info about MenuFont.
    /FontAscent		null def
    /FontHeight		null def
% Inner radius around which labels are positioned. Based  LabelMinRadius,
% LabelRadiusPerKey, and the length of MenuKeys.
    /LabelRadius	null def
% Minimum radius for label positioning.
    /LabelMinRadius	40 def
% Increase LabelRadius by this much per key.
    /LabelRadiusPerKey	3 def
% Direction in which the keys are laid out around the circle.
    /Clockwise		true def
% Pie menu outer radius. Based on LabelRadius and the bounding boxes of
% the Key Things.
    /PieRadius		null def
% The angle at which the first key is placed.
    /PieInitialAngle	90 def
% The number of degrees a slice takes up. Based on length of MenuKeys. 
    /PieSliceWidth	null def
% The current direction in degrees of the cursor from the menu center.
    /PieDirection	null def
% The current distance of the cursor from the menu center.
    /PieDistance	null def
% Angle used in loops.
    /ThisAngle		null def
% Ammount to move the menu so that it fits entirely on the screen.
    /DeltaX		null def
    /DeltaY		null def
dictend

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%
% Class variables
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

classbegin
% Highlight: true strokes, false fills.
    /StrokeSelection	false def
% Width of border just inside PieRadius perimiter.
    /Border		3 def
% Gap between outermost label edge and border.
    /Gap		9 def
% Radius of numb hole in menu center that makes no menu selection.
    /NumbRadius		10 def
% Fudge factors for menu positioning.
    /MouseXDelta	0 def
    /MouseYDelta	-3 def
% Draw lines delimiting slices.
    /SliceLines		false def
% Draw arrows in the directions of slices.
    /SliceArrows	true def
% Drill a hole through the menu center, as big as NumbRadius.
    /NumbHole		true def
% Save the bits so pop-up is fast.
    /RetainCanvas?	true def

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%
% Class methods
%
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

% Calculate and set the menu FontAscent, FontHeight, PieSliceWidth,
% LabelRadius, PieRadius, MenuWidth, and MenuHeight. Shape the canvas
% and set the cursor.

    /layout {
      gsave
        MenuFont setfont initmatrix MenuCanvas setcanvas
        /FontAscent currentfont fontascent store
        /FontHeight currentfont fontheight store

	/PieSliceWidth 360 MenuKeys length 1 max div store

	/LabelRadius
	  LabelMinRadius LabelRadiusPerKey MenuKeys length mul add store

	/ThisAngle PieInitialAngle store
	/PieRadius
  	  0 MenuKeys {					% maxradius key
	    MenuFont ThingSize				% maxradius w h
	    ThisAngle cos abs .01 lt {
	      exch 2 div exch
	    } {
	      2 div
	    } ifelse
	    ThisAngle sin abs LabelRadius mul add exch	% maxradius y w
	    ThisAngle cos abs LabelRadius mul add	% maxradius y x
	    dup mul exch dup mul add			% maxradius r
	    max						% maxradius
	    /ThisAngle ThisAngle PieSliceWidth
	      Clockwise {sub} {add} ifelse
	    store
	  } forall
	  sqrt Gap add Border add store

        /MenuWidth
	  PieRadius dup add store
        /MenuHeight
	  PieRadius dup add store

	framebuffer setcanvas
	PieRadius dup dup 0 360 arc
	NumbHole {
		PieRadius dup NumbRadius 1 sub 0 360 arc } if
	MenuCanvas eoreshapecanvas
	/beye /beye_m MenuCanvas setstandardcursor
	% So retained canvases don't have their old image upon popup:
	MenuCanvas setcanvas
        MenuFillColor fillcanvas

      grestore
    } def

% Make sure nothing's highlighted if there's a retained canvas.  Fork
% an event manager, make a canvas, and layout the menu as needed.  If
% ShowAtMouse? is true, move the menu so it's centered on the mouse,
% and calculate any movement (DeltaX, DeltaY) needed to force it
% to be completely on the screen. Move the menu to the right place on
% screen, and set the canvas up. Move the cursor by the same ammount
% that the menu had to be moved (DeltaX, DeltaY). (This is
% commented out because setcursorlocation is broken.) Reset the menu
% value, and fork a menu event manager.

    /show {
	PaintedValue null ne MenuCanvas null ne and MenuWidth null ne and {
	    % /paint self send
	    % God! Fix the paint stuff to be more atomic!!
	    gsave MenuFont setfont initmatrix MenuCanvas setcanvas
	        PaintedValue PaintSlice
	    grestore
	} if
	/PaintedValue null store

	MenuEventMgr null ne {MenuEventMgr waitprocess pop} if
	MenuCanvas null eq {/MenuCanvas ParentCanvas newcanvas def} if
	MenuWidth null eq {/layout self send} if
	ShowAtMouse? {
	  gsave
	    framebuffer setcanvas 
	    CurrentEvent begin XLocation YLocation end
	    /MenuY exch PieRadius sub MouseYDelta add store
	    /MenuX exch PieRadius sub MouseXDelta add store

	    % Correct for menu being beyond framebuffer.
	    clippath pathbbox
	    /DeltaY
	      exch MenuHeight sub MenuY min 0 max
	      MenuY sub store
	    /DeltaX
	      exch MenuWidth sub MenuX min 0 max
	      MenuX sub store
	    pop pop
	  grestore
	} {
	  /DeltaX 0 store  /DeltaY 0 store
	} ifelse

	/MenuX MenuX DeltaX add store
	/MenuY MenuY DeltaY add store

	MenuCanvas savebehindcanvas 
        MenuCanvas setcanvas
	MenuX MenuY movecanvas
        MenuCanvas canvastotop

% Arrgh! setcursorlocation is broken!

%	gsave
%	  fb setcanvas
%	  currentcursorlocation
%	  exch DeltaX add exch DeltaY add setcursorlocation
%	grestore

        MenuCanvas mapcanvas	% note that this causes brain damage, thus
        			% causes paint to be called.

	/MenuValue null def
	/fork self send
    } def

% Paint the menu. Draw a Border width border inside PieRadius, and a
% circle of radius NumbRadius at the menu center, in the
% MenuBorderColor. Draw the keys at points on a circle around the
% center of the menu. PieInitialAngle is the angle of the first point.
% The points are PieSliceWidth degrees apart, at a distance of
% LabelRadius.  The keys may be Things (from liteitem.ps), which
% ThingSize and ShowThing take. If the angle of a point is 90 or 270,
% then the center of the bottom or top edge of the Thing is positioned
% at the point. If the angle is to the left (>90 and <270) or right
% (<90 or >270), then the center of the right or left edge of the
% Thing is positioned at the point.  If SliceLines is true, lines are
% drawn at the edges of the slices.  If SliceArrows is true, arrows
% are drawn from the menu center pointing in the direction of each
% slice.

    /paint {
    gsave MenuFont setfont initmatrix MenuCanvas setcanvas
        MenuFillColor fillcanvas

	PieRadius dup translate
	
	newpath 0 0 PieRadius Border sub 0 360 arc
	0 0 PieRadius 0 360 arc
	MenuBorderColor setcolor eofill
	newpath 0 0 NumbRadius 0 360 arc fill
	

	/ThisAngle PieInitialAngle NormalAngle store

        MenuKeys {					% thing
          MenuTextColor 1 index				% thing color thing
	  MenuFont ThingSize				% thing color w h
	  ThisAngle cos abs .01 lt {
	    ThisAngle 180 lt {
	      pop -2 div 0
	    } {
	      neg exch -2 div exch
	    } ifelse
	  } {
	    ThisAngle 90 gt ThisAngle 270 lt and {
	      -2 div exch neg exch
	    } {
	      -2 div exch pop 0 exch
	    } ifelse
	  } ifelse					% thing color dx dy
	  ThisAngle sin LabelRadius mul add exch	% thing color y dx
	  ThisAngle cos LabelRadius mul add exch	% thing color x y
	  MenuFont
	  ShowThing

	  SliceLines {
	    gsave
	      newpath
	      ThisAngle PieSliceWidth 2 div sub rotate
	      NumbRadius 0 moveto
	      PieRadius 1 sub 0 lineto
              MenuBorderColor setcolor
	      stroke
 	    grestore
	  } if

	  SliceArrows {
	    gsave
	      newpath
	      ThisAngle rotate
	      NumbRadius 0 moveto
	      LabelRadius .5 mul 0 lineto
	      currentpoint
	      LabelRadius .4 mul LabelRadius .04 mul lineto
	      moveto
	      LabelRadius .4 mul LabelRadius -.04 mul lineto
              MenuBorderColor setcolor
	      stroke
 	    grestore
	  } if

	  /ThisAngle ThisAngle PieSliceWidth
	    Clockwise {sub} {add} ifelse
	    NormalAngle
	  def
        } forall
    grestore
    } def

% Handle drag events. If there's not a child menu up, then track the
% mouse movement, updating the menu value according the the event
% location; if it has changed, then update the highlighting.

    /DragProc {
	ChildMenu null eq {
        gsave MenuFont setfont initmatrix MenuCanvas setcanvas
	    PieRadius dup translate
	    CurrentEvent begin XLocation YLocation end		% x y
	    SetMenuValue

	    MenuValue PaintedValue ne {
	        PaintMenuValue
            } if
        grestore
	} if
    } def

% Handle enter canvas events. Just call DragProc to keep the menu
% value updated. 

    /EnterProc {
	DragProc
    } def

% Handle exit canvas events. Same as above. Here we keep tracking even
% when you're off the menu edge (due to expressing interest in events
% on the framebuffer overlay canvas). But if it really turns you on,
% going off the edge could mean no selection (like when you're within
% the numb radius - look at SetMenuValue), or select the slice, or pop
% up a submenu, or drag the menu around, or give more info about the
% slice, or whatever.

    /ExitProc {
        DragProc
    } def

% Calculate and set the menu value from the cursor x y location.
% Updates /PieDistance and /PieDirection instance variables.

    /SetMenuValue { % x y => - (Sets /MenuValue)
        /PieDistance
	  2 index dup mul 2 index dup mul add sqrt def
	exch atan /PieDirection exch def
	/MenuValue
	  PieDistance NumbRadius le
% It could be that when the cursor is out past the menu radius,
% nothing is selected. But I don't do it that way, because it wins
% to be able to get arbitrarily more precision by moving out further.
%	  PieDistance PieRadius gt or
	  { null }
	  { PieInitialAngle PieSliceWidth 2 div
	    Clockwise { add PieDirection sub } { sub PieDirection add } ifelse
	    NormalAngle
	    PieSliceWidth idiv } ifelse
	def
    } def

% Update the highlighted slice to show the current menu value.

    /PaintMenuValue { % - => - (Hilite current item, un-hilite prev one.)
	PaintedValue	 PaintSlice
	MenuValue        PaintSlice
	/PaintedValue	 MenuValue store
    } def

% Paint highlighting on a menu slice. If it's null, then do nothing.
% Draw an arrow, and a box around the key.

    /PaintSlice { % key => -
        dup null ne {	   			% key
	  gsave
	    MenuCanvas setcanvas
	    PieRadius dup translate

% Draw an arrow pointing out in the direction of the slice.
	    PieInitialAngle			% key PieInitialAngle
	    1 index PieSliceWidth mul		% key PieInitialAngle width
	    Clockwise { sub } { add } ifelse	% key angle
	    /ThisAngle exch NormalAngle store	% key
	    ThisAngle rotate

	    newpath
	    NumbRadius 0 moveto
	    LabelRadius .4 mul LabelRadius .1 mul lineto
	    LabelRadius .6 mul 0 lineto
	    LabelRadius .4 mul LabelRadius -.1 mul lineto
	    closepath

% Highlight the key Thing.
	    LabelRadius 0 translate
	    ThisAngle neg rotate
	    
% Factor this stuff out of here!
	    MenuKeys exch get			% thing
	    MenuFont ThingSize			% w h
	    2 copy				% w h w h
	    ThisAngle cos abs .01 lt {
	      ThisAngle 180 lt {
	        pop -2 div 0
	      } {
	        neg exch -2 div exch
	      } ifelse
	    } {
	      ThisAngle 90 gt ThisAngle 270 lt and {
	        -2 div exch neg exch
	      } {
	        -2 div exch pop 0 exch
	      } ifelse
	    } ifelse				% w h dx dy
	    % Fudge
	    1 add % exch 1 sub exch
	    4 2 roll				% dx dy h w
	    -4 2 6 2 roll
	    insetrrect rrectpath		%
	    overlayerase
            StrokeSelection {stroke} {fill} ifelse
	  grestore
        } {pop} ifelse				%
    } def

% Handle button up events. If we have children, then let the leaf
% child menu handle the button up event. Otherwise, we handle it: If
% it's a menu dictionary, then make it the child menu and show it.
% Otherwise, execute the associated menu action, and send a /popdown
% message to the root parent menu.

    /UpProc {
	% If we have a child menu, let the leaf do the UpProc.
	ChildMenu null ne { 
	  /UpProc /leafmenu self send send
	} { % No child menus. Handle it.
	  DragProc
	  MenuValue getmenuaction dup type /dicttype eq {
	    /ChildMenu exch store
	    ChildMenu /ParentMenu self put
	    /show ChildMenu send
	  } {
	    % Do our stuff
	    /domenu self send
	    % Find the parent menu
	    self {
	      dup /ParentMenu get dup null eq
	      { pop exit }
	      { exch pop } ifelse
	    } loop
	    % ^?^? (toodles [ex-tm]!)
	    /popdown exch send
	  } ifelse
	} ifelse
    } def

% Handle button down events. Remember we got a down event so 

    /DownProc { 
      /GotDown true store
    } def

% Handle damage events. Gotta make sure the highlighted slice is
% re-highlighted. 

    /DamageProc {
      MenuFont setfont initmatrix MenuCanvas setcanvas
      damagepath clipcanvas
      /paint self send
      PaintedValue PaintSlice
      newpath clipcanvas
    } def

% Construct menu event interests. Only the first menu in a chain of
% nested menus listens for mouse up events. It forwards them on to the
% leaf child menu. I'm not sure if this is the right way to do it,
% though.  The interests for button and drag events are on the frame
% buffer overlay canvas, instead of null, or menucanvas. This is
% because we want to keep tracking when the mouse is out of the
% canvas. Using fboverlay seems to be more politically correct than
% using null.
%
% Foo on political correctness. Using fboverlay does not seem to work!
% Living in sin, by using null for now. 

    /makeinterests {
        /MenuInterests [
            ParentMenu null eq {
                MenuButton /UpProc UpTransition null eventmgrinterest
	    } if
            MenuButton /DownProc DownTransition null eventmgrinterest
	    MouseDragged /DragProc  null null eventmgrinterest
	    /EnterEvent /EnterProc null MenuCanvas eventmgrinterest
	    /ExitEvent /ExitProc null MenuCanvas eventmgrinterest
	    /Damaged /DamageProc null MenuCanvas eventmgrinterest
% Kludge to refresh messed up retained menu canvases. Ssssh! Don't tell anyone.
            PointButton /DamageProc UpTransition MenuCanvas eventmgrinterest
	] def
    } def

classend def

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

% Death linear menus!
/DefaultMenu PieMenu store

% This only sort of works. Read the flipstyle comments.
systemdict /rootmenu known {
  rootmenu type /dicttype eq {
    /rootmenu /flipstyle rootmenu send store
  } if
} if

end

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%