unit FlatSpinEdit;

{ Custom integer spin edit with canvas rendering.
  Use instead of TSpinEdit where native widgetset styling is undesirable. }

{$mode objfpc}{$H+}

interface

uses
  Classes, SysUtils, Controls, Graphics, Forms, LCLType, LMessages, Types,
  Clipbrd, Math, AppTheme;

type
  TFlatSpinPart = (fspNone, fspEdit, fspUp, fspDown);

  TFlatSpinEdit = class(TCustomControl)
  private
    FValue: Integer;
    FMinValue: Integer;
    FMaxValue: Integer;
    FIncrement: Integer;
    FText: string;
    FCaretPos: Integer;
    FSelStart: Integer;
    FSelLen: Integer;
    FHot: Boolean;
    FEditing: Boolean;
    FDragging: Boolean;
    FHotPart: TFlatSpinPart;
    FDownPart: TFlatSpinPart;
    FOnChange: TNotifyEvent;
    FClrOuterBG: TColor;
    FClrBG: TColor;
    FClrBGHot: TColor;
    FClrBorder: TColor;
    FClrText: TColor;
    FClrTextDim: TColor;
    FClrSelBG: TColor;
    FClrSelText: TColor;
    FClrBtnHot: TColor;
    FClrBtnDown: TColor;
    FClrBtnText: TColor;
    function DpiScale(V: Integer): Integer;
    function ButtonWidth: Integer;
    function EditRect: TRect;
    function UpRect: TRect;
    function DownRect: TRect;
    function PartAt(X, Y: Integer): TFlatSpinPart;
    function HasSelection: Boolean;
    function SelFirst: Integer;
    function SelAfter: Integer;
    function CaretPosAtX(X: Integer): Integer;
    procedure SetValue(V: Integer);
    procedure SetMinValue(V: Integer);
    procedure SetMaxValue(V: Integer);
    procedure SetIncrement(V: Integer);
    procedure SetTextInternal(const S: string; AFireChange: Boolean);
    procedure DeleteSelection;
    procedure InsertText(const S: string);
    procedure UpdateValueFromText;
    procedure CommitText;
    procedure StepValue(Dir: Integer);
    procedure DoChange;
    procedure AppUserInput(Sender: TObject; Msg: Cardinal);
  protected
    procedure Paint; override;
    procedure MouseEnter; override;
    procedure MouseLeave; override;
    procedure MouseDown(Button: TMouseButton; Shift: TShiftState;
      X, Y: Integer); override;
    procedure MouseMove(Shift: TShiftState; X, Y: Integer); override;
    procedure MouseUp(Button: TMouseButton; Shift: TShiftState;
      X, Y: Integer); override;
    procedure DblClick; override;
    procedure KeyDown(var Key: Word; Shift: TShiftState); override;
    procedure KeyPress(var Key: char); override;
    procedure DoEnter; override;
    procedure DoExit; override;
    function DoMouseWheelUp(Shift: TShiftState; MousePos: TPoint): Boolean; override;
    function DoMouseWheelDown(Shift: TShiftState; MousePos: TPoint): Boolean; override;
  public
    constructor Create(AOwner: TComponent); override;
    destructor Destroy; override;
    procedure SetAppTheme(const T: TAppTheme);
    procedure SelectAll;

    property Value: Integer read FValue write SetValue;
    property MinValue: Integer read FMinValue write SetMinValue;
    property MaxValue: Integer read FMaxValue write SetMaxValue;
    property Increment: Integer read FIncrement write SetIncrement;
    property OnChange: TNotifyEvent read FOnChange write FOnChange;
    property Align;
    property Anchors;
    property Enabled;
    property Font;
    property ParentShowHint;
    property PopupMenu;
    property ShowHint;
    property TabOrder;
    property TabStop;
    property Tag;
    property Visible;
    property OnEnter;
    property OnExit;
    property OnKeyDown;
    property OnKeyPress;
  end;

