2026-03-30 17:09:29 +09:00

254 lines
9.5 KiB
C#

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace GUI.Dialog.Extender
{
[ProvideProperty("Hint", typeof(Control))]
public sealed class HintProvider : Component, IExtenderProvider
{
private readonly ToolTip _tip;
private readonly Dictionary<Control, string> _hints = new Dictionary<Control, string>();
private readonly Dictionary<Control, EventHandlerGroup> _handlers = new Dictionary<Control, EventHandlerGroup>();
private readonly Timer _lingerTimer = new Timer() { Interval = 250 }; // 지연 닫기
private readonly Timer _debounceTimer = new Timer() { Interval = 120 }; // 재표시 디바운스
private Point _lastPt;
private string _lastText;
private Control _lastCtrl;
private bool _pendingReshow;
private bool _suspend = true;
// 기본 동작 설정(원하면 public set 가능)
[DefaultValue(true)] public bool IsBalloon { get => _tip.IsBalloon; set => _tip.IsBalloon = value; }
[DefaultValue(true)] public bool ShowAlways { get => _tip.ShowAlways; set => _tip.ShowAlways = value; }
[DefaultValue(8000)] public int AutoPopDelay { get => _tip.AutoPopDelay; set => _tip.AutoPopDelay = value; }
[DefaultValue(200)] public int InitialDelay { get => _tip.InitialDelay; set => _tip.InitialDelay = value; }
[DefaultValue(100)] public int ReshowDelay { get => _tip.ReshowDelay; set => _tip.ReshowDelay = value; }
[DefaultValue("")] public string Title { get; set; } = "";
[DefaultValue(ToolTipIcon.None)] public ToolTipIcon Icon { get; set; } = ToolTipIcon.None;
[DefaultValue(380)] public int MaxWidth { get; set; } = 380; // 멀티라인 폭 제한
public HintProvider()
{
_tip = new ToolTip { IsBalloon = true, ShowAlways = true, AutoPopDelay = 8000, InitialDelay = 200, ReshowDelay = 100 };
_tip.Popup += Tip_Popup;
_lingerTimer.Tick += (s, e) =>
{
if (_lastCtrl == null) { _lingerTimer.Stop(); return; }
// 마우스가 컨트롤 위에 있거나(=재표시 가능성) 캐럿 이동 중이면 유지
var screen = Cursor.Position;
var rect = _lastCtrl.RectangleToScreen(_lastCtrl.ClientRectangle);
if (!rect.Contains(screen)) {
_lingerTimer.Stop();
_tip.Hide(_lastCtrl);
}
};
_debounceTimer.Tick += (s, e) =>
{
_debounceTimer.Stop();
if (_pendingReshow && _lastCtrl != null) {
InternalShow(_lastCtrl, _lastText, _lastPt);
_pendingReshow = false;
}
};
}
public HintProvider(IContainer container) : this()
{
container?.Add(this);
}
public bool CanExtend(object extendee) => extendee is Control;
// 확장 속성: Hint
public string GetHint(Control control)
=> control != null && _hints.TryGetValue(control, out var v) ? v : null;
public void SetHint(Control control, string value)
{
if (control == null) return;
var had = _hints.ContainsKey(control);
if (string.IsNullOrWhiteSpace(value)) {
if (had) {
Unhook(control);
_hints.Remove(control);
_tip.SetToolTip(control, null);
}
return;
}
_hints[control] = value;
_tip.SetToolTip(control, value); // 기본값(마우스 hover 시)도 동작하도록 세팅
if (!had) Hook(control);
}
// 팝업 시 크기 조절(멀티라인)
private void Tip_Popup(object sender, PopupEventArgs e)
{
var txt = _tip.GetToolTip(e.AssociatedControl);
if (string.IsNullOrEmpty(txt)) { e.ToolTipSize = new Size(80, 24); return; }
var f = new Font("Segoe UI", 9f);
var sz = TextRenderer.MeasureText(txt, f, new Size(MaxWidth, int.MaxValue), TextFormatFlags.WordBreak);
var w = Math.Max(80, Math.Min(MaxWidth, sz.Width + 16));
var h = Math.Max(24, Math.Min(240, sz.Height + 16));
e.ToolTipSize = new Size(w, h);
if (!string.IsNullOrEmpty(Title) || Icon != ToolTipIcon.None)
_tip.ToolTipTitle = Title;
_tip.ToolTipIcon = Icon;
}
private void Hook(Control c)
{
var eg = new EventHandlerGroup();
//eg.Enter = (s, e) => ShowNow(c);
eg.MouseEnter = (s, e) => ShowNow(c);
// ❌ 즉시 Hide 금지 —> ⭕ 지연 닫기
eg.Leave = (s, e) => _lingerTimer.Start();
eg.MouseLeave = (s, e) => _lingerTimer.Start();
c.Enter += eg.Enter;
c.MouseEnter += eg.MouseEnter;
c.Leave += eg.Leave;
c.MouseLeave += eg.MouseLeave;
if (c is TextBoxBase tb) {
eg.MouseMove = (s, e) => { if (_tip.Active && IsMouseOver(tb)) ScheduleReshow(tb); };
eg.TextChanged = (s, e) => { if (_tip.Active && IsMouseOver(tb)) ScheduleReshow(tb); };
tb.MouseMove += eg.MouseMove;
tb.TextChanged += eg.TextChanged;
}
_handlers[c] = eg;
}
private void ScheduleReshow(TextBoxBase tb)
{
if (!_hints.TryGetValue(tb, out var txt) || string.IsNullOrWhiteSpace(txt)) return;
var pt = GetCaretPointSafe(tb);
// 위치 변화가 미미하면 무시(진동 방지)
if (Math.Abs(pt.X - _lastPt.X) + Math.Abs(pt.Y - _lastPt.Y) < 3 && ReferenceEquals(tb, _lastCtrl))
return;
_lastCtrl = tb;
_lastText = txt;
_lastPt = pt;
_pendingReshow = true;
_debounceTimer.Stop();
_debounceTimer.Start();
}
private void ShowNow(Control c)
{
if (_suspend) return; // 폼 뜨기 전에는 아예 표시 금지
if (!IsMouseOver(c)) return; // 호버가 아닐 때도 표시 금지
if (!_hints.TryGetValue(c, out var txt) || string.IsNullOrWhiteSpace(txt)) return;
_lastCtrl = c;
_lastText = txt;
_lastPt = (c is TextBoxBase tb) ? GetCaretPointSafe(tb) : new Point(c.ClientSize.Width / 2, c.ClientSize.Height + 6);
InternalShow(c, txt, _lastPt);
}
public void ActivateOn(Form form)
{
if (form == null) return;
form.Shown += (_, __) => _suspend = false; // 폼 표시 후 활성화
}
private static bool IsMouseOver(Control c)
{
var screen = Cursor.Position;
var rect = c.RectangleToScreen(c.ClientRectangle);
return rect.Contains(screen);
}
private void InternalShow(Control c, string text, Point pt)
{
// 포인트를 클라이언트 범위로 clamp (음수/과도한 값 방지)
pt = new Point(
Math.Max(0, Math.Min(c.ClientSize.Width - 4, pt.X)),
Math.Max(0, Math.Min(c.ClientSize.Height - 4, pt.Y))
);
_lingerTimer.Stop(); // 표시 중에는 닫기 타이머 중단
_tip.Show(text, c, pt, AutoPopDelay);
}
private Point GetCaretPointSafe(TextBoxBase tb)
{
var idx = Math.Max(0, Math.Min(tb.TextLength, tb.SelectionStart));
var pt = tb.GetPositionFromCharIndex(idx);
// 줄 높이만큼 아래로
var h = (int)Math.Round(tb.Font.GetHeight(tb.CreateGraphics()));
pt.Offset(0, h);
return pt;
}
private void Unhook(Control c)
{
if (!_handlers.TryGetValue(c, out var eg)) return;
c.Enter -= eg.Enter;
c.MouseEnter -= eg.MouseEnter;
c.Leave -= eg.Leave;
c.MouseLeave -= eg.MouseLeave;
if (c is TextBoxBase tb) {
tb.MouseMove -= eg.MouseMove;
tb.TextChanged -= eg.TextChanged;
}
_handlers.Remove(c);
}
private void ShowNearCaret(TextBoxBase tb)
{
if (!_hints.TryGetValue(tb, out var txt) || string.IsNullOrWhiteSpace(txt))
return;
try {
var idx = Math.Max(0, tb.SelectionStart);
var pt = tb.GetPositionFromCharIndex(idx);
// 줄 높이만큼 아래로
pt.Offset(0, (int)Math.Round(tb.Font.GetHeight(tb.CreateGraphics())));
_tip.Show(txt, tb, pt, AutoPopDelay);
} catch {
// 폰트/인덱스 이슈 등은 안전하게 무시
_tip.Show(txt, tb, tb.ClientSize.Width / 2, tb.ClientSize.Height + 6, AutoPopDelay);
}
}
protected override void Dispose(bool disposing)
{
if (disposing) {
foreach (var kv in _handlers.Keys)
Unhook(kv);
_tip?.Dispose();
}
base.Dispose(disposing);
}
private sealed class EventHandlerGroup
{
public EventHandler Enter, Leave, MouseEnter, MouseLeave;
public MouseEventHandler MouseMove;
public EventHandler TextChanged;
}
}
}