Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 53 additions & 5 deletions src-tauri/src/models/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,10 +206,10 @@ pub struct KeyPosition {
pub note_color: NoteColor,
pub note_opacity: u32,
#[serde(default)]
pub note_border_radius: Option<u32>,
pub note_border_radius: Option<f64>,
/// 노트 넓이(px). None이면 키 width를 사용(자동).
#[serde(default)]
pub note_width: Option<u32>,
pub note_width: Option<f64>,
/// 노트 정렬 (left/center/right). 기본값 center.
#[serde(default)]
pub note_alignment: NoteAlignment,
Expand All @@ -218,7 +218,7 @@ pub struct KeyPosition {
#[serde(default = "default_note_glow_enabled")]
pub note_glow_enabled: bool,
#[serde(default = "default_note_glow_size")]
pub note_glow_size: u32,
pub note_glow_size: f64,
#[serde(default = "default_note_glow_opacity")]
pub note_glow_opacity: u32,
#[serde(default)]
Expand Down Expand Up @@ -760,8 +760,8 @@ fn default_note_effect_enabled() -> bool {
fn default_note_glow_enabled() -> bool {
false
}
fn default_note_glow_size() -> u32 {
20
fn default_note_glow_size() -> f64 {
20.0
}
fn default_note_glow_opacity() -> u32 {
70
Expand Down Expand Up @@ -1812,3 +1812,51 @@ pub struct SettingsPatch {
#[serde(skip_serializing_if = "Option::is_none")]
pub obs_mode_enabled: Option<bool>,
}

#[cfg(test)]
mod tests {
use super::KeyPosition;

// 필수 필드만 채운 최소 KeyPosition JSON. 시각 px 필드는 호출부에서 주입
fn key_position_json(visual_px: &str) -> String {
format!(
r##"{{
"dx": 0.0, "dy": 0.0, "width": 60.0, "height": 60.0,
"count": 0, "noteColor": "#FFFFFF", "noteOpacity": 80,
{visual_px}
}}"##
)
}

// 기존 정수 저장값이 f64 필드로 그대로 역직렬화되는지 (하위 호환)
#[test]
fn visual_px_fields_accept_integer_json() {
let json =
key_position_json(r#""noteWidth": 100, "noteBorderRadius": 8, "noteGlowSize": 20"#);
let pos: KeyPosition = serde_json::from_str(&json).unwrap();
assert_eq!(pos.note_width, Some(100.0));
assert_eq!(pos.note_border_radius, Some(8.0));
assert_eq!(pos.note_glow_size, 20.0);
}

// 소수 저장값이 정상 역직렬화되는지
#[test]
fn visual_px_fields_accept_decimal_json() {
let json = key_position_json(
r#""noteWidth": 100.5, "noteBorderRadius": 8.5, "noteGlowSize": 20.5"#,
);
let pos: KeyPosition = serde_json::from_str(&json).unwrap();
assert_eq!(pos.note_width, Some(100.5));
assert_eq!(pos.note_border_radius, Some(8.5));
assert_eq!(pos.note_glow_size, 20.5);
}

// note_glow_size 미지정 시 기본값(20.0) 적용
#[test]
fn note_glow_size_defaults_to_20() {
let json = key_position_json(r#""noteWidth": null"#);
let pos: KeyPosition = serde_json::from_str(&json).unwrap();
assert_eq!(pos.note_glow_size, 20.0);
assert_eq!(pos.note_width, None);
}
}
2 changes: 1 addition & 1 deletion src-tauri/src/state/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@ pub(crate) fn normalize_state(mut data: AppStoreData) -> AppStoreData {
if let Some(legacy_border_radius) = data.note_settings.border_radius.take() {
for positions in data.key_positions.values_mut() {
for pos in positions.iter_mut() {
pos.note_border_radius = Some(legacy_border_radius);
pos.note_border_radius = Some(legacy_border_radius as f64);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,16 +300,61 @@ export const OptionalNumberInput: React.FC<OptionalNumberInputProps> = ({
width = '54px',
placeholder,
allowNegative = false,
allowDecimal = false,
decimalScale = 1,
isMixed = false,
mixedPlaceholder = 'Mixed',
}) => {
const hasSuffix = !!suffix;
const resolvedDecimalScale = allowDecimal
? Math.max(0, Math.floor(decimalScale))
: 0;
const supportsDecimal = resolvedDecimalScale > 0;
const inputMode = supportsDecimal ? 'decimal' : 'numeric';

const normalizePrecision = (num: number): number => {
if (!supportsDecimal) return num;
return Number(num.toFixed(resolvedDecimalScale));
};

// 숫자/부호/소수점만 남기고, 부호는 맨 앞에 하나, 소수점도 하나만 유지
const sanitizeInput = (raw: string): string => {
const pattern = supportsDecimal
? allowNegative
? /[^0-9.-]/g
: /[^0-9.]/g
: allowNegative
? /[^0-9-]/g
: /[^0-9]/g;
let sanitized = raw.replace(pattern, '');

if (allowNegative) {
const isNegative = sanitized.startsWith('-');
sanitized = sanitized.replace(/-/g, '');
if (isNegative) sanitized = `-${sanitized}`;
}

if (!supportsDecimal) return sanitized;

const sign = sanitized.startsWith('-') ? '-' : '';
const unsigned = sign ? sanitized.slice(1) : sanitized;
const dotIndex = unsigned.indexOf('.');
if (dotIndex === -1) return `${sign}${unsigned}`;

const integerPart = unsigned.slice(0, dotIndex);
const fractionalPart = unsigned
.slice(dotIndex + 1)
.replace(/\./g, '')
.slice(0, resolvedDecimalScale);
return `${sign}${integerPart}.${fractionalPart}`;
};

const getDisplayValue = (val: number, focused: boolean): string => {
const normalized = normalizePrecision(val);
if (hasSuffix && !focused) {
return `${val}${suffix}`;
return `${normalized}${suffix}`;
}
return String(val);
return String(normalized);
};

const [localValue, setLocalValue] = useState<string>(() => {
Expand Down Expand Up @@ -349,31 +394,30 @@ export const OptionalNumberInput: React.FC<OptionalNumberInputProps> = ({
if (e.ctrlKey || e.metaKey) return;
if (/^[0-9]$/.test(e.key)) return;
if (allowNegative && e.key === '-') return;
if (supportsDecimal && (e.key === '.' || e.key === 'Decimal')) return;

e.preventDefault();
};

const numericPattern = allowNegative ? /[^0-9-]/g : /[^0-9]/g;

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value.replace(numericPattern, '');
// 마이너스는 맨 앞에만 허용
const newValue = allowNegative
? (raw.startsWith('-') ? '-' : '') + raw.replace(/-/g, '')
: raw;
const newValue = sanitizeInput(e.target.value);
setLocalValue(newValue);
setHasUserInput(true);

if (newValue === '' || newValue === '-') {
// 빈 값만 unset, 부호·소수점만 남은 중간 상태는 commit하지 않고 입력 유지
if (newValue === '') {
onChange(undefined);
return;
}
if (newValue === '-' || newValue === '.' || newValue === '-.') {
return;
}

const numValue = Number(newValue);
if (!Number.isFinite(numValue)) return;

const clamped = Math.min(Math.max(numValue, min), max);
onChange(clamped);
onChange(normalizePrecision(clamped));
};

const handleFocus = () => {
Expand All @@ -388,9 +432,7 @@ export const OptionalNumberInput: React.FC<OptionalNumberInputProps> = ({

const handleBlur = () => {
setIsFocused(false);
const cleaned = allowNegative
? localValue.replace(/[^0-9-]/g, '')
: localValue.replace(/[^0-9]/g, '');
const cleaned = sanitizeInput(localValue);

// Mixed 상태에서 사용자 입력이 없었으면 Mixed 유지
if (isMixed && !hasUserInput) {
Expand All @@ -400,7 +442,13 @@ export const OptionalNumberInput: React.FC<OptionalNumberInputProps> = ({
return;
}

if (cleaned === '' || cleaned === '-' || isNaN(Number(cleaned))) {
if (
cleaned === '' ||
cleaned === '-' ||
cleaned === '.' ||
cleaned === '-.' ||
isNaN(Number(cleaned))
) {
setLocalValue('');
onChange(undefined);
setHasUserInput(false);
Expand All @@ -409,7 +457,7 @@ export const OptionalNumberInput: React.FC<OptionalNumberInputProps> = ({
}

const numValue = Number(cleaned);
const clamped = Math.min(Math.max(numValue, min), max);
const clamped = normalizePrecision(Math.min(Math.max(numValue, min), max));
setLocalValue(getDisplayValue(clamped, false));
onChange(clamped);
setHasUserInput(false);
Expand Down Expand Up @@ -443,7 +491,7 @@ export const OptionalNumberInput: React.FC<OptionalNumberInputProps> = ({
)}
<input
type="text"
inputMode="numeric"
inputMode={inputMode}
value={localValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
Expand All @@ -464,7 +512,7 @@ export const OptionalNumberInput: React.FC<OptionalNumberInputProps> = ({
return (
<input
type="text"
inputMode="numeric"
inputMode={inputMode}
value={localValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
Expand All @@ -482,7 +530,7 @@ export const OptionalNumberInput: React.FC<OptionalNumberInputProps> = ({
return (
<input
type="text"
inputMode="numeric"
inputMode={inputMode}
value={localValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,28 +116,28 @@ const BatchNoteTabContent: React.FC<BatchNoteTabContentProps> = ({
{/* 오프셋 */}
<PropertyRow label={t('keySetting.noteOffset') || '오프셋'}>
<OptionalNumberInput
value={
getMixedValue((pos) => pos.noteOffsetX, 0).value || undefined
}
value={getMixedValue((pos) => pos.noteOffsetX, 0).value || undefined}
onChange={(value) =>
handleBatchStyleChangeComplete('noteOffsetX', value)
}
prefix="X"
allowNegative
allowDecimal
decimalScale={1}
min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.min}
max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetX.max}
placeholder="0"
isMixed={getMixedValue((pos) => pos.noteOffsetX, 0).isMixed}
/>
<OptionalNumberInput
value={
getMixedValue((pos) => pos.noteOffsetY, 0).value || undefined
}
value={getMixedValue((pos) => pos.noteOffsetY, 0).value || undefined}
onChange={(value) =>
handleBatchStyleChangeComplete('noteOffsetY', value)
}
prefix="Y"
allowNegative
allowDecimal
decimalScale={1}
min={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.min}
max={NOTE_SETTINGS_CONSTRAINTS.noteOffsetY.max}
placeholder="0"
Expand All @@ -154,6 +154,8 @@ const BatchNoteTabContent: React.FC<BatchNoteTabContentProps> = ({
}
suffix="px"
min={1}
allowDecimal
decimalScale={1}
placeholder="Auto"
isMixed={noteWidthMixed.isMixed}
/>
Expand Down Expand Up @@ -332,6 +334,8 @@ const BatchNoteTabContent: React.FC<BatchNoteTabContentProps> = ({
suffix="px"
min={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.min}
max={NOTE_SETTINGS_CONSTRAINTS.noteBorderWidth.max}
allowDecimal
decimalScale={1}
isMixed={
getMixedValue(
(pos) => pos.noteBorderWidth,
Expand All @@ -356,6 +360,8 @@ const BatchNoteTabContent: React.FC<BatchNoteTabContentProps> = ({
suffix="px"
min={NOTE_SETTINGS_CONSTRAINTS.borderRadius.min}
max={NOTE_SETTINGS_CONSTRAINTS.borderRadius.max}
allowDecimal
decimalScale={1}
isMixed={
getMixedValue(
(pos) => pos.noteBorderRadius,
Expand Down Expand Up @@ -410,6 +416,8 @@ const BatchNoteTabContent: React.FC<BatchNoteTabContentProps> = ({
suffix="px"
min={0}
max={50}
allowDecimal
decimalScale={1}
isMixed={getMixedValue((pos) => pos.noteGlowSize, 20).isMixed}
/>
</PropertyRow>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,8 @@ const BatchStyleTabContent: React.FC<BatchStyleTabContentProps> = ({
suffix="px"
min={0}
max={20}
allowDecimal
decimalScale={1}
/>
</PropertyRow>

Expand All @@ -646,6 +648,8 @@ const BatchStyleTabContent: React.FC<BatchStyleTabContentProps> = ({
suffix="px"
min={0}
max={100}
allowDecimal
decimalScale={1}
/>
</PropertyRow>

Expand Down Expand Up @@ -722,6 +726,8 @@ const BatchStyleTabContent: React.FC<BatchStyleTabContentProps> = ({
suffix="px"
min={8}
max={72}
allowDecimal
decimalScale={1}
/>
</PropertyRow>

Expand Down
Loading
Loading