implementation

const
  BASE_PAD_X = 8;
  BASE_BTN_W = 22;

constructor TFlatSpinEdit.Create(AOwner: TComponent);
begin
  inherited Create(AOwner);
  ControlStyle := ControlStyle + [csOpaque, csCaptureMouse, csClickEvents];
  Width := 96;
  Height := DpiScale(26);
  TabStop := True;
  Cursor := crIBeam;

  Font.Name := 'Courier New';
  Font.Size := 9;

  FMinValue := 0;
  FMaxValue := 100;
  FIncrement := 1;
  FValue := 0;
  FText := '0';
  FCaretPos := Length(FText);
  FSelStart := FCaretPos;

  FClrOuterBG := TColor($00181818);
  FClrBG := TColor($001A1A1A);
  FClrBGHot := TColor($00202020);
  FClrBorder := TColor($00444444);
  FClrText := TColor($00E0E0E0);
  FClrTextDim := TColor($00666666);
  FClrSelBG := TColor($000C3010);
  FClrSelText := TColor($0000FF88);
  FClrBtnHot := TColor($000C3010);
  FClrBtnDown := TColor($00183618);
  FClrBtnText := TColor($0000FF88);
  Application.AddOnUserInputHandler(@AppUserInput);
end;

destructor TFlatSpinEdit.Destroy;
begin
  Application.RemoveOnUserInputHandler(@AppUserInput);
  inherited Destroy;
end;

function TFlatSpinEdit.DpiScale(V: Integer): Integer;
begin
  Result := MulDiv(V, Screen.PixelsPerInch, 96);
  if (V > 0) and (Result < 1) then
    Result := 1;
end;

function TFlatSpinEdit.ButtonWidth: Integer;
begin
  Result := DpiScale(BASE_BTN_W);
  if Result > Width div 2 then
    Result := Width div 2;
end;

function TFlatSpinEdit.EditRect: TRect;
begin
  Result := Rect(0, 0, Width - ButtonWidth, Height);
end;

function TFlatSpinEdit.UpRect: TRect;
begin
  Result := Rect(Width - ButtonWidth, 0, Width, Height div 2);
end;

function TFlatSpinEdit.DownRect: TRect;
begin
  Result := Rect(Width - ButtonWidth, Height div 2, Width, Height);
end;

function TFlatSpinEdit.PartAt(X, Y: Integer): TFlatSpinPart;
begin
  if not PtInRect(ClientRect, Point(X, Y)) then
    Exit(fspNone);
  if PtInRect(UpRect, Point(X, Y)) then
    Exit(fspUp);
  if PtInRect(DownRect, Point(X, Y)) then
    Exit(fspDown);
  Result := fspEdit;
end;

procedure TFlatSpinEdit.SetAppTheme(const T: TAppTheme);
begin
  FClrOuterBG := T.Panel;
  FClrBG := T.BtnNorm;
  FClrBGHot := T.BtnHot;
  FClrBorder := T.Border;
  FClrText := T.Text;
  FClrTextDim := T.TextDim;
  FClrSelBG := T.BtnActive;
  FClrSelText := T.BtnTextActive;
  FClrBtnHot := T.BtnActive;
  FClrBtnDown := T.SliderTrackFill;
  FClrBtnText := T.BtnTextActive;
  Font.Color := T.Text;
  Invalidate;
end;

function TFlatSpinEdit.HasSelection: Boolean;
begin
  Result := FSelLen <> 0;
end;

function TFlatSpinEdit.SelFirst: Integer;
begin
  if FSelLen >= 0 then
    Result := FSelStart
  else
    Result := FSelStart + FSelLen;
end;

function TFlatSpinEdit.SelAfter: Integer;
begin
  if FSelLen >= 0 then
    Result := FSelStart + FSelLen
  else
    Result := FSelStart;
end;

