[Phase49] ChatWindow.Sending.cs 분리 — MessageEdit, StreamingTimers 분할
변경 파일: - ChatWindow.Sending.cs: 720 → ~300줄 (전송 섹션만 유지) - ChatWindow.MessageEdit.cs (신규): ApplyMessageEntryAnimation, _isEditing 필드, EnterEditMode, SubmitEditAsync (~180줄) - ChatWindow.StreamingTimers.cs (신규): StopAiIconPulse, CursorTimer_Tick, ElapsedTimer_Tick, TypingTimer_Tick (~58줄) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
201
src/AxCopilot/Views/ChatWindow.MessageEdit.cs
Normal file
201
src/AxCopilot/Views/ChatWindow.MessageEdit.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
// ─── 메시지 등장 애니메이션 ──────────────────────────────────────────
|
||||
|
||||
private static void ApplyMessageEntryAnimation(FrameworkElement element)
|
||||
{
|
||||
element.Opacity = 0;
|
||||
element.RenderTransform = new TranslateTransform(0, 16);
|
||||
element.BeginAnimation(UIElement.OpacityProperty,
|
||||
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350))
|
||||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
|
||||
((TranslateTransform)element.RenderTransform).BeginAnimation(
|
||||
TranslateTransform.YProperty,
|
||||
new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400))
|
||||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
|
||||
}
|
||||
|
||||
// ─── 메시지 편집 ──────────────────────────────────────────────────────
|
||||
|
||||
private bool _isEditing; // 편집 모드 중복 방지
|
||||
|
||||
private void EnterEditMode(StackPanel wrapper, string originalText)
|
||||
{
|
||||
if (_isStreaming || _isEditing) return;
|
||||
_isEditing = true;
|
||||
|
||||
// wrapper 위치(인덱스) 기억
|
||||
var idx = MessagePanel.Children.IndexOf(wrapper);
|
||||
if (idx < 0) { _isEditing = false; return; }
|
||||
|
||||
// 편집 UI 생성
|
||||
var editPanel = new StackPanel
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
MaxWidth = 540,
|
||||
Margin = wrapper.Margin,
|
||||
};
|
||||
|
||||
var editBox = new TextBox
|
||||
{
|
||||
Text = originalText,
|
||||
FontSize = 13.5,
|
||||
Foreground = ThemeResourceHelper.Primary(this),
|
||||
Background = ThemeResourceHelper.ItemBg(this),
|
||||
CaretBrush = ThemeResourceHelper.Accent(this),
|
||||
BorderBrush = ThemeResourceHelper.Accent(this),
|
||||
BorderThickness = new Thickness(1.5),
|
||||
Padding = new Thickness(14, 10, 14, 10),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
AcceptsReturn = false,
|
||||
MaxHeight = 200,
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
};
|
||||
// 둥근 모서리
|
||||
var editBorder = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(14),
|
||||
Child = editBox,
|
||||
ClipToBounds = true,
|
||||
};
|
||||
editPanel.Children.Add(editBorder);
|
||||
|
||||
// 버튼 바
|
||||
var btnBar = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Margin = new Thickness(0, 6, 0, 0),
|
||||
};
|
||||
|
||||
var accentBrush = ThemeResourceHelper.Accent(this);
|
||||
var secondaryBrush = ThemeResourceHelper.Secondary(this);
|
||||
|
||||
// 취소 버튼
|
||||
var cancelBtn = new Button
|
||||
{
|
||||
Content = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryBrush },
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Cursor = Cursors.Hand,
|
||||
Padding = new Thickness(12, 5, 12, 5),
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
};
|
||||
cancelBtn.Click += (_, _) =>
|
||||
{
|
||||
_isEditing = false;
|
||||
if (idx >= 0 && idx < MessagePanel.Children.Count)
|
||||
MessagePanel.Children[idx] = wrapper; // 원래 버블 복원
|
||||
};
|
||||
btnBar.Children.Add(cancelBtn);
|
||||
|
||||
// 전송 버튼
|
||||
var sendBtn = new Button
|
||||
{
|
||||
Cursor = Cursors.Hand,
|
||||
Padding = new Thickness(0),
|
||||
};
|
||||
sendBtn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse(
|
||||
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
|
||||
"<Border Background='" + (accentBrush is SolidColorBrush sb ? sb.Color.ToString() : "#4B5EFC") + "' CornerRadius='8' Padding='12,5,12,5'>" +
|
||||
"<TextBlock Text='전송' FontSize='12' Foreground='White' HorizontalAlignment='Center'/>" +
|
||||
"</Border></ControlTemplate>");
|
||||
sendBtn.Click += (_, _) =>
|
||||
{
|
||||
var newText = editBox.Text.Trim();
|
||||
if (!string.IsNullOrEmpty(newText))
|
||||
_ = SubmitEditAsync(idx, newText);
|
||||
};
|
||||
btnBar.Children.Add(sendBtn);
|
||||
|
||||
editPanel.Children.Add(btnBar);
|
||||
|
||||
// 기존 wrapper → editPanel 교체
|
||||
MessagePanel.Children[idx] = editPanel;
|
||||
|
||||
// Enter 키로도 전송
|
||||
editBox.KeyDown += (_, ke) =>
|
||||
{
|
||||
if (ke.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.None)
|
||||
{
|
||||
ke.Handled = true;
|
||||
var newText = editBox.Text.Trim();
|
||||
if (!string.IsNullOrEmpty(newText))
|
||||
_ = SubmitEditAsync(idx, newText);
|
||||
}
|
||||
if (ke.Key == Key.Escape)
|
||||
{
|
||||
ke.Handled = true;
|
||||
_isEditing = false;
|
||||
if (idx >= 0 && idx < MessagePanel.Children.Count)
|
||||
MessagePanel.Children[idx] = wrapper;
|
||||
}
|
||||
};
|
||||
|
||||
editBox.Focus();
|
||||
editBox.SelectAll();
|
||||
}
|
||||
|
||||
private async Task SubmitEditAsync(int bubbleIndex, string newText)
|
||||
{
|
||||
_isEditing = false;
|
||||
if (_isStreaming) return;
|
||||
|
||||
ChatConversation conv;
|
||||
lock (_convLock)
|
||||
{
|
||||
if (_currentConversation == null) return;
|
||||
conv = _currentConversation;
|
||||
}
|
||||
|
||||
// bubbleIndex에 해당하는 user 메시지 찾기
|
||||
int userMsgIdx = -1;
|
||||
int uiIdx = 0;
|
||||
lock (_convLock)
|
||||
{
|
||||
for (int i = 0; i < conv.Messages.Count; i++)
|
||||
{
|
||||
if (conv.Messages[i].Role == "system") continue;
|
||||
if (uiIdx == bubbleIndex)
|
||||
{
|
||||
userMsgIdx = i;
|
||||
break;
|
||||
}
|
||||
uiIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
if (userMsgIdx < 0) return;
|
||||
|
||||
// 데이터 모델에서 편집된 메시지 이후 모두 제거
|
||||
lock (_convLock)
|
||||
{
|
||||
conv.Messages[userMsgIdx].Content = newText;
|
||||
while (conv.Messages.Count > userMsgIdx + 1)
|
||||
conv.Messages.RemoveAt(conv.Messages.Count - 1);
|
||||
}
|
||||
|
||||
// UI에서 편집된 버블 이후 모두 제거
|
||||
while (MessagePanel.Children.Count > bubbleIndex + 1)
|
||||
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
|
||||
|
||||
// 편집된 메시지를 새 버블로 교체
|
||||
MessagePanel.Children.RemoveAt(bubbleIndex);
|
||||
AddMessageBubble("user", newText, animate: false);
|
||||
|
||||
// AI 재응답
|
||||
await SendRegenerateAsync(conv);
|
||||
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
|
||||
RefreshConversationList();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Animation;
|
||||
using System.Windows.Threading;
|
||||
using AxCopilot.Models;
|
||||
using AxCopilot.Services;
|
||||
@@ -11,255 +9,6 @@ namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
// ─── 메시지 등장 애니메이션 ──────────────────────────────────────────
|
||||
|
||||
private static void ApplyMessageEntryAnimation(FrameworkElement element)
|
||||
{
|
||||
element.Opacity = 0;
|
||||
element.RenderTransform = new TranslateTransform(0, 16);
|
||||
element.BeginAnimation(UIElement.OpacityProperty,
|
||||
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(350))
|
||||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
|
||||
((TranslateTransform)element.RenderTransform).BeginAnimation(
|
||||
TranslateTransform.YProperty,
|
||||
new DoubleAnimation(16, 0, TimeSpan.FromMilliseconds(400))
|
||||
{ EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut } });
|
||||
}
|
||||
|
||||
// ─── 메시지 편집 ──────────────────────────────────────────────────────
|
||||
|
||||
private bool _isEditing; // 편집 모드 중복 방지
|
||||
|
||||
private void EnterEditMode(StackPanel wrapper, string originalText)
|
||||
{
|
||||
if (_isStreaming || _isEditing) return;
|
||||
_isEditing = true;
|
||||
|
||||
// wrapper 위치(인덱스) 기억
|
||||
var idx = MessagePanel.Children.IndexOf(wrapper);
|
||||
if (idx < 0) { _isEditing = false; return; }
|
||||
|
||||
// 편집 UI 생성
|
||||
var editPanel = new StackPanel
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
MaxWidth = 540,
|
||||
Margin = wrapper.Margin,
|
||||
};
|
||||
|
||||
var editBox = new TextBox
|
||||
{
|
||||
Text = originalText,
|
||||
FontSize = 13.5,
|
||||
Foreground = ThemeResourceHelper.Primary(this),
|
||||
Background = ThemeResourceHelper.ItemBg(this),
|
||||
CaretBrush = ThemeResourceHelper.Accent(this),
|
||||
BorderBrush = ThemeResourceHelper.Accent(this),
|
||||
BorderThickness = new Thickness(1.5),
|
||||
Padding = new Thickness(14, 10, 14, 10),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
AcceptsReturn = false,
|
||||
MaxHeight = 200,
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
|
||||
};
|
||||
// 둥근 모서리
|
||||
var editBorder = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(14),
|
||||
Child = editBox,
|
||||
ClipToBounds = true,
|
||||
};
|
||||
editPanel.Children.Add(editBorder);
|
||||
|
||||
// 버튼 바
|
||||
var btnBar = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Margin = new Thickness(0, 6, 0, 0),
|
||||
};
|
||||
|
||||
var accentBrush = ThemeResourceHelper.Accent(this);
|
||||
var secondaryBrush = ThemeResourceHelper.Secondary(this);
|
||||
|
||||
// 취소 버튼
|
||||
var cancelBtn = new Button
|
||||
{
|
||||
Content = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryBrush },
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Cursor = Cursors.Hand,
|
||||
Padding = new Thickness(12, 5, 12, 5),
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
};
|
||||
cancelBtn.Click += (_, _) =>
|
||||
{
|
||||
_isEditing = false;
|
||||
if (idx >= 0 && idx < MessagePanel.Children.Count)
|
||||
MessagePanel.Children[idx] = wrapper; // 원래 버블 복원
|
||||
};
|
||||
btnBar.Children.Add(cancelBtn);
|
||||
|
||||
// 전송 버튼
|
||||
var sendBtn = new Button
|
||||
{
|
||||
Cursor = Cursors.Hand,
|
||||
Padding = new Thickness(0),
|
||||
};
|
||||
sendBtn.Template = (ControlTemplate)System.Windows.Markup.XamlReader.Parse(
|
||||
"<ControlTemplate xmlns='http://schemas.microsoft.com/winfx/2006/xaml/presentation' TargetType='Button'>" +
|
||||
"<Border Background='" + (accentBrush is SolidColorBrush sb ? sb.Color.ToString() : "#4B5EFC") + "' CornerRadius='8' Padding='12,5,12,5'>" +
|
||||
"<TextBlock Text='전송' FontSize='12' Foreground='White' HorizontalAlignment='Center'/>" +
|
||||
"</Border></ControlTemplate>");
|
||||
sendBtn.Click += (_, _) =>
|
||||
{
|
||||
var newText = editBox.Text.Trim();
|
||||
if (!string.IsNullOrEmpty(newText))
|
||||
_ = SubmitEditAsync(idx, newText);
|
||||
};
|
||||
btnBar.Children.Add(sendBtn);
|
||||
|
||||
editPanel.Children.Add(btnBar);
|
||||
|
||||
// 기존 wrapper → editPanel 교체
|
||||
MessagePanel.Children[idx] = editPanel;
|
||||
|
||||
// Enter 키로도 전송
|
||||
editBox.KeyDown += (_, ke) =>
|
||||
{
|
||||
if (ke.Key == Key.Enter && Keyboard.Modifiers == ModifierKeys.None)
|
||||
{
|
||||
ke.Handled = true;
|
||||
var newText = editBox.Text.Trim();
|
||||
if (!string.IsNullOrEmpty(newText))
|
||||
_ = SubmitEditAsync(idx, newText);
|
||||
}
|
||||
if (ke.Key == Key.Escape)
|
||||
{
|
||||
ke.Handled = true;
|
||||
_isEditing = false;
|
||||
if (idx >= 0 && idx < MessagePanel.Children.Count)
|
||||
MessagePanel.Children[idx] = wrapper;
|
||||
}
|
||||
};
|
||||
|
||||
editBox.Focus();
|
||||
editBox.SelectAll();
|
||||
}
|
||||
|
||||
private async Task SubmitEditAsync(int bubbleIndex, string newText)
|
||||
{
|
||||
_isEditing = false;
|
||||
if (_isStreaming) return;
|
||||
|
||||
ChatConversation conv;
|
||||
lock (_convLock)
|
||||
{
|
||||
if (_currentConversation == null) return;
|
||||
conv = _currentConversation;
|
||||
}
|
||||
|
||||
// bubbleIndex에 해당하는 user 메시지 찾기
|
||||
int userMsgIdx = -1;
|
||||
int uiIdx = 0;
|
||||
lock (_convLock)
|
||||
{
|
||||
for (int i = 0; i < conv.Messages.Count; i++)
|
||||
{
|
||||
if (conv.Messages[i].Role == "system") continue;
|
||||
if (uiIdx == bubbleIndex)
|
||||
{
|
||||
userMsgIdx = i;
|
||||
break;
|
||||
}
|
||||
uiIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
if (userMsgIdx < 0) return;
|
||||
|
||||
// 데이터 모델에서 편집된 메시지 이후 모두 제거
|
||||
lock (_convLock)
|
||||
{
|
||||
conv.Messages[userMsgIdx].Content = newText;
|
||||
while (conv.Messages.Count > userMsgIdx + 1)
|
||||
conv.Messages.RemoveAt(conv.Messages.Count - 1);
|
||||
}
|
||||
|
||||
// UI에서 편집된 버블 이후 모두 제거
|
||||
while (MessagePanel.Children.Count > bubbleIndex + 1)
|
||||
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
|
||||
|
||||
// 편집된 메시지를 새 버블로 교체
|
||||
MessagePanel.Children.RemoveAt(bubbleIndex);
|
||||
AddMessageBubble("user", newText, animate: false);
|
||||
|
||||
// AI 재응답
|
||||
await SendRegenerateAsync(conv);
|
||||
try { _storage.Save(conv); } catch (Exception ex) { Services.LogService.Debug($"대화 저장 실패: {ex.Message}"); }
|
||||
RefreshConversationList();
|
||||
}
|
||||
|
||||
// ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ────────────────────────────
|
||||
|
||||
private void StopAiIconPulse()
|
||||
{
|
||||
if (_aiIconPulseStopped || _activeAiIcon == null) return;
|
||||
_activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null);
|
||||
_activeAiIcon.Opacity = 1.0;
|
||||
_activeAiIcon = null;
|
||||
_aiIconPulseStopped = true;
|
||||
}
|
||||
|
||||
private void CursorTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
_cursorVisible = !_cursorVisible;
|
||||
// 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당
|
||||
if (_activeStreamText != null && _displayedLength > 0)
|
||||
{
|
||||
var displayed = _cachedStreamContent.Length > 0
|
||||
? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)]
|
||||
: "";
|
||||
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
|
||||
}
|
||||
}
|
||||
|
||||
private void ElapsedTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - _streamStartTime;
|
||||
var sec = (int)elapsed.TotalSeconds;
|
||||
if (_elapsedLabel != null)
|
||||
_elapsedLabel.Text = $"{sec}s";
|
||||
|
||||
// 하단 상태바 시간 갱신
|
||||
if (StatusElapsed != null)
|
||||
StatusElapsed.Text = $"{sec}초";
|
||||
}
|
||||
|
||||
private void TypingTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return;
|
||||
|
||||
var targetLen = _cachedStreamContent.Length;
|
||||
if (_displayedLength >= targetLen) return;
|
||||
|
||||
// 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
|
||||
var pending = targetLen - _displayedLength;
|
||||
int step;
|
||||
if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기
|
||||
else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속
|
||||
else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자
|
||||
|
||||
_displayedLength += step;
|
||||
|
||||
var displayed = _cachedStreamContent[.._displayedLength];
|
||||
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
|
||||
|
||||
// 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발)
|
||||
if (!_userScrolled)
|
||||
MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight);
|
||||
}
|
||||
|
||||
// ─── 전송 ──────────────────────────────────────────────────────────────
|
||||
|
||||
public void SendInitialMessage(string message)
|
||||
@@ -456,7 +205,7 @@ public partial class ChatWindow
|
||||
{
|
||||
Source = thumbnail,
|
||||
MaxHeight = 48, MaxWidth = 64,
|
||||
Stretch = Stretch.Uniform,
|
||||
Stretch = System.Windows.Media.Stretch.Uniform,
|
||||
Margin = new Thickness(0, 0, 4, 0),
|
||||
});
|
||||
}
|
||||
@@ -483,8 +232,8 @@ public partial class ChatWindow
|
||||
var removeBtn = new Button
|
||||
{
|
||||
Content = new TextBlock { Text = "\uE711", FontFamily = ThemeResourceHelper.SegoeMdl2, FontSize = 8, Foreground = secondaryBrush },
|
||||
Background = Brushes.Transparent, BorderThickness = new Thickness(0),
|
||||
Cursor = Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0),
|
||||
Background = System.Windows.Media.Brushes.Transparent, BorderThickness = new Thickness(0),
|
||||
Cursor = System.Windows.Input.Cursors.Hand, Padding = new Thickness(4, 2, 4, 2), Margin = new Thickness(2, 0, 0, 0),
|
||||
};
|
||||
removeBtn.Click += (_, _) =>
|
||||
{
|
||||
|
||||
66
src/AxCopilot/Views/ChatWindow.StreamingTimers.cs
Normal file
66
src/AxCopilot/Views/ChatWindow.StreamingTimers.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
// ─── 스트리밍 커서 깜빡임 + AI 아이콘 펄스 ────────────────────────────
|
||||
|
||||
private void StopAiIconPulse()
|
||||
{
|
||||
if (_aiIconPulseStopped || _activeAiIcon == null) return;
|
||||
_activeAiIcon.BeginAnimation(UIElement.OpacityProperty, null);
|
||||
_activeAiIcon.Opacity = 1.0;
|
||||
_activeAiIcon = null;
|
||||
_aiIconPulseStopped = true;
|
||||
}
|
||||
|
||||
private void CursorTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
_cursorVisible = !_cursorVisible;
|
||||
// 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당
|
||||
if (_activeStreamText != null && _displayedLength > 0)
|
||||
{
|
||||
var displayed = _cachedStreamContent.Length > 0
|
||||
? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)]
|
||||
: "";
|
||||
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
|
||||
}
|
||||
}
|
||||
|
||||
private void ElapsedTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - _streamStartTime;
|
||||
var sec = (int)elapsed.TotalSeconds;
|
||||
if (_elapsedLabel != null)
|
||||
_elapsedLabel.Text = $"{sec}s";
|
||||
|
||||
// 하단 상태바 시간 갱신
|
||||
if (StatusElapsed != null)
|
||||
StatusElapsed.Text = $"{sec}초";
|
||||
}
|
||||
|
||||
private void TypingTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
if (_activeStreamText == null || string.IsNullOrEmpty(_cachedStreamContent)) return;
|
||||
|
||||
var targetLen = _cachedStreamContent.Length;
|
||||
if (_displayedLength >= targetLen) return;
|
||||
|
||||
// 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
|
||||
var pending = targetLen - _displayedLength;
|
||||
int step;
|
||||
if (pending > 200) step = Math.Min(pending / 5, 40); // 대량 버퍼: 빠르게 따라잡기
|
||||
else if (pending > 50) step = Math.Min(pending / 4, 15); // 중간 버퍼: 적당히 가속
|
||||
else step = Math.Min(3, pending); // 소량: 자연스러운 1~3자
|
||||
|
||||
_displayedLength += step;
|
||||
|
||||
var displayed = _cachedStreamContent[.._displayedLength];
|
||||
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
|
||||
|
||||
// 스트리밍 중에는 즉시 스크롤 (부드러운 애니메이션은 지연 유발)
|
||||
if (!_userScrolled)
|
||||
MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user