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}