JJServiceManager/Core/LogPanel.cs

344 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Threading;
namespace JJServiceManager
{
public enum LogLevel { Info, Success, Warning, Error, Notice }
/// <summary>로그 한 줄</summary>
public class LogLine : ObservableObject
{
private bool _isChecked;
private double _fillerHeight;
public int No { get; init; }
public DateTime Time { get; init; }
public string Text { get; init; } = "";
public LogLevel Level { get; init; }
public bool ShowTime { get; init; }
public bool IsFiller { get; init; }
public bool IsChecked { get => _isChecked; set => Set(ref _isChecked, value); }
public double FillerHeight
{
get => _fillerHeight;
set => Set(ref _fillerHeight, Math.Max(0, value));
}
public string NoText => IsFiller ? "" : No.ToString();
public string Display => IsFiller ? "" : (ShowTime ? $"[{Time:HH:mm:ss}] {Text}" : Text);
public Visibility FillerVisibility => IsFiller ? Visibility.Visible : Visibility.Collapsed;
public Visibility LineVisibility => IsFiller ? Visibility.Collapsed : Visibility.Visible;
public Brush Foreground => Level switch
{
LogLevel.Success => Brushes2.Running,
LogLevel.Warning => Brushes2.Pending,
LogLevel.Error => Brushes2.Stopped,
LogLevel.Notice => LogPanel.NoticeBrush,
_ => LogPanel.NormalBrush
};
/// <summary>복사할 때의 텍스트</summary>
public string CopyText => ShowTime ? $"[{Time:yyyy-MM-dd HH:mm:ss}] {Text}" : Text;
}
/// <summary>앞쪽 여러 줄을 한 번에 지울 수 있는 컬렉션 (변경 알림 1회)</summary>
public sealed class LogCollection : ObservableCollection<LogLine>
{
public void RemoveFirst(int count)
{
if (count <= 0) return;
CheckReentrancy();
var list = (List<LogLine>)Items;
list.RemoveRange(0, Math.Min(count, list.Count));
OnPropertyChanged(new System.ComponentModel.PropertyChangedEventArgs("Count"));
OnPropertyChanged(new System.ComponentModel.PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new System.Collections.Specialized.NotifyCollectionChangedEventArgs(
System.Collections.Specialized.NotifyCollectionChangedAction.Reset));
}
}
/// <summary>
/// 번호 + 체크박스가 있는 로그 목록.
/// - 화면클리어: 새 로그를 화면 맨 위부터 출력, 위로 스크롤하면 이전 로그가 보임, 번호는 이어짐
/// - 로그 클리어: 화면의 로그를 모두 지우고 번호를 1번부터 다시 시작
/// - 파일은 절대 지우지 않음
/// </summary>
public class LogPanel : ContentControl
{
public static readonly SolidColorBrush NormalBrush = Brushes2.Make("#212121");
public static readonly SolidColorBrush NoticeBrush = Brushes2.Make("#1565C0");
private const double LineHeight = 22;
private readonly LogCollection _lines = new LogCollection();
private readonly ListBox _list;
private readonly TextBlock _info;
private readonly DispatcherTimer _infoTimer;
private ScrollViewer _scroll;
private LogLine _filler;
private int _fillerNewLines;
private int _nextNo = 1;
private bool _scrollPending;
/// <summary>줄 앞에 시각 표시 여부 (작업 로그 = true, 파일 로그 = false)</summary>
public bool ShowTimestamp { get; set; } = true;
/// <summary>화면에 유지할 최대 줄 수 (오래된 줄부터 화면에서만 빠짐)</summary>
public int MaxLines { get; set; } = 20000;
public int Count => _lines.Count - (_filler != null ? 1 : 0);
public LogPanel()
{
var root = XamlLoader.LoadElement("LogPanel.xaml");
Content = root;
_list = root.Find<ListBox>("lstLog");
_info = root.Find<TextBlock>("txtInfo");
_list.ItemsSource = _lines;
root.Find<Button>("btnSelectAll").Click += (s, e) => SetAllChecked(true);
root.Find<Button>("btnSelectNone").Click += (s, e) => SetAllChecked(false);
root.Find<Button>("btnClearScreen").Click += (s, e) => ClearScreen();
root.Find<Button>("btnCopy").Click += (s, e) => CopyChecked();
root.Find<Button>("btnClearLog").Click += (s, e) => ClearLog();
_list.Loaded += (s, e) => _scroll ??= FindChild<ScrollViewer>(_list);
_list.SizeChanged += (s, e) => AdjustFiller();
_list.PreviewKeyDown += OnListKeyDown;
// 화면클리어용 빈 공간은 선택되지 않게 함
_list.SelectionChanged += (s, e) =>
{
if (_filler != null && e.AddedItems.Contains(_filler)) _list.SelectedItems.Remove(_filler);
};
_infoTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(4) };
_infoTimer.Tick += (s, e) => { _info.Text = ""; _infoTimer.Stop(); };
}
// ───────────────────────── 추가 ─────────────────────────
public void Add(string text, LogLevel level = LogLevel.Info)
=> AddMany(new[] { text }, level);
public void AddMany(IEnumerable<string> texts, LogLevel level = LogLevel.Info)
{
var list = texts as IList<string> ?? texts.ToList();
if (list.Count == 0) return;
if (!Dispatcher.CheckAccess())
{
Dispatcher.BeginInvoke(new Action(() => AddMany(list, level)));
return;
}
bool atBottom = IsAtBottom();
var now = DateTime.Now;
foreach (var t in list)
{
var line = new LogLine
{
No = _nextNo++,
Time = now,
Text = t ?? "",
Level = level,
ShowTime = ShowTimestamp
};
if (_filler != null)
{
_lines.Insert(_lines.Count - 1, line);
_fillerNewLines++;
_filler.FillerHeight -= LineHeight;
if (_filler.FillerHeight <= 0)
{
_lines.Remove(_filler);
_filler = null;
}
}
else
{
_lines.Add(line);
}
}
// 최대 줄 수를 여유분 이상 넘으면 화면에서만 오래된 줄을 한 번에 제거
int slack = Math.Max(500, MaxLines / 10);
if (Count > MaxLines + slack)
{
int remove = Count - MaxLines;
if (_filler != null) remove = Math.Min(remove, _lines.Count - 1);
_lines.RemoveFirst(remove);
atBottom = true;
}
if (atBottom) ScrollToEndDeferred();
}
// ───────────────────────── 버튼 동작 ─────────────────────────
/// <summary>화면클리어: 이전 로그는 위쪽에 그대로 두고, 새 로그가 화면 맨 위부터 보이도록 함</summary>
public void ClearScreen()
{
if (_filler != null)
{
_lines.Remove(_filler);
_filler = null;
}
if (_lines.Count == 0) return;
double vh = ViewportHeight();
if (vh <= 0) return; // 화면이 보이지 않는 상태
_filler = new LogLine { IsFiller = true, FillerHeight = vh };
_fillerNewLines = 0;
_lines.Add(_filler);
ScrollToEndDeferred();
ShowInfo("화면을 비웠습니다. 위로 스크롤하면 이전 로그를 볼 수 있습니다.");
}
/// <summary>로그 클리어: 화면의 로그를 모두 지우고 번호를 1번부터</summary>
public void ClearLog()
{
_lines.Clear();
_filler = null;
_fillerNewLines = 0;
_nextNo = 1;
ShowInfo("화면의 로그를 지웠습니다. 번호는 1번부터 시작합니다.");
}
public void SetAllChecked(bool value)
{
foreach (var l in _lines)
if (!l.IsFiller) l.IsChecked = value;
}
public void CopyChecked()
{
var picked = _lines.Where(l => !l.IsFiller && l.IsChecked).ToList();
if (picked.Count == 0)
{
ShowInfo("복사할 줄을 체크하세요.");
return;
}
CopyLines(picked);
}
private void CopyLines(IList<LogLine> lines)
{
var sb = new StringBuilder();
foreach (var l in lines) sb.AppendLine(l.CopyText);
for (int retry = 0; retry < 5; retry++)
{
try
{
Clipboard.SetText(sb.ToString());
ShowInfo($"{lines.Count}줄을 복사했습니다.");
return;
}
catch
{
System.Threading.Thread.Sleep(60);
}
}
ShowInfo("클립보드를 사용할 수 없습니다. 잠시 후 다시 시도하세요.");
}
private void OnListKeyDown(object sender, KeyEventArgs e)
{
// Ctrl+C: 마우스로 선택(파란 표시)한 줄 복사, 없으면 체크한 줄 복사
if (e.Key == Key.C && (Keyboard.Modifiers & ModifierKeys.Control) != 0)
{
var picked = new HashSet<LogLine>(_list.SelectedItems.Cast<LogLine>());
var sel = _lines.Where(l => !l.IsFiller && picked.Contains(l)).ToList();
if (sel.Count > 0) CopyLines(sel); else CopyChecked();
e.Handled = true;
}
// Space: 선택한 줄의 체크 토글
else if (e.Key == Key.Space)
{
var sel = _list.SelectedItems.Cast<LogLine>().Where(l => !l.IsFiller).ToList();
if (sel.Count > 0)
{
bool v = !sel.All(l => l.IsChecked);
foreach (var l in sel) l.IsChecked = v;
e.Handled = true;
}
}
}
// ───────────────────────── 스크롤 ─────────────────────────
private double ViewportHeight()
{
_scroll ??= FindChild<ScrollViewer>(_list);
if (_scroll != null && _scroll.ViewportHeight > 0) return _scroll.ViewportHeight;
return Math.Max(0, _list.ActualHeight - 4);
}
private void AdjustFiller()
{
if (_filler == null) return;
_filler.FillerHeight = ViewportHeight() - _fillerNewLines * LineHeight;
if (_filler.FillerHeight <= 0)
{
_lines.Remove(_filler);
_filler = null;
}
ScrollToEndDeferred();
}
private bool IsAtBottom()
{
_scroll ??= FindChild<ScrollViewer>(_list);
if (_scroll == null) return true;
return _scroll.VerticalOffset >= _scroll.ScrollableHeight - 2;
}
private void ScrollToEndDeferred()
{
if (_scrollPending) return;
_scrollPending = true;
Dispatcher.BeginInvoke(DispatcherPriority.Background, new Action(() =>
{
_scrollPending = false;
_scroll ??= FindChild<ScrollViewer>(_list);
_scroll?.ScrollToBottom();
}));
}
private void ShowInfo(string msg)
{
_info.Text = msg;
_infoTimer.Stop();
_infoTimer.Start();
}
private static T FindChild<T>(DependencyObject parent) where T : DependencyObject
{
if (parent == null) return null;
int n = VisualTreeHelper.GetChildrenCount(parent);
for (int i = 0; i < n; i++)
{
var c = VisualTreeHelper.GetChild(parent, i);
if (c is T t) return t;
var r = FindChild<T>(c);
if (r != null) return r;
}
return null;
}
}
}