function TFlatSpinEdit.CaretPosAtX(X: Integer): Integer;
var
  I, Pad, RelX, BestDist, Dist: Integer;
begin
  Pad := DpiScale(BASE_PAD_X);
  RelX := X - Pad;
  Result := 0;
  BestDist := Abs(RelX);
  for I := 1 to Length(FText) do
  begin
    Dist := Abs(RelX - Canvas.TextWidth(Copy(FText, 1, I)));
    if Dist < BestDist then
    begin
      BestDist := Dist;
      Result := I;
    end;
  end;
end;

procedure TFlatSpinEdit.SetTextInternal(const S: string; AFireChange: Boolean);
begin
  FText := S;
  FCaretPos := EnsureRange(FCaretPos, 0, Length(FText));
  FSelStart := FCaretPos;
  FSelLen := 0;
  Invalidate;
  if AFireChange then
    DoChange;
end;

procedure TFlatSpinEdit.SetValue(V: Integer);
begin
  V := EnsureRange(V, FMinValue, FMaxValue);
  if (FValue = V) and (FText = IntToStr(V)) then Exit;
  FValue := V;
  FText := IntToStr(FValue);
  FCaretPos := Length(FText);
  FSelStart := FCaretPos;
  FSelLen := 0;
  Invalidate;
  DoChange;
end;

procedure TFlatSpinEdit.SetMinValue(V: Integer);
begin
  FMinValue := V;
  if FMaxValue < FMinValue then
    FMaxValue := FMinValue;
  SetValue(FValue);
end;

procedure TFlatSpinEdit.SetMaxValue(V: Integer);
begin
  FMaxValue := V;
  if FMinValue > FMaxValue then
    FMinValue := FMaxValue;
  SetValue(FValue);
end;

procedure TFlatSpinEdit.SetIncrement(V: Integer);
begin
  FIncrement := Max(1, Abs(V));
end;

procedure TFlatSpinEdit.DeleteSelection;
var
  A, B: Integer;
begin
  if not HasSelection then Exit;
  A := SelFirst;
  B := SelAfter;
  Delete(FText, A + 1, B - A);
  FCaretPos := A;
  FSelStart := FCaretPos;
  FSelLen := 0;
end;

procedure TFlatSpinEdit.InsertText(const S: string);
begin
  if S = '' then Exit;
  DeleteSelection;
  Insert(S, FText, FCaretPos + 1);
  Inc(FCaretPos, Length(S));
  FSelStart := FCaretPos;
  FSelLen := 0;
  UpdateValueFromText;
  Invalidate;
end;

procedure TFlatSpinEdit.UpdateValueFromText;
var
  Code, V: Integer;
begin
  Val(FText, V, Code);
  if (Code <> 0) or (V < FMinValue) or (V > FMaxValue) then Exit;
  if FValue = V then Exit;
  FValue := V;
  DoChange;
end;

procedure TFlatSpinEdit.CommitText;
var
  Code, V: Integer;
begin
  if (FText = '') or (FText = '-') then
  begin
    SetValue(FValue);
    Exit;
  end;
  Val(FText, V, Code);
  if Code <> 0 then
    V := FValue;
  SetValue(V);
end;

procedure TFlatSpinEdit.StepValue(Dir: Integer);
begin
  CommitText;
  SetValue(FValue + Dir * FIncrement);
end;

procedure TFlatSpinEdit.DoChange;
begin
  if Assigned(FOnChange) then
    FOnChange(Self);
end;

procedure TFlatSpinEdit.AppUserInput(Sender: TObject; Msg: Cardinal);
var
  P: TPoint;
begin
  if not FEditing then Exit;
  case Msg of
    LM_LBUTTONDOWN, LM_RBUTTONDOWN, LM_MBUTTONDOWN: ;
  else
    Exit;
  end;

  P := ScreenToClient(Mouse.CursorPos);
  if PtInRect(ClientRect, P) then Exit;

  CommitText;
  FDragging := False;
  MouseCapture := False;
  FEditing := False;
  FHot := False;
  FHotPart := fspNone;
  FDownPart := fspNone;
  FSelLen := 0;
  Invalidate;
