| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265 |
- package rgb
- import (
- "encoding/json"
- "fmt"
- "os"
- "sort"
- "strconv"
- "strings"
- "netdome.biz/paul/qmk-rgb/internal/via"
- )
- // NameSource says where an effect name came from. It is on the effect rather
- // than on the file because one board's names can come from both: a definition
- // the manufacturer wrote and a name the user wrote over the top of one of its
- // entries. The tool's rule is that it never reports a value it has not verified,
- // and a name is the one value it cannot read back off a keyboard, so which of
- // the two a name is has to be visible wherever the name is.
- type NameSource string
- const (
- // SourceVendor is a name from the board's own definition file. It is what the
- // manufacturer wrote, so it is a fact about the board.
- SourceVendor NameSource = ""
- // SourceUser is a name from the per-user names file. It is what the user
- // called the effect, which is a fact about them and not about the board.
- SourceUser NameSource = "you"
- )
- // namesDocument is the file as it is stored. The channel is keyed by its QMK
- // subsystem name and the effect by its number, both as strings because that is
- // what a JSON object key is, and both are read back through the one vocabulary in
- // internal/via rather than a second table here.
- type namesDocument struct {
- Name string `json:"name,omitempty"`
- VendorID string `json:"vendorId"`
- ProductID string `json:"productId"`
- Channels map[string]map[string]string `json:"channels"`
- Aliases map[string]map[string]string `json:"aliases,omitempty"`
- }
- // Names is one board's effect names a user wrote, or overrode.
- //
- // It exists because a VIA definition file belongs to the manufacturer. The tool
- // used to write one of its own, which meant authoring a menu description for
- // someone else's keyboard and handing the user a nested JSON array to type a
- // name into. The names are the part that is missing and the part that is
- // anyone's to supply, so they are stored on their own and merged over whatever
- // the definition says.
- type Names struct {
- Path string
- Name string
- VendorID uint16
- ProductID uint16
- // effects and aliases are keyed by channel, then by effect ID or by spelling.
- effects map[via.Channel]map[uint8]string
- aliases map[via.Channel]map[string]string
- }
- // LoadNamesFile reads one names file. A file that is not a names file is an
- // error rather than nothing: the user put it there, and a silent "no names" would
- // read as the keyboard having none.
- func LoadNamesFile(path string) (*Names, error) {
- raw, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- var doc namesDocument
- if err := json.Unmarshal(raw, &doc); err != nil {
- return nil, fmt.Errorf("parse %s: %w", path, err)
- }
- vendorID, productID, err := namesIdentifiers(doc.VendorID, doc.ProductID)
- if err != nil {
- return nil, fmt.Errorf("parse %s: %w", path, err)
- }
- names := &Names{
- Path: path,
- Name: doc.Name,
- VendorID: vendorID,
- ProductID: productID,
- effects: map[via.Channel]map[uint8]string{},
- aliases: map[via.Channel]map[string]string{},
- }
- for key, byID := range doc.Channels {
- ch, ok := via.ChannelFromSubsystem(key)
- if !ok {
- return nil, fmt.Errorf("parse %s: %q is not a QMK lighting channel", path, key)
- }
- for rawID, name := range byID {
- id, err := parseEffectID(rawID)
- if err != nil {
- return nil, fmt.Errorf("parse %s: %s channel %q: %w", path, key, rawID, err)
- }
- if names.effects[ch] == nil {
- names.effects[ch] = map[uint8]string{}
- }
- names.effects[ch][id] = name
- }
- }
- for key, byName := range doc.Aliases {
- ch, ok := via.ChannelFromSubsystem(key)
- if !ok {
- return nil, fmt.Errorf("parse %s: %q is not a QMK lighting channel", path, key)
- }
- if names.aliases[ch] == nil {
- names.aliases[ch] = map[string]string{}
- }
- for spelling, name := range byName {
- names.aliases[ch][spelling] = name
- }
- }
- return names, nil
- }
- // Matches reports whether these names are the ones for a board.
- func (n *Names) Matches(vendorID, productID uint16) bool {
- return n.VendorID == vendorID && n.ProductID == productID
- }
- // Apply returns a catalog with these names over the top of the ones it holds.
- //
- // It works on a nil catalog, because a board with no definition file is exactly
- // the board a user writes names for: the file the manufacturer would have
- // published is the one that is missing, not the names. A name here replaces the
- // vendor's for that one effect ID and leaves every other entry alone, so
- // overriding one name does not mean restating the ones that were right.
- func (n *Names) Apply(base *Catalog) *Catalog {
- merged := make(map[via.Channel][]Effect)
- var aliases = map[via.Channel]map[string]string{}
- board := ""
- // A nil catalog is the normal case here, not a degenerate one: it is what a
- // board with no definition file gives, and that board is the one a user writes
- // names for.
- if base != nil {
- board = base.board
- for ch, effects := range base.names {
- merged[ch] = append([]Effect(nil), effects...)
- }
- for ch, byName := range base.aliases {
- copied := make(map[string]string, len(byName))
- for k, v := range byName {
- copied[k] = v
- }
- aliases[ch] = copied
- }
- }
- // An effect the definition does not have is added, because the definition may
- // stop short of the board's highest ID and a user naming that one is telling
- // us something the file did not.
- for ch, byID := range n.effects {
- for id, name := range byID {
- merged[ch] = upsertEffect(merged[ch], Effect{ID: id, Name: name, Source: SourceUser})
- }
- }
- for ch, byName := range n.aliases {
- if aliases[ch] == nil {
- aliases[ch] = map[string]string{}
- }
- for spelling, name := range byName {
- // Stored as written. An alias is looked up by the exact string the
- // catalog already uses, so normalising it here would make a spelling
- // resolve that the definition's own aliases do not.
- aliases[ch][spelling] = name
- }
- }
- if n.Name != "" {
- board = n.Name
- }
- out := &Catalog{board: board, names: merged, aliases: aliases}
- for ch := range merged {
- sort.Slice(out.names[ch], func(i, j int) bool { return out.names[ch][i].ID < out.names[ch][j].ID })
- }
- return out
- }
- // upsertEffect replaces the entry for an ID or adds one, keeping the list sorted.
- func upsertEffect(effects []Effect, want Effect) []Effect {
- for i := range effects {
- if effects[i].ID == want.ID {
- effects[i] = want
- return effects
- }
- }
- effects = append(effects, want)
- sort.Slice(effects, func(i, j int) bool { return effects[i].ID < effects[j].ID })
- return effects
- }
- // Channels returns the lighting channels this file names, in channel order, so a
- // command that reports on the file can walk it the way it walks a board.
- func (n *Names) Channels() []via.Channel {
- var out []via.Channel
- for _, ch := range via.LightingChannels {
- if len(n.effects[ch]) > 0 || len(n.aliases[ch]) > 0 {
- out = append(out, ch)
- }
- }
- return out
- }
- // Effects returns the names on one channel, in ID order.
- func (n *Names) Effects(ch via.Channel) []Effect {
- ids := make([]int, 0, len(n.effects[ch]))
- for id := range n.effects[ch] {
- ids = append(ids, int(id))
- }
- sort.Ints(ids)
- out := make([]Effect, 0, len(ids))
- for _, id := range ids {
- out = append(out, Effect{ID: uint8(id), Name: n.effects[ch][uint8(id)], Source: SourceUser})
- }
- return out
- }
- // parseEffectID reads an effect ID from a names file, where it is a JSON object
- // key and therefore a string. A plain number is what a person writes, and 0x is
- // accepted because both spellings appear in the tool's own output.
- func parseEffectID(raw string) (uint8, error) {
- var n uint64
- var err error
- if base, value, found := strings.Cut(raw, "0x"); found {
- _ = base
- n, err = strconv.ParseUint(value, 16, 16)
- } else {
- n, err = strconv.ParseUint(raw, 10, 16)
- }
- if err != nil {
- return 0, fmt.Errorf("%q is not an effect ID", raw)
- }
- return uint8(n), nil
- }
- // namesIdentifiers returns the board a names file is for. It is the same pair of
- // spellings a definition file carries, read through the same hex parser, so a
- // names file and a definition file for one board are recognisably the same board.
- func namesIdentifiers(vendorID, productID string) (uint16, uint16, error) {
- if vendorID == "" || productID == "" {
- return 0, 0, fmt.Errorf("neither vendorId nor productId; not a names file")
- }
- v, err := parseHexID(vendorID)
- if err != nil {
- return 0, 0, fmt.Errorf("vendorId %q: %w", vendorID, err)
- }
- p, err := parseHexID(productID)
- if err != nil {
- return 0, 0, fmt.Errorf("productId %q: %w", productID, err)
- }
- return v, p, nil
- }
- // EffectSource says whether the name for an effect ID on a channel is the
- // manufacturer's or the user's, which is what lets a command print where a name
- // came from instead of presenting both as the same kind of fact.
- func (c *Catalog) EffectSource(ch via.Channel, id uint8) NameSource {
- if c == nil {
- return SourceVendor
- }
- for _, e := range c.names[ch] {
- if e.ID == id {
- return e.Source
- }
- }
- return SourceVendor
- }
|