[jubeat-analyser] Prettier rendering of decimal values
This commit is contained in:
parent
bb48c4ef0b
commit
996deb3ed3
@ -1,3 +1,7 @@
|
||||
# Unreleased
|
||||
## Fixed
|
||||
- [jubeat-analyser] Prettier rendering of decimal values
|
||||
|
||||
# v0.2.0
|
||||
## Added
|
||||
- [#mono-column] #circlefree mode accepts non-16ths notes and falls back to normal symbols when needed
|
||||
|
@ -24,12 +24,17 @@ Known hash commands :
|
||||
- #bpp # bytes per panel (2 by default)
|
||||
"""
|
||||
|
||||
from numbers import Number
|
||||
from typing import Any, List, Optional, Tuple
|
||||
from decimal import ROUND_HALF_DOWN, Decimal
|
||||
from fractions import Fraction
|
||||
from functools import singledispatch
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple, Union
|
||||
|
||||
from parsimonious import Grammar, NodeVisitor, ParseError
|
||||
from parsimonious.nodes import Node
|
||||
|
||||
from jubeatools.utils import fraction_to_decimal
|
||||
|
||||
command_grammar = Grammar(
|
||||
r"""
|
||||
line = ws command ws comment?
|
||||
@ -75,7 +80,7 @@ class CommandVisitor(NodeVisitor):
|
||||
self.key = letter.text
|
||||
|
||||
def visit_quoted_value(self, node: Node, visited_children: List[Node]) -> None:
|
||||
self.value = parse_value(node.text)
|
||||
self.value = unescape_string_value(node.text)
|
||||
|
||||
def visit_number(self, node: Node, visited_children: List[Node]) -> None:
|
||||
self.value = node.text
|
||||
@ -105,27 +110,83 @@ def parse_command(line: str) -> Tuple[str, Optional[str]]:
|
||||
raise
|
||||
|
||||
|
||||
def dump_command(key: str, value: Any = None) -> str:
|
||||
CommandValue = Union[str, Path, int, Decimal, Fraction]
|
||||
|
||||
|
||||
def dump_command(key: str, value: Optional[CommandValue] = None) -> str:
|
||||
if len(key) == 1:
|
||||
key_part = key
|
||||
else:
|
||||
key_part = f"#{key}"
|
||||
|
||||
if isinstance(value, Number):
|
||||
value_part = f"={value}"
|
||||
elif value is not None:
|
||||
escaped = dump_value(str(value))
|
||||
value_part = f'="{escaped}"'
|
||||
else:
|
||||
if value is None:
|
||||
value_part = ""
|
||||
else:
|
||||
dumped_value = dump_value(value)
|
||||
value_part = f"={dumped_value}"
|
||||
|
||||
return key_part + value_part
|
||||
|
||||
|
||||
@singledispatch
|
||||
def dump_value(value: CommandValue) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
@dump_value.register
|
||||
def dump_int_value(value: int) -> str:
|
||||
return str(value)
|
||||
|
||||
|
||||
@dump_value.register
|
||||
def dump_decimal_value(value: Decimal) -> str:
|
||||
return pretty_print_decimal(value)
|
||||
|
||||
|
||||
@dump_value.register
|
||||
def dump_fraction_value(value: Fraction) -> str:
|
||||
decimal = fraction_to_decimal(value)
|
||||
quantized = decimal.quantize(Decimal("0.000001"), rounding=ROUND_HALF_DOWN)
|
||||
return pretty_print_decimal(quantized)
|
||||
|
||||
|
||||
@dump_value.register
|
||||
def dump_string_value(value: str) -> str:
|
||||
"""Escapes backslashes and " from a string"""
|
||||
escaped = escape_string_value(value)
|
||||
quoted = f'"{escaped}"'
|
||||
return quoted
|
||||
|
||||
|
||||
@dump_value.register
|
||||
def dump_path_value(value: Path) -> str:
|
||||
if value == Path(""):
|
||||
string_value = ""
|
||||
else:
|
||||
string_value = str(value)
|
||||
|
||||
escaped = escape_string_value(string_value)
|
||||
quoted = f'"{escaped}"'
|
||||
return quoted
|
||||
|
||||
|
||||
def pretty_print_decimal(d: Decimal) -> str:
|
||||
raw_string_form = str(d)
|
||||
if "." in raw_string_form:
|
||||
return raw_string_form.rstrip("0").rstrip(".")
|
||||
else:
|
||||
return raw_string_form
|
||||
|
||||
|
||||
BACKSLASH = "\\"
|
||||
ESCAPE_TABLE = str.maketrans({'"': BACKSLASH + '"', BACKSLASH: BACKSLASH + BACKSLASH})
|
||||
|
||||
|
||||
def parse_value(escaped: str) -> str:
|
||||
def escape_string_value(unescaped: str) -> str:
|
||||
return unescaped.translate(ESCAPE_TABLE)
|
||||
|
||||
|
||||
def unescape_string_value(escaped: str) -> str:
|
||||
"""Unescapes a backslash-escaped string"""
|
||||
res = []
|
||||
i = 0
|
||||
@ -141,11 +202,3 @@ def parse_value(escaped: str) -> str:
|
||||
i += 1
|
||||
|
||||
return "".join(res)
|
||||
|
||||
|
||||
ESCAPE_TABLE = str.maketrans({'"': BACKSLASH + '"', BACKSLASH: BACKSLASH + BACKSLASH})
|
||||
|
||||
|
||||
def dump_value(value: str) -> str:
|
||||
"""Escapes backslashes and " from a string"""
|
||||
return value.translate(ESCAPE_TABLE)
|
||||
|
@ -9,12 +9,12 @@ from pathlib import Path
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
DefaultDict,
|
||||
Dict,
|
||||
Generic,
|
||||
Iterator,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
TypedDict,
|
||||
TypeVar,
|
||||
Union,
|
||||
)
|
||||
@ -34,6 +34,7 @@ from jubeatools.song import (
|
||||
TapNote,
|
||||
Timing,
|
||||
)
|
||||
from jubeatools.utils import fraction_to_decimal
|
||||
|
||||
from .command import dump_command
|
||||
from .symbols import CIRCLE_FREE_SYMBOLS, NOTE_SYMBOLS
|
||||
@ -92,11 +93,6 @@ DEFAULT_EXTRA_SYMBOLS = (
|
||||
)
|
||||
|
||||
|
||||
def fraction_to_decimal(frac: Fraction) -> Decimal:
|
||||
"Thanks stackoverflow ! https://stackoverflow.com/a/40468867/10768117"
|
||||
return frac.numerator / Decimal(frac.denominator)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LongNoteEnd:
|
||||
time: BeatsTime
|
||||
@ -107,7 +103,7 @@ K = TypeVar("K")
|
||||
V = TypeVar("V")
|
||||
|
||||
|
||||
class SortedDefaultDict(SortedDict, Generic[K, V]):
|
||||
class SortedDefaultDict(SortedDict, DefaultDict[K, V]):
|
||||
|
||||
"""Custom SortedDict that also acts as a defaultdict,
|
||||
passes the key to the value factory"""
|
||||
@ -127,13 +123,36 @@ class SortedDefaultDict(SortedDict, Generic[K, V]):
|
||||
return value
|
||||
|
||||
|
||||
class Commands(TypedDict, total=False):
|
||||
b: Union[int, Fraction, Decimal]
|
||||
t: Union[int, Fraction, Decimal]
|
||||
m: Union[str, Path]
|
||||
o: int
|
||||
r: int
|
||||
title: str
|
||||
artist: str
|
||||
lev: Union[int, Decimal]
|
||||
dif: int
|
||||
jacket: Union[str, Path]
|
||||
prevpos: int
|
||||
holdbyarrow: int
|
||||
circlefree: int
|
||||
memo: None
|
||||
memo1: None
|
||||
memo2: None
|
||||
boogie: None
|
||||
pw: int
|
||||
ph: int
|
||||
bpp: int
|
||||
|
||||
|
||||
# Here we split dataclass and ABC stuff since mypy curently can't handle both
|
||||
# at once on a single class definition
|
||||
@dataclass
|
||||
class _JubeatAnalyerDumpedSection:
|
||||
current_beat: BeatsTime
|
||||
length: BeatsTime = BeatsTime(4)
|
||||
commands: Dict[str, Optional[str]] = field(default_factory=dict)
|
||||
commands: Commands = field(default_factory=Commands) # type: ignore
|
||||
symbol_definitions: Dict[BeatsTime, str] = field(default_factory=dict)
|
||||
symbols: Dict[BeatsTime, str] = field(default_factory=dict)
|
||||
notes: List[Union[TapNote, LongNote, LongNoteEnd]] = field(default_factory=list)
|
||||
@ -143,33 +162,31 @@ class JubeatAnalyserDumpedSection(_JubeatAnalyerDumpedSection, ABC):
|
||||
def _dump_commands(self) -> Iterator[str]:
|
||||
keys = chain(COMMAND_ORDER, self.commands.keys() - set(COMMAND_ORDER))
|
||||
for key in keys:
|
||||
try:
|
||||
value = self.commands[key]
|
||||
except KeyError:
|
||||
continue
|
||||
yield dump_command(key, value)
|
||||
if key in self.commands:
|
||||
yield dump_command(key, self.commands[key]) # type: ignore
|
||||
|
||||
def _dump_symbol_definitions(self) -> Iterator[str]:
|
||||
for time, symbol in self.symbol_definitions.items():
|
||||
decimal_time = fraction_to_decimal(time)
|
||||
yield f"*{symbol}:{decimal_time:.6f}"
|
||||
yield f"*{symbol}:{decimal_time:.6}"
|
||||
|
||||
@abstractmethod
|
||||
def _dump_notes(self, circle_free: bool) -> Iterator[str]:
|
||||
...
|
||||
|
||||
|
||||
S = TypeVar("S", bound=JubeatAnalyserDumpedSection)
|
||||
@abstractmethod
|
||||
def render(self, circle_free: bool) -> str:
|
||||
...
|
||||
|
||||
|
||||
def create_sections_from_chart(
|
||||
section_factory: Callable[[BeatsTime], S],
|
||||
section_factory: Callable[[BeatsTime], JubeatAnalyserDumpedSection],
|
||||
chart: Chart,
|
||||
difficulty: str,
|
||||
timing: Timing,
|
||||
metadata: Metadata,
|
||||
circle_free: bool,
|
||||
) -> Mapping[BeatsTime, S]:
|
||||
) -> Mapping[BeatsTime, JubeatAnalyserDumpedSection]:
|
||||
sections = SortedDefaultDict(section_factory)
|
||||
|
||||
timing_events = sorted(timing.events, key=lambda e: e.time)
|
||||
@ -213,15 +230,18 @@ def create_sections_from_chart(
|
||||
|
||||
# First, Set every single b=… value
|
||||
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
|
||||
if next_key is None:
|
||||
length = Decimal(4)
|
||||
if key is None:
|
||||
continue
|
||||
elif next_key is None:
|
||||
length = BeatsTime(4)
|
||||
else:
|
||||
length = fraction_to_decimal(next_key - key)
|
||||
length = next_key - key
|
||||
|
||||
sections[key].commands["b"] = length
|
||||
sections[key].length = length
|
||||
|
||||
# Then, trim all the redundant b=…
|
||||
last_b = 4
|
||||
last_b: Union[int, Fraction, Decimal] = 4
|
||||
for section in sections.values():
|
||||
current_b = section.commands["b"]
|
||||
if current_b == last_b:
|
||||
@ -231,6 +251,7 @@ def create_sections_from_chart(
|
||||
|
||||
# Fill sections with notes
|
||||
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
|
||||
assert key is not None
|
||||
sections[key].notes = list(
|
||||
notes.irange_key(min_key=key, max_key=next_key, inclusive=(True, False))
|
||||
)
|
||||
|
@ -281,13 +281,16 @@ def _dump_memo2_chart(
|
||||
sections.add_key(beat)
|
||||
|
||||
# Timing events
|
||||
sections[0].events.append(StopEvent(BeatsTime(0), timing.beat_zero_offset))
|
||||
sections[BeatsTime(0)].events.append(
|
||||
StopEvent(BeatsTime(0), timing.beat_zero_offset)
|
||||
)
|
||||
for event in timing_events:
|
||||
section_beat = event.time - (event.time % 4)
|
||||
sections[section_beat].events.append(event)
|
||||
|
||||
# Fill sections with notes
|
||||
for key, next_key in windowed(chain(sections.keys(), [None]), 2):
|
||||
assert key is not None
|
||||
sections[key].notes = list(
|
||||
notes.irange_key(min_key=key, max_key=next_key, inclusive=(True, False))
|
||||
)
|
||||
|
@ -3,6 +3,7 @@ from pathlib import Path
|
||||
from typing import Set, Union
|
||||
|
||||
from hypothesis import example, given
|
||||
from hypothesis import note as hypothesis_note
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from jubeatools import song
|
||||
@ -30,6 +31,7 @@ def test_that_notes_roundtrip(notes: Set[Union[song.TapNote, song.LongNote]]) ->
|
||||
metadata = song.Metadata("", "", Path(""), Path(""))
|
||||
string_io = _dump_memo_chart("", chart, metadata, timing, False)
|
||||
chart_text = string_io.getvalue()
|
||||
hypothesis_note(f"Chart :\n{chart_text}")
|
||||
parser = MemoParser()
|
||||
for line in chart_text.split("\n"):
|
||||
parser.load_line(line)
|
||||
|
@ -1,11 +1,11 @@
|
||||
from hypothesis import given
|
||||
from hypothesis import strategies as st
|
||||
|
||||
from ..command import dump_value, parse_value
|
||||
from ..command import escape_string_value, unescape_string_value
|
||||
|
||||
|
||||
@given(st.text())
|
||||
def test_that_strings_roundtrip(expected: str) -> None:
|
||||
dumped = dump_value(expected)
|
||||
actual = parse_value(dumped)
|
||||
def test_that_escaping_preserves_string(expected: str) -> None:
|
||||
escaped = escape_string_value(expected)
|
||||
actual = unescape_string_value(escaped)
|
||||
assert expected == actual
|
||||
|
@ -1,4 +1,6 @@
|
||||
import unicodedata
|
||||
from decimal import Decimal
|
||||
from fractions import Fraction
|
||||
from functools import reduce
|
||||
from math import gcd
|
||||
from typing import Callable, Optional, TypeVar
|
||||
@ -28,3 +30,8 @@ def none_or(c: Callable[[A], B], e: Optional[A]) -> Optional[B]:
|
||||
return None
|
||||
else:
|
||||
return c(e)
|
||||
|
||||
|
||||
def fraction_to_decimal(frac: Fraction) -> Decimal:
|
||||
"Thanks stackoverflow ! https://stackoverflow.com/a/40468867/10768117"
|
||||
return frac.numerator / Decimal(frac.denominator)
|
||||
|
Loading…
Reference in New Issue
Block a user