end;

procedure TFlatSpinEdit.Paint;
var
  R, ER, UR, DR, TextR, SelR: TRect;
  Pad, TextY, CaretX, A, B, MidX, MidY, S: Integer;
  BeforeSel, SelText: string;

  procedure FillPart(const AR: TRect; Part: TFlatSpinPart);
  begin
    if not Enabled then
      Canvas.Brush.Color := FClrBG
    else if FDownPart = Part then
      Canvas.Brush.Color := FClrBtnDown
    else if FHotPart = Part then
      Canvas.Brush.Color := FClrBtnHot
    else if FHot or FEditing or Focused then
      Canvas.Brush.Color := FClrBGHot
    else
      Canvas.Brush.Color := FClrBG;
    Canvas.Pen.Style := psClear;
    Canvas.Brush.Style := bsSolid;
    Canvas.FillRect(AR);
  end;

  procedure DrawArrow(const AR: TRect; Up: Boolean);
  var
    P: array[0..2] of TPoint;
    Size: Integer;
  begin
    Size := Max(3, DpiScale(4));
    MidX := (AR.Left + AR.Right) div 2;
    MidY := (AR.Top + AR.Bottom) div 2;
    if Up then
    begin
      P[0] := Point(MidX, MidY - Size div 2);
      P[1] := Point(MidX - Size, MidY + Size div 2);
      P[2] := Point(MidX + Size, MidY + Size div 2);
    end
    else
    begin
      P[0] := Point(MidX - Size, MidY - Size div 2);
      P[1] := Point(MidX + Size, MidY - Size div 2);
      P[2] := Point(MidX, MidY + Size div 2);
    end;
    if Enabled then
      Canvas.Brush.Color := FClrBtnText
    else
      Canvas.Brush.Color := FClrTextDim;
    Canvas.Pen.Style := psClear;
    Canvas.Polygon(P);
  end;
begin
  R := ClientRect;
  ER := EditRect;
  UR := UpRect;
  DR := DownRect;

  Canvas.Brush.Color := FClrOuterBG;
  Canvas.Brush.Style := bsSolid;
  Canvas.Pen.Style := psClear;
  Canvas.FillRect(R);

  if FHot or FEditing or Focused then
    Canvas.Brush.Color := FClrBGHot
  else
    Canvas.Brush.Color := FClrBG;
  Canvas.Pen.Color := FClrBorder;
  Canvas.Pen.Style := psSolid;
  Canvas.Pen.Width := 1;
  Canvas.Rectangle(R.Left, R.Top, R.Right, R.Bottom);

  FillPart(UR, fspUp);
  FillPart(DR, fspDown);
  Canvas.Pen.Color := FClrBorder;
  Canvas.Pen.Style := psSolid;
  Canvas.Line(UR.Left, 1, UR.Left, Height - 1);
  Canvas.Line(UR.Left, DR.Top, Width - 1, DR.Top);
  DrawArrow(UR, True);
  DrawArrow(DR, False);

  Canvas.Font.Assign(Font);
  if Enabled then
    Canvas.Font.Color := FClrText
  else
    Canvas.Font.Color := FClrTextDim;
  Pad := DpiScale(BASE_PAD_X);
  TextR := Rect(ER.Left + Pad, ER.Top, ER.Right - DpiScale(3), ER.Bottom);
  TextY := (Height - Canvas.TextHeight('Ag')) div 2;

  if HasSelection then
  begin
    A := SelFirst;
    B := SelAfter;
    BeforeSel := Copy(FText, 1, A);
    SelText := Copy(FText, A + 1, B - A);
    SelR := Rect(TextR.Left + Canvas.TextWidth(BeforeSel), TextY,
      TextR.Left + Canvas.TextWidth(BeforeSel + SelText), TextY + Canvas.TextHeight('Ag'));
    Canvas.Brush.Color := FClrSelBG;
    Canvas.Pen.Style := psClear;
    Canvas.FillRect(SelR);
  end;

  Canvas.Brush.Style := bsClear;
  Canvas.TextRect(TextR, TextR.Left, TextY, FText);

  if HasSelection then
  begin
    A := SelFirst;
    B := SelAfter;
    BeforeSel := Copy(FText, 1, A);
    SelText := Copy(FText, A + 1, B - A);
    Canvas.Font.Color := FClrSelText;
    Canvas.TextOut(TextR.Left + Canvas.TextWidth(BeforeSel), TextY, SelText);
  end;

  if FEditing and not HasSelection then
  begin
    S := EnsureRange(FCaretPos, 0, Length(FText));
    CaretX := TextR.Left + Canvas.TextWidth(Copy(FText, 1, S));
    Canvas.Pen.Color := FClrText;
    Canvas.Pen.Style := psSolid;
    Canvas.Pen.Width := 1;
    Canvas.Line(CaretX, TextY, CaretX, TextY + Canvas.TextHeight('Ag'));
  end;
