Skip to content

Commit c109089

Browse files
committed
Port settings and pointer profile to C#
1 parent 48e6739 commit c109089

6 files changed

Lines changed: 691 additions & 0 deletions

File tree

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
using SwitchifyPc.Protocol;
2+
using SwitchifyPc.Core.Settings;
3+
4+
namespace SwitchifyPc.Core.Input;
5+
6+
public sealed record Point(double X, double Y);
7+
public sealed record Bounds(double X, double Y, double Width, double Height);
8+
public sealed record DisplayInfo(Bounds Bounds, double ScaleFactor);
9+
public sealed record PointerProfileInput(Point Cursor, DisplayInfo Display, int? MaxDelta = null);
10+
public sealed record RecommendedDeltas(int Small, int Medium, int Large);
11+
public sealed record PointerCapabilities(bool NoAckMouseMove, IReadOnlyList<string> NoAckCommands, IReadOnlyList<string> SupportedCommands);
12+
public sealed record PointerMovementProfile(
13+
string DisplayId,
14+
double ScaleFactor,
15+
Bounds Bounds,
16+
int MaxDelta,
17+
RecommendedDeltas RecommendedDeltas,
18+
PointerCapabilities Capabilities);
19+
20+
public static class PointerProfile
21+
{
22+
private const double ReferencePointerShortEdge = 1080;
23+
24+
private static readonly IReadOnlyDictionary<PointerMovementSizeKey, double> TargetReferenceNativeDeltas =
25+
Enum.GetValues<PointerMovementSizeKey>().ToDictionary(
26+
size => size,
27+
size => ReferencePointerShortEdge * PointerMovementSettingsModel.FractionFor(PointerMovementSettingsModel.Default, size));
28+
29+
public static PointerMovementProfile Create(PointerProfileInput input)
30+
{
31+
Bounds bounds = NormalizeBounds(input.Display.Bounds);
32+
double scaleFactor = double.IsFinite(input.Display.ScaleFactor) && input.Display.ScaleFactor > 0 ? input.Display.ScaleFactor : 1;
33+
int maxDelta = input.MaxDelta ?? ProtocolConstants.MaxPointerDelta;
34+
35+
return new PointerMovementProfile(
36+
DisplayId: $"{FormatNumber(bounds.X)}:{FormatNumber(bounds.Y)}:{FormatNumber(bounds.Width)}:{FormatNumber(bounds.Height)}:{FormatNumber(scaleFactor)}",
37+
ScaleFactor: scaleFactor,
38+
Bounds: bounds,
39+
MaxDelta: maxDelta,
40+
RecommendedDeltas: new RecommendedDeltas(
41+
Small: ToLogicalDelta(TargetReferenceNativeDeltas[PointerMovementSizeKey.Small], scaleFactor, maxDelta),
42+
Medium: ToLogicalDelta(TargetReferenceNativeDeltas[PointerMovementSizeKey.Medium], scaleFactor, maxDelta),
43+
Large: ToLogicalDelta(TargetReferenceNativeDeltas[PointerMovementSizeKey.Large], scaleFactor, maxDelta)),
44+
Capabilities: new PointerCapabilities(
45+
NoAckMouseMove: true,
46+
NoAckCommands: ProtocolConstants.NoAckControlCommandTypes.ToArray(),
47+
SupportedCommands:
48+
[
49+
.. ProtocolConstants.NoAckControlCommandTypes,
50+
"keyboard.textStream.open",
51+
"keyboard.textStream.chunk",
52+
"keyboard.textStream.close",
53+
"connection.ping",
54+
"pointer.profile"
55+
]));
56+
}
57+
58+
private static Bounds NormalizeBounds(Bounds bounds)
59+
{
60+
return new Bounds(
61+
X: FiniteOr(bounds.X, 0),
62+
Y: FiniteOr(bounds.Y, 0),
63+
Width: PositiveFiniteOr(bounds.Width, 1),
64+
Height: PositiveFiniteOr(bounds.Height, 1));
65+
}
66+
67+
private static double FiniteOr(double value, double fallback)
68+
{
69+
return double.IsFinite(value) ? value : fallback;
70+
}
71+
72+
private static double PositiveFiniteOr(double value, double fallback)
73+
{
74+
return double.IsFinite(value) && value > 0 ? value : fallback;
75+
}
76+
77+
private static int ToLogicalDelta(double nativePixels, double scaleFactor, int maxDelta)
78+
{
79+
return Clamp((int)Math.Round(nativePixels / scaleFactor, MidpointRounding.AwayFromZero), 1, maxDelta);
80+
}
81+
82+
private static int Clamp(int value, int min, int max)
83+
{
84+
return Math.Min(max, Math.Max(min, value));
85+
}
86+
87+
private static string FormatNumber(double value)
88+
{
89+
return value.ToString("G15", System.Globalization.CultureInfo.InvariantCulture);
90+
}
91+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Nodes;
3+
using SwitchifyPc.Core.Storage;
4+
5+
namespace SwitchifyPc.Core.Settings;
6+
7+
public sealed record CursorOverlayColorInfo(string Label, int[] Rgb, string Hex);
8+
9+
public sealed record CursorOverlaySettings(
10+
bool Enabled,
11+
string Size,
12+
string Visibility,
13+
bool Crosshairs,
14+
string Color);
15+
16+
public static class CursorOverlaySettingsModel
17+
{
18+
public static readonly CursorOverlaySettings Default = new(
19+
Enabled: true,
20+
Size: "medium",
21+
Visibility: "onInput",
22+
Crosshairs: false,
23+
Color: "red");
24+
25+
public static readonly IReadOnlyDictionary<string, int> SizePixels = new Dictionary<string, int>(StringComparer.Ordinal)
26+
{
27+
["small"] = 96,
28+
["medium"] = 128,
29+
["large"] = 176
30+
};
31+
32+
public static readonly IReadOnlyDictionary<string, CursorOverlayColorInfo> Colors =
33+
new Dictionary<string, CursorOverlayColorInfo>(StringComparer.Ordinal)
34+
{
35+
["red"] = new("Red", [211, 47, 47], "#d32f2f"),
36+
["green"] = new("Green", [132, 255, 145], "#84ff91"),
37+
["blue"] = new("Blue", [100, 166, 255], "#64a6ff"),
38+
["yellow"] = new("Yellow", [255, 209, 102], "#ffd166"),
39+
["white"] = new("White", [255, 255, 255], "#ffffff")
40+
};
41+
42+
private static readonly HashSet<string> VisibilityValues = new(StringComparer.Ordinal)
43+
{
44+
"onInput",
45+
"whileControlling"
46+
};
47+
48+
public static CursorOverlaySettings Normalize(JsonNode? value)
49+
{
50+
if (value is not JsonObject candidate)
51+
{
52+
return Default;
53+
}
54+
55+
return new CursorOverlaySettings(
56+
Enabled: BooleanOrDefault(candidate, "enabled", Default.Enabled),
57+
Size: StringSetOrDefault(candidate, "size", SizePixels.Keys, Default.Size),
58+
Visibility: StringSetOrDefault(candidate, "visibility", VisibilityValues, Default.Visibility),
59+
Crosshairs: BooleanOrDefault(candidate, "crosshairs", Default.Crosshairs),
60+
Color: StringSetOrDefault(candidate, "color", Colors.Keys, Default.Color));
61+
}
62+
63+
public static CursorOverlaySettings Normalize(object? value)
64+
{
65+
return Normalize(JsonSerializer.SerializeToNode(value));
66+
}
67+
68+
public static CursorOverlaySettings Normalize(CursorOverlaySettings settings)
69+
{
70+
return new CursorOverlaySettings(
71+
Enabled: settings.Enabled,
72+
Size: SizePixels.ContainsKey(settings.Size) ? settings.Size : Default.Size,
73+
Visibility: VisibilityValues.Contains(settings.Visibility) ? settings.Visibility : Default.Visibility,
74+
Crosshairs: settings.Crosshairs,
75+
Color: Colors.ContainsKey(settings.Color) ? settings.Color : Default.Color);
76+
}
77+
78+
public static int ResolveSizePixels(string size)
79+
{
80+
return SizePixels.TryGetValue(size, out int pixels) ? pixels : SizePixels[Default.Size];
81+
}
82+
83+
public static int[] ResolveColorRgb(string color)
84+
{
85+
return Colors.TryGetValue(color, out CursorOverlayColorInfo? value)
86+
? value.Rgb.ToArray()
87+
: Colors[Default.Color].Rgb.ToArray();
88+
}
89+
90+
public static JsonObject ToJsonObject(CursorOverlaySettings settings)
91+
{
92+
CursorOverlaySettings normalized = Normalize(settings);
93+
return new JsonObject
94+
{
95+
["enabled"] = normalized.Enabled,
96+
["size"] = normalized.Size,
97+
["visibility"] = normalized.Visibility,
98+
["crosshairs"] = normalized.Crosshairs,
99+
["color"] = normalized.Color
100+
};
101+
}
102+
103+
private static bool BooleanOrDefault(JsonObject value, string propertyName, bool fallback)
104+
{
105+
return value.TryGetPropertyValue(propertyName, out JsonNode? property) &&
106+
property is not null &&
107+
property.GetValueKind() is JsonValueKind.True or JsonValueKind.False
108+
? property.GetValue<bool>()
109+
: fallback;
110+
}
111+
112+
private static string StringSetOrDefault(JsonObject value, string propertyName, IEnumerable<string> allowed, string fallback)
113+
{
114+
return value.TryGetPropertyValue(propertyName, out JsonNode? property) &&
115+
property?.GetValueKind() == JsonValueKind.String &&
116+
allowed.Contains(property.GetValue<string>(), StringComparer.Ordinal)
117+
? property.GetValue<string>()
118+
: fallback;
119+
}
120+
}
121+
122+
public sealed class JsonCursorOverlaySettingsStore
123+
{
124+
private readonly string filePath;
125+
private readonly Action<string> warn;
126+
127+
public JsonCursorOverlaySettingsStore(string filePath, Action<string>? warn = null)
128+
{
129+
this.filePath = filePath;
130+
this.warn = warn ?? Console.WriteLine;
131+
}
132+
133+
public CursorOverlaySettings Load()
134+
{
135+
try
136+
{
137+
return CursorOverlaySettingsModel.Normalize(JsonNode.Parse(File.ReadAllText(filePath)));
138+
}
139+
catch (Exception error) when (error is FileNotFoundException or DirectoryNotFoundException)
140+
{
141+
return CursorOverlaySettingsModel.Default;
142+
}
143+
catch
144+
{
145+
warn("Switchify cursor overlay settings could not be loaded. Defaults will be used.");
146+
return CursorOverlaySettingsModel.Default;
147+
}
148+
}
149+
150+
public CursorOverlaySettings Save(CursorOverlaySettings settings)
151+
{
152+
CursorOverlaySettings normalized = CursorOverlaySettingsModel.Normalize(settings);
153+
string content = JsonSerializer.Serialize(CursorOverlaySettingsModel.ToJsonObject(normalized), JsonOptions) + "\n";
154+
JsonFileStore.WriteJsonFileAtomicSync(filePath, content);
155+
return normalized;
156+
}
157+
158+
private static readonly JsonSerializerOptions JsonOptions = new()
159+
{
160+
WriteIndented = true
161+
};
162+
}

0 commit comments

Comments
 (0)