diff --git a/.gitignore b/.gitignore index 66b2247..0aa520f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ local.json profiles.json +# локальные ярлыки Windows +*.lnk + # ── рантайм ────────────────────────────────────────────────────────── # сюда лаунчер генерирует config.json перед запуском run/ diff --git a/App.cs b/App.cs index 0a8302a..1404c3d 100644 --- a/App.cs +++ b/App.cs @@ -33,10 +33,12 @@ using System.Threading.Tasks; using System.Web.Script.Serialization; using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; using System.Windows.Input; using System.Windows.Interop; using System.Windows.Markup; using System.Windows.Media; +using System.Windows.Media.Animation; using System.Windows.Media.Imaging; using System.Windows.Threading; @@ -147,7 +149,7 @@ namespace SbLauncher _ni = new System.Windows.Forms.NotifyIcon(); _ni.Icon = LoadIcon(); - _ni.Text = "sb launcher"; + _ni.Text = "SbLauncher"; _ni.Visible = true; _ni.DoubleClick += delegate { Restore(); }; @@ -497,13 +499,12 @@ namespace SbLauncher vm.SetTheme(themeArg != null ? themeArg : Theme.Dark); vm.Load(paths); vm.ShowSettings = settingsTab; - vm.AppendLog("демо-рендер интерфейса — sing-box не запускался"); + vm.ClearLog(); + vm.AppendLog("демонстрация интерфейса — sing-box не запускался"); ImageSource logo = LoadLogo(); Image li = w.FindName("LogoImg") as Image; if (li != null && logo != null) li.Source = logo; - Image statusLogo = w.FindName("StatusLogoImg") as Image; - if (statusLogo != null && logo != null) statusLogo.Source = logo; // немного «живых» данных, чтобы картинка была честной int i = 0; @@ -526,6 +527,14 @@ namespace SbLauncher root2.UpdateLayout(); Pump(); // дать ItemsControl создать карточки root2.UpdateLayout(); + ToggleButton modeSwitch = w.FindName("WireguardSwitch") as ToggleButton; + if (modeSwitch != null) UpdateSwitch(modeSwitch, false); + ListBox serverList = w.FindName("ServerList") as ListBox; + FrameworkElement serverHost = w.FindName("ServerListHost") as FrameworkElement; + Border selection = w.FindName("ServerSelectionWindow") as Border; + if (serverList != null && serverHost != null && selection != null) + MoveServerSelection(serverList, serverHost, selection, false); + root2.UpdateLayout(); RenderTargetBitmap rtb = new RenderTargetBitmap( (int)(ww * 1.5), (int)(hh * 1.5), 144, 144, PixelFormats.Pbgra32); @@ -608,8 +617,6 @@ namespace SbLauncher { Image li = w.FindName("LogoImg") as Image; if (li != null) li.Source = logo; - Image statusLogo = w.FindName("StatusLogoImg") as Image; - if (statusLogo != null) statusLogo.Source = logo; try { w.Icon = logo; } catch { } } @@ -656,7 +663,127 @@ namespace SbLauncher if (log != null) log.TextChanged += delegate { log.ScrollToEnd(); }; + WireSwitch(w); + WireServerSelection(w); w.Loaded += delegate { vm.PingAll(); }; } + + static void WireSwitch(Window w) + { + ToggleButton toggle = w.FindName("WireguardSwitch") as ToggleButton; + if (toggle == null) return; + + // При загрузке положение выставляется сразу; последующие изменения + // идут от текущего положения, поэтому быстрые клики не дают скачка. + toggle.Loaded += delegate { UpdateSwitch(toggle, false); }; + toggle.Checked += delegate { UpdateSwitch(toggle, true); }; + toggle.Unchecked += delegate { UpdateSwitch(toggle, true); }; + } + + static void UpdateSwitch(ToggleButton toggle, bool animate) + { + if (!toggle.IsLoaded && animate) return; + toggle.ApplyTemplate(); + Border active = toggle.Template.FindName("ActiveTrack", toggle) as Border; + System.Windows.Shapes.Ellipse thumb = + toggle.Template.FindName("Thumb", toggle) as System.Windows.Shapes.Ellipse; + TranslateTransform shift = thumb != null ? thumb.RenderTransform as TranslateTransform : null; + if (active == null || shift == null) return; + if (shift.IsFrozen) + { + shift = shift.Clone(); + thumb.RenderTransform = shift; + } + + double targetX = toggle.IsChecked == true ? 22 : 0; + double targetOpacity = toggle.IsChecked == true ? 1 : 0; + double fromX = shift.X, fromOpacity = active.Opacity; + shift.BeginAnimation(TranslateTransform.XProperty, null); + active.BeginAnimation(UIElement.OpacityProperty, null); + shift.X = targetX; + active.Opacity = targetOpacity; + if (!animate) return; + + DoubleAnimation slide = new DoubleAnimation(fromX, targetX, + TimeSpan.FromMilliseconds(430)); + slide.EasingFunction = new BackEase { Amplitude = 0.28, EasingMode = EasingMode.EaseOut }; + slide.FillBehavior = FillBehavior.Stop; + shift.BeginAnimation(TranslateTransform.XProperty, slide); + + DoubleAnimation color = new DoubleAnimation(fromOpacity, targetOpacity, + TimeSpan.FromMilliseconds(300)); + color.EasingFunction = new CubicEase { EasingMode = EasingMode.EaseOut }; + color.FillBehavior = FillBehavior.Stop; + active.BeginAnimation(UIElement.OpacityProperty, color); + } + + static void WireServerSelection(Window w) + { + ListBox list = w.FindName("ServerList") as ListBox; + FrameworkElement host = w.FindName("ServerListHost") as FrameworkElement; + Border highlight = w.FindName("ServerSelectionWindow") as Border; + if (list == null || host == null || highlight == null) return; + + bool selectionPending = false; + int selectionRevision = 0; + list.Loaded += delegate { MoveServerSelection(list, host, highlight, false); }; + list.SelectionChanged += delegate + { + selectionPending = true; + int revision = ++selectionRevision; + if (list.SelectedItem != null) list.ScrollIntoView(list.SelectedItem); + list.Dispatcher.BeginInvoke(DispatcherPriority.Loaded, new Action(delegate + { + if (revision != selectionRevision) return; + try + { + list.UpdateLayout(); + MoveServerSelection(list, host, highlight, true); + } + finally { selectionPending = false; } + })); + }; + list.AddHandler(ScrollViewer.ScrollChangedEvent, new ScrollChangedEventHandler( + delegate { if (!selectionPending) MoveServerSelection(list, host, highlight, false); })); + list.SizeChanged += delegate { if (!selectionPending) MoveServerSelection(list, host, highlight, false); }; + } + + static void MoveServerSelection(ListBox list, FrameworkElement host, + Border highlight, bool animate) + { + ListBoxItem item = list.ItemContainerGenerator.ContainerFromItem(list.SelectedItem) as ListBoxItem; + if (item == null || item.ActualHeight <= 10) + { + highlight.Visibility = Visibility.Collapsed; + return; + } + + Point p; + try { p = item.TransformToAncestor(host).Transform(new Point(0, 0)); } + catch (InvalidOperationException) { return; } + double height = item.ActualHeight - 10; // промежуток между карточками + if (p.Y + height < 0 || p.Y > host.ActualHeight) + { + highlight.Visibility = Visibility.Collapsed; + return; + } + + bool wasVisible = highlight.Visibility == Visibility.Visible; + double fromY = Canvas.GetTop(highlight); + if (double.IsNaN(fromY)) fromY = p.Y; + highlight.BeginAnimation(Canvas.TopProperty, null); + Canvas.SetLeft(highlight, p.X); + Canvas.SetTop(highlight, p.Y); + highlight.Width = item.ActualWidth; + highlight.Height = height; + highlight.Visibility = Visibility.Visible; + if (!animate || !wasVisible || Math.Abs(fromY - p.Y) < 0.5) return; + + DoubleAnimation slide = new DoubleAnimation(fromY, p.Y, + TimeSpan.FromMilliseconds(520)); + slide.EasingFunction = new BackEase { Amplitude = 0.32, EasingMode = EasingMode.EaseOut }; + slide.FillBehavior = FillBehavior.Stop; + highlight.BeginAnimation(Canvas.TopProperty, slide); + } } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 87819ae..60875d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ Пока нет записей. +## 0.1.1 — 2026-09-27 + +- Название окна и значка в трее приведено к `SbLauncher`; в шапке показывается версия сборки. +- Значок в блоке подключения заменён кнопкой питания. +- Кнопка очистки лога убрана из интерфейса. +- Переключатель WireGuard получил плавное движение с лёгкой отдачей; подсветка выбранного сервера скользит между карточками. +- Анимация подключения переделана: мягкий пульс во время ожидания и спокойное свечение при успешном подключении. + ## 0.1.0 — 2026-09-26 - Первый рабочий прототип WPF-лаунчера с интерфейсом glass. diff --git a/README.md b/README.md index 389113d..8e89c17 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# sb launcher · glass +# SbLauncher Лаунчер для sing-box с интерфейсом WPF. Работает в Windows 10/11 x64 с .NET Framework 4.x. Поддерживает импорт и выбор серверов, три режима diff --git a/Ui.xaml b/Ui.xaml index 167f28d..513e5b1 100644 --- a/Ui.xaml +++ b/Ui.xaml @@ -1,7 +1,7 @@ - - + - + + + + + + + - - - - - - + + + + + + + + + + + + + + + + @@ -419,10 +438,6 @@ - - - - @@ -618,12 +633,12 @@ - - @@ -662,31 +677,40 @@ Padding="10,4"/> - - - - - + + + + + + + + + + + + @@ -714,8 +738,8 @@ StrokeThickness="1"/> - - + @@ -723,15 +747,24 @@ + - + + From="0" To="0.18" Duration="0:0:0.55"/> + From="0.82" To="1" Duration="0:0:0.55"> + + + + + From="0.82" To="1" Duration="0:0:0.55"> + + + + @@ -740,44 +773,80 @@ - + - + - + @@ -808,7 +877,7 @@ - @@ -822,10 +891,6 @@