end;

procedure TFlatSpinEdit.MouseEnter;
begin
  inherited;
  FHot := True;
  Invalidate;
end;

procedure TFlatSpinEdit.MouseLeave;
begin
  inherited;
  FHot := False;
  FHotPart := fspNone;
  Invalidate;
end;

procedure TFlatSpinEdit.MouseDown(Button: TMouseButton; Shift: TShiftState;
  X, Y: Integer);
var
  Pos: Integer;
begin
  inherited;
  if (Button <> mbLeft) or not Enabled then Exit;
  SetFocus;
  FEditing := True;
  FDownPart := PartAt(X, Y);
  FHotPart := FDownPart;
  if FDownPart in [fspUp, fspDown] then
  begin
    FDragging := False;
    MouseCapture := True;
    if FDownPart = fspUp then
      StepValue(1)
    else
      StepValue(-1);
  end
  else
  begin
    FDragging := True;
    MouseCapture := True;
    Pos := CaretPosAtX(X);
    FCaretPos := Pos;
    FSelStart := Pos;
    FSelLen := 0;
  end;
  Invalidate;
end;

procedure TFlatSpinEdit.MouseMove(Shift: TShiftState; X, Y: Integer);
var
  Pos: Integer;
  Part: TFlatSpinPart;
begin
  inherited;
  Part := PartAt(X, Y);
  if FHotPart <> Part then
  begin
    FHotPart := Part;
    if Part = fspEdit then
      Cursor := crIBeam
    else
      Cursor := crDefault;
    Invalidate;
  end;

  if not FDragging then Exit;
  Pos := CaretPosAtX(X);
  FCaretPos := Pos;
  FSelLen := Pos - FSelStart;
  Invalidate;
end;

procedure TFlatSpinEdit.MouseUp(Button: TMouseButton; Shift: TShiftState;
  X, Y: Integer);
begin
  inherited;
  if Button <> mbLeft then Exit;
  FDragging := False;
  FDownPart := fspNone;
  MouseCapture := False;
  Invalidate;
end;

procedure TFlatSpinEdit.DblClick;
begin
  inherited;
  if FHotPart <> fspEdit then Exit;
  FEditing := True;
  SelectAll;
end;

