Release v0.1.2

This commit is contained in:
nikit3s
2026-10-10 14:58:04 +03:00
parent b2ae91b5b3
commit 3cdc30ec2a
33 changed files with 1218 additions and 212 deletions
+162 -3
View File
@@ -1,7 +1,8 @@
// SbLauncher — GUI для sing-box.
//
// ПРИНЦИП (изменён по сравнению с прошлой версией):
// Источник правды — profiles.json (серверы, общее) и local.json (ключ,
// Источник правды — profiles.base.json (базовые серверы), profiles.json
// (личные серверы) и local.json (ключ,
// адрес, адаптер — своё на каждой машине). Конфиг sing-box НЕ хранится:
// он собирается из шаблона templates\*.json при каждом запуске и кладётся
// в run\config.json. Поэтому папок conf_* больше нет, и править один и
@@ -71,6 +72,7 @@ namespace SbLauncher
{
public const string Dark = "dark";
public const string Light = "light";
static readonly TimeSpan TransitionDuration = TimeSpan.FromMilliseconds(460);
public static string DetectSystem()
{
@@ -103,12 +105,92 @@ namespace SbLauncher
ResourceDictionary src = w.Resources[key] as ResourceDictionary;
if (src == null) return;
// Первый кадр и служебные рендеры сразу получают конечную тему.
bool animate = w.IsLoaded && SystemParameters.ClientAreaAnimation;
foreach (object k in src.Keys)
{
object v = src[k];
Brush target = v as Brush;
if (target != null)
v = TransitionBrush(w.Resources[k] as Brush, target, animate);
w.Resources[k] = v;
if (Application.Current != null) Application.Current.Resources[k] = v;
}
UpdateIcon(w, "ThemeSun", theme != Light, -100, animate);
UpdateIcon(w, "ThemeMoon", theme == Light, 90, animate);
}
static Brush TransitionBrush(Brush current, Brush target, bool animate)
{
// Исходные словари остаются неизменными. Новый переход начинается
// с видимого цвета, даже если предыдущий ещё не успел завершиться.
Brush next = target.Clone();
if (!animate || current == null) return next;
SolidColorBrush fromSolid = current as SolidColorBrush;
SolidColorBrush toSolid = next as SolidColorBrush;
GradientBrush fromGradient = current as GradientBrush;
GradientBrush toGradient = next as GradientBrush;
if (fromSolid != null && toSolid != null)
AnimateColor(toSolid, SolidColorBrush.ColorProperty, fromSolid.Color, toSolid.Color);
else if (fromGradient != null && toGradient != null &&
current.GetType() == target.GetType() &&
fromGradient.GradientStops.Count == toGradient.GradientStops.Count)
{
for (int i = 0; i < toGradient.GradientStops.Count; i++)
{
GradientStop from = fromGradient.GradientStops[i];
GradientStop to = toGradient.GradientStops[i];
AnimateColor(to, GradientStop.ColorProperty, from.Color, to.Color);
AnimateDouble(to, GradientStop.OffsetProperty, from.Offset, to.Offset, true);
}
}
AnimateDouble(next, Brush.OpacityProperty, current.Opacity, target.Opacity, true);
return next;
}
static void AnimateColor(Animatable target, DependencyProperty property, Color from, Color to)
{
if (from == to) return;
// WPF активирует clock на следующем кадре: до него тоже должен
// оставаться исходный цвет, иначе при клике видна вспышка цели.
target.SetValue(property, from);
var animation = new ColorAnimation(from, to, TransitionDuration);
animation.EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut };
animation.FillBehavior = FillBehavior.HoldEnd;
target.BeginAnimation(property, animation, HandoffBehavior.SnapshotAndReplace);
}
static void AnimateDouble(DependencyObject target, DependencyProperty property,
double from, double to, bool animate)
{
var animated = (IAnimatable)target;
animated.BeginAnimation(property, null);
bool transition = animate && Math.Abs(from - to) >= 0.001;
target.SetValue(property, transition ? from : to);
if (!transition) return;
var animation = new DoubleAnimation(from, to, TransitionDuration);
animation.EasingFunction = new CubicEase { EasingMode = EasingMode.EaseInOut };
// SnapshotAndReplace keeps one clock per property. Holding the
// endpoint avoids callbacks from an interrupted transition.
animation.FillBehavior = FillBehavior.HoldEnd;
animated.BeginAnimation(property, animation, HandoffBehavior.SnapshotAndReplace);
}
static void UpdateIcon(Window window, string name, bool visible, double hiddenAngle, bool animate)
{
FrameworkElement icon = window.FindName(name) as FrameworkElement;
if (icon == null) return;
TransformGroup transforms = icon.RenderTransform as TransformGroup;
if (transforms == null || transforms.Children.Count != 2) return;
if (transforms.IsFrozen) icon.RenderTransform = transforms = transforms.Clone();
var scale = transforms.Children[0] as ScaleTransform;
var rotation = transforms.Children[1] as RotateTransform;
if (scale == null || rotation == null) return;
AnimateDouble(icon, UIElement.OpacityProperty, icon.Opacity, visible ? 1 : 0, animate);
AnimateDouble(scale, ScaleTransform.ScaleXProperty, scale.ScaleX, visible ? 1 : 0.4, animate);
AnimateDouble(scale, ScaleTransform.ScaleYProperty, scale.ScaleY, visible ? 1 : 0.4, animate);
AnimateDouble(rotation, RotateTransform.AngleProperty, rotation.Angle, visible ? 0 : hiddenAngle, animate);
}
// Смысловые цвета (Ok/Warn/Bad/Idle/Na/Muted/Accent) тоже живут в
@@ -534,6 +616,7 @@ namespace SbLauncher
Border selection = w.FindName("ServerSelectionWindow") as Border;
if (serverList != null && serverHost != null && selection != null)
MoveServerSelection(serverList, serverHost, selection, false);
UpdateTabs(w, vm, false);
root2.UpdateLayout();
RenderTargetBitmap rtb = new RenderTargetBitmap(
@@ -569,10 +652,12 @@ namespace SbLauncher
if (argv[i] == "--root") return argv[i + 1];
string exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
if (File.Exists(Path.Combine(exeDir, "profiles.json"))) return exeDir;
if (File.Exists(Path.Combine(exeDir, "profiles.json")) ||
File.Exists(Path.Combine(exeDir, "profiles.base.json"))) return exeDir;
string parent = Path.GetDirectoryName(exeDir);
if (parent != null && File.Exists(Path.Combine(parent, "profiles.json"))) return parent;
if (parent != null && (File.Exists(Path.Combine(parent, "profiles.json")) ||
File.Exists(Path.Combine(parent, "profiles.base.json")))) return parent;
return exeDir;
}
@@ -665,9 +750,83 @@ namespace SbLauncher
WireSwitch(w);
WireServerSelection(w);
WireTabs(w, vm);
w.Loaded += delegate { vm.PingAll(); };
}
static void WireTabs(Window w, MainVm vm)
{
FrameworkElement host = w.FindName("TabContentHost") as FrameworkElement;
if (host == null) return;
w.Loaded += delegate { UpdateTabs(w, vm, false); };
host.SizeChanged += delegate { UpdateTabs(w, vm, false); };
PropertyChangedEventHandler changed = delegate(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == "ShowSettings") UpdateTabs(w, vm, w.IsLoaded);
};
vm.PropertyChanged += changed;
w.Closed += delegate { vm.PropertyChanged -= changed; };
}
static void UpdateTabs(Window w, MainVm vm, bool animate)
{
FrameworkElement host = w.FindName("TabContentHost") as FrameworkElement;
FrameworkElement selector = w.FindName("TabSelectorHost") as FrameworkElement;
Border highlight = w.FindName("TabSelectionWindow") as Border;
Border log = w.FindName("LogPane") as Border;
Border settings = w.FindName("SettingsPane") as Border;
Button selected = w.FindName(vm.ShowSettings ? "BtnTabSettings" : "BtnTabLog") as Button;
if (host == null || selector == null || highlight == null || log == null ||
settings == null || selected == null || host.ActualWidth <= 0) return;
bool showSettings = vm.ShowSettings;
Border inactive = showSettings ? log : settings;
// Уходящая панель остаётся видимой до конца движения, но её поля
// уже не должны получать клики или фокус с клавиатуры.
if (inactive.IsKeyboardFocusWithin) selected.Focus();
log.IsHitTestVisible = !showSettings;
settings.IsHitTestVisible = showSettings;
KeyboardNavigation.SetTabNavigation(log, showSettings ? KeyboardNavigationMode.None : KeyboardNavigationMode.Continue);
KeyboardNavigation.SetTabNavigation(settings, showSettings ? KeyboardNavigationMode.Continue : KeyboardNavigationMode.None);
log.Visibility = settings.Visibility = Visibility.Visible;
double travel = host.ActualWidth + 16;
SlideTabElement(log, showSettings ? -travel : 0, animate,
delegate { if (vm.ShowSettings) log.Visibility = Visibility.Hidden; });
SlideTabElement(settings, showSettings ? 0 : travel, animate,
delegate { if (!vm.ShowSettings) settings.Visibility = Visibility.Hidden; });
double targetX = selected.TransformToAncestor(selector).Transform(new Point()).X;
SlideTabElement(highlight, targetX, animate, null);
}
static void SlideTabElement(FrameworkElement element, double targetX, bool animate, Action completed)
{
TranslateTransform shift = element.RenderTransform as TranslateTransform;
if (shift == null) element.RenderTransform = shift = new TranslateTransform();
else if (shift.IsFrozen) element.RenderTransform = shift = shift.Clone();
double fromX = shift.X;
shift.BeginAnimation(TranslateTransform.XProperty, null);
shift.X = fromX;
if (!animate || Math.Abs(fromX - targetX) < 0.1)
{
shift.X = targetX;
if (completed != null) completed();
return;
}
DoubleAnimation slide = new DoubleAnimation(fromX, targetX, TimeSpan.FromMilliseconds(520));
slide.EasingFunction = new BackEase { Amplitude = 0.22, EasingMode = EasingMode.EaseOut };
slide.Completed += delegate
{
shift.X = targetX;
shift.BeginAnimation(TranslateTransform.XProperty, null);
if (completed != null) completed();
};
shift.BeginAnimation(TranslateTransform.XProperty, slide);
}
static void WireSwitch(Window w)
{
ToggleButton toggle = w.FindName("WireguardSwitch") as ToggleButton;