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 _hints = new Dictionary(); private readonly Dictionary _handlers = new Dictionary(); 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; } } }