procedure TFlatSpinEdit.KeyDown(var Key: Word; Shift: TShiftState);
begin
  inherited;
  if not Enabled then Exit;
  FEditing := True;
  case Key of
    VK_LEFT:
      begin
        FCaretPos := EnsureRange(FCaretPos - 1, 0, Length(FText));
        FSelStart := FCaretPos;
        FSelLen := 0;
        UpdateValueFromText;
        Invalidate;
        Key := 0;
      end;
    VK_RIGHT:
      begin
        FCaretPos := EnsureRange(FCaretPos + 1, 0, Length(FText));
        FSelStart := FCaretPos;
        FSelLen := 0;
        UpdateValueFromText;
        Invalidate;
        Key := 0;
      end;
    VK_HOME:
      begin
        FCaretPos := 0;
        FSelStart := FCaretPos;
        FSelLen := 0;
        Invalidate;
        Key := 0;
      end;
    VK_END:
      begin
        FCaretPos := Length(FText);
        FSelStart := FCaretPos;
        FSelLen := 0;
        Invalidate;
        Key := 0;
      end;
    VK_UP:
      begin
        StepValue(1);
        Key := 0;
      end;
    VK_DOWN:
      begin
        StepValue(-1);
        Key := 0;
      end;
    VK_RETURN:
      begin
        CommitText;
        SelectAll;
        Key := 0;
      end;
    VK_ESCAPE:
      begin
        SetTextInternal(IntToStr(FValue), False);
        Key := 0;
      end;
    VK_BACK:
      begin
        if HasSelection then
          DeleteSelection
        else if FCaretPos > 0 then
        begin
          Delete(FText, FCaretPos, 1);
          Dec(FCaretPos);
        end;
        FSelStart := FCaretPos;
        FSelLen := 0;
        Invalidate;
        Key := 0;
      end;
    VK_DELETE:
      begin
        if HasSelection then
          DeleteSelection
        else if FCaretPos < Length(FText) then
          Delete(FText, FCaretPos + 1, 1);
        FSelStart := FCaretPos;
        FSelLen := 0;
        Invalidate;
        Key := 0;
      end;
    Ord('A'):
      if ssCtrl in Shift then
      begin
        SelectAll;
        Key := 0;
      end;
    Ord('C'):
      if (ssCtrl in Shift) and HasSelection then
      begin
        Clipboard.AsText := Copy(FText, SelFirst + 1, SelAfter - SelFirst);
        Key := 0;
      end;
    Ord('X'):
      if (ssCtrl in Shift) and HasSelection then
      begin
        Clipboard.AsText := Copy(FText, SelFirst + 1, SelAfter - SelFirst);
        DeleteSelection;
        UpdateValueFromText;
        Invalidate;
        Key := 0;
      end;
    Ord('V'):
      if ssCtrl in Shift then
      begin
        InsertText(Clipboard.AsText);
        Key := 0;
      end;
  end;
end;

procedure TFlatSpinEdit.KeyPress(var Key: char);
begin
  inherited;
  if not Enabled then Exit;
  FEditing := True;
  if Key in ['0'..'9'] then
  begin
    InsertText(Key);
    Key := #0;
  end
  else if (Key = '-') and (FMinValue < 0) and (FCaretPos = 0) and (Pos('-', FText) = 0) then
  begin
    InsertText(Key);
    Key := #0;
  end
  else if Key >= #32 then
    Key := #0;
end;

procedure TFlatSpinEdit.DoEnter;
begin
  inherited;
  FEditing := True;
  Invalidate;
end;

procedure TFlatSpinEdit.DoExit;
begin
  inherited;
  CommitText;
  FEditing := False;
  FHot := False;
  FHotPart := fspNone;
  FDownPart := fspNone;
  FSelLen := 0;
  Invalidate;
end;

function TFlatSpinEdit.DoMouseWheelUp(Shift: TShiftState; MousePos: TPoint): Boolean;
begin
  if Enabled then
    StepValue(1);
  Result := True;
end;

function TFlatSpinEdit.DoMouseWheelDown(Shift: TShiftState; MousePos: TPoint): Boolean;
begin
  if Enabled then
    StepValue(-1);
  Result := True;
end;

procedure TFlatSpinEdit.SelectAll;
begin
  FSelStart := 0;
  FSelLen := Length(FText);
  FCaretPos := Length(FText);
  Invalidate;
end;

end.
