001package polaris.sdk.ui.controls;
002import polaris.sdk.ui.*;
003import java.awt.event.KeyEvent;
004
005/** Horizontal, frameless stepper: minus / centered editable value / plus.
006 * Actions have full-height hit regions but no separate plates, fills or borders.
007 * Logical value changes immediately; only its displayed transition is animated.
008 */
009public class Spinner extends NumberField {
010 private boolean framed,editing,painted;
011 private int heldPart;
012 private double minusPulse,plusPulse,valueStart=-1;
013 private String outgoing;
014 private int direction=1;
015 public Spinner(){this(0);}
016 public Spinner(double value){super(value);w=180;h=Spacing.CONTROL_HEIGHT;decimals(0);snapMotion("minusPress",0);snapMotion("plusPress",0);}
017 /** Optional ONE outer glass surface. Bare by default; +/- never get their own backing. */
018 public Spinner framed(boolean value){framed=value;return this;}
019 public boolean framed(){return framed;}
020 @Override public Spinner number(double value){
021  String old=text;double before=number();super.number(value);
022  if(painted&&!editing&&old!=null&&!old.equals(text)){
023   outgoing=old;valueStart=clock();direction=lastValid>=before?1:-1;
024  }
025  return this;
026 }
027 @Override public Spinner range(double min,double max){super.range(min,max);return this;}
028 @Override public Spinner step(double value){super.step(value);return this;}
029 @Override public Spinner decimals(int value){super.decimals(value);return this;}
030 private float innerGap(){return Math.min(Spacing.GAP,Math.max(0,(w-40)/4));}
031 private float sideWidth(){return Math.min(Math.min(36,h),Math.max(0,(w-2*innerGap()-28)/2));}
032 private float actionX(int part){return part<0?ax:ax+w-sideWidth();}
033 private boolean available(int part){return enabled&&!readOnly&&(part<0?number()>min:number()<max);}
034 private int actionAt(float px,float py){
035  if(!contains(px,py))return 0;
036  if(px<ax+sideWidth())return -1;
037  if(px>=ax+w-sideWidth())return 1;
038  return 0;
039 }
040 @Override protected float textInsetLeft(){return sideWidth()+innerGap();}
041 @Override protected float textInsetRight(){return textInsetLeft();}
042 @Override protected float textOriginX(){
043  float content=Math.max(0,w-textInsetLeft()-textInsetRight());
044  return ax+textInsetLeft()+Math.max(0,(content-measure(text))/2)-scrollX;
045 }
046 @Override protected Rect textClip(){return new Rect(ax+textInsetLeft(),ay+4,Math.max(0,w-textInsetLeft()-textInsetRight()),Math.max(0,h-8)).intersect(clip);}
047 @Override protected boolean showCaret(){return focused()&&(editing||ui!=null&&ui.keyboardMode());}
048 private void changeBy(int part){
049  if(!available(part))return;
050  editing=false;selecting=false;
051  if(part<0)minusPulse=clock()+.105;else plusPulse=clock()+.105;
052  double previous=number();
053  number(Math.max(min,Math.min(max,previous+part*step)));
054  if(number()!=previous)changed();
055 }
056 @Override public void event(UiEvent e){
057  if(!enabled||!visible)return;
058  if(e.type==UiEvent.Type.DOWN&&e.button==1){
059   int part=actionAt(e.x,e.y);
060   if(part!=0){heldPart=available(part)?part:0;selecting=false;editing=false;changeBy(part);e.handled=true;return;}
061   editing=true;outgoing=null;valueStart=-1;
062  }
063  if(e.type==UiEvent.Type.MOVE&&heldPart!=0){e.handled=true;return;}
064  if(e.type==UiEvent.Type.UP&&e.button==1&&heldPart!=0){heldPart=0;selecting=false;e.handled=true;return;}
065  if(e.type==UiEvent.Type.KEY&&(e.key==KeyEvent.VK_UP||e.key==KeyEvent.VK_DOWN)){
066   changeBy(e.key==KeyEvent.VK_UP?1:-1);e.handled=true;return;
067  }
068  if(e.type==UiEvent.Type.TEXT||e.type==UiEvent.Type.KEY){editing=true;outgoing=null;valueStart=-1;}
069  if(e.type==UiEvent.Type.BLUR){heldPart=0;selecting=false;if(editing)super.event(e);editing=false;return;}
070  super.event(e);
071 }
072 @Override public void resetPress(){super.resetPress();heldPart=0;minusPulse=plusPulse=0;selecting=false;}
073 @Override protected void paint(Painter p){
074  metrics=p;painted=true;
075  if(framed)surface(p);
076  if(outgoing!=null&&!editing&&!theme().reducedMotion){
077   float t=MotionValue.clamp((float)((clock()-valueStart)/.16));
078   if(t<1){
079    float ease=1-(float)Math.pow(1-t,3);float travel=Math.min(9,h*.22f);
080    Rect c=textClip();p.clip(c);
081    drawValue(p,outgoing,-direction*travel*ease,1-ease);
082    drawValue(p,text,direction*travel*(1-ease),ease);
083    p.clip(clip);
084   }else{outgoing=null;paintText(p);}
085  }else{outgoing=null;paintText(p);}
086  p.clip(rect().intersect(clip));paintAction(p,-1);paintAction(p,1);p.clip(clip);
087 }
088 private void drawValue(Painter p,String value,float offset,float opacity){
089  Rect c=textClip();float x=c.x+Math.max(0,(c.w-p.textWidth(value,14))/2);
090  p.text(value,x,ay+(h-14)/2+offset,14,Theme.alpha(ink(),opacity));
091 }
092 private void paintAction(Painter p,int part){
093  boolean down=heldPart==part;
094  if(ui!=null&&down)down=actionAt(ui.pointerX(),ui.pointerY())==part;
095  float target=down||clock()<(part<0?minusPulse:plusPulse)?1:0;
096  float press=motion(part<0?"minusPress":"plusPress",target,39,.63f);
097  float hover=motion(part<0?"minusHover":"plusHover",ui!=null&&actionAt(ui.pointerX(),ui.pointerY())==part&&available(part)?1:0,26,.96f);
098  float scale=Math.max(.72f,Math.min(1.06f,1-.20f*press+.035f*hover));
099  float cx=actionX(part)+sideWidth()/2,cy=ay+h/2;
100  float extent=Math.min(5.5f,Math.max(0,sideWidth()/2-3))*scale;
101  int color=Theme.alpha(Theme.mix(theme().muted,ink(),.65f+.35f*MotionValue.clamp(hover)),available(part)?1:.30f);
102  // Both signs are analytic SDF line primitives, not font glyphs or button rectangles.
103  p.line(cx-extent,cy,cx+extent,cy,1.5f*scale,color);
104  if(part>0)p.line(cx,cy-extent,cx,cy+extent,1.5f*scale,color);
105 }
106}