Hone logo
Hone
Problems

Advanced Key Remapping Utilities in TypeScript

Imagine you're building a text editor or a command-line interface where users can customize keyboard shortcuts. This challenge focuses on creating flexible and robust utilities to remap keys, allowing for complex transformations and efficient processing of input events. You will implement functions to define and apply key mappings, handling different types of key events and ensuring predictable behavior.

Problem Description

You need to implement two primary utilities in TypeScript:

  1. defineKeyMap(mappings: KeyMapping[]): KeyMapper: This function will take an array of KeyMapping objects and return a KeyMapper function. The KeyMapper function will be responsible for transforming incoming KeyEvent objects based on the defined mappings.
  2. applyKeyMap(keyMapper: KeyMapper, event: KeyEvent): KeyEvent | null: This function will take a KeyMapper function (created by defineKeyMap) and a KeyEvent object. It should apply the mappings defined in the keyMapper to the event and return the transformed KeyEvent or null if the event is consumed or suppressed by a mapping.

Key Requirements:

  • KeyMapping Interface: Define an interface for KeyMapping that includes:
    • from: A KeyEvent pattern to match against. This pattern can include specific key values, modifier keys (ctrl, alt, shift, meta), and optionally a sequence for multi-key presses.
    • to: A KeyEvent object representing the desired transformation.
    • condition (optional): A function that takes the original KeyEvent and returns a boolean. The mapping is only applied if the condition evaluates to true.
  • KeyEvent Interface: Define an interface for KeyEvent that includes:
    • key: The primary key pressed (e.g., "a", "Enter", "Escape").
    • ctrl: Boolean indicating if Ctrl key was pressed.
    • alt: Boolean indicating if Alt key was pressed.
    • shift: Boolean indicating if Shift key was pressed.
    • meta: Boolean indicating if Meta key (e.g., Cmd on Mac, Win on Windows) was pressed.
    • sequence: An optional array of strings representing a sequence of key presses that led to this event. This is crucial for defining multi-key shortcuts.
  • KeyMapper Type: Define a type alias for the KeyMapper function. It should accept a KeyEvent and return a KeyEvent | null.
  • Matching Logic:
    • The from pattern in KeyMapping should be matched against the incoming KeyEvent.
    • All specified properties in the from pattern must match exactly, including modifiers. If a modifier is true in the from pattern, it must be true in the event. If it's false, it must be false.
    • If sequence is present in the from pattern, it must match the beginning of the event.sequence (or the event.key if event.sequence is empty). The order of keys in the sequence matters.
    • If multiple mappings match an event, the first mapping in the provided array will take precedence.
  • Transformation Logic:
    • If a mapping is applied, the to KeyEvent should be returned, but any properties present in the original event that are not specified in the to KeyEvent should be preserved. For example, if to.key is specified but to.ctrl is not, and the original event.ctrl was true, the transformed event should retain ctrl: true.
    • If a to KeyEvent contains a sequence, this sequence should replace the original event.sequence.
    • If a mapping's condition evaluates to false, the mapping is skipped.
    • If a mapping's to property is null or undefined, it signifies that the event should be suppressed and null should be returned by applyKeyMap.
  • defineKeyMap Behavior: The order of mappings in the input array is important and determines precedence.
  • applyKeyMap Behavior: If no mapping matches the event, the original event should be returned.

Edge Cases to Consider:

  • Empty mapping arrays.
  • Mappings with only modifiers specified.
  • Mappings with sequence but no key in from.
  • Mappings with an empty sequence in from.
  • condition functions that throw errors.
  • to properties that are partially defined (e.g., only key is specified, or only shift is specified).
  • Chained mappings: If an applyKeyMap call returns a transformed event, this transformed event could potentially be fed back into another applyKeyMap call with a different mapper (though this challenge focuses on a single pass).

Examples

Example 1: Basic Key Remapping

interface KeyEvent {
  key: string;
  ctrl?: boolean;
  alt?: boolean;
  shift?: boolean;
  meta?: boolean;
  sequence?: string[];
}

interface KeyMapping {
  from: Omit<KeyEvent, 'sequence'> & { sequence?: string[] };
  to: KeyEvent | null;
  condition?: (event: KeyEvent) => boolean;
}

type KeyMapper = (event: KeyEvent) => KeyEvent | null;

// --- Implementation will go here ---

// Example Usage
const myMappings: KeyMapping[] = [
  {
    from: { key: "a", shift: true },
    to: { key: "A" }
  },
  {
    from: { key: "s", ctrl: true },
    to: { key: "Escape" }
  },
  {
    from: { key: "z", ctrl: true, alt: true },
    to: null // Suppress event
  }
];

const mapper = defineKeyMap(myMappings);

const event1: KeyEvent = { key: "a", shift: true };
const result1 = applyKeyMap(mapper, event1);
// Expected Output: { key: "A", shift: true }
// Explanation: The shift+a mapping was found, and 'a' was transformed to 'A'. The shift modifier is preserved.

const event2: KeyEvent = { key: "s", ctrl: true };
const result2 = applyKeyMap(mapper, event2);
// Expected Output: { key: "Escape", ctrl: true }
// Explanation: The ctrl+s mapping was found, and 's' was transformed to 'Escape'. The ctrl modifier is preserved.

const event3: KeyEvent = { key: "z", ctrl: true, alt: true };
const result3 = applyKeyMap(mapper, event3);
// Expected Output: null
// Explanation: The ctrl+alt+z mapping was found, and its 'to' property is null, signifying suppression.

const event4: KeyEvent = { key: "b" };
const result4 = applyKeyMap(mapper, event4);
// Expected Output: { key: "b" }
// Explanation: No mapping matched.

Example 2: Sequence Remapping

// ... (interfaces and types from Example 1)

// Example Usage
const sequenceMappings: KeyMapping[] = [
  {
    from: { sequence: ["g", "i"] },
    to: { key: "Insert" }
  },
  {
    from: { sequence: ["c", "o", "d", "e"] },
    to: { key: "Enter", ctrl: true }
  },
  {
    from: { key: "k", ctrl: true, sequence: ["p"] }, // Combined key and sequence match
    to: { key: "Save" }
  }
];

const sequenceMapper = defineKeyMap(sequenceMappings);

const event5: KeyEvent = { key: "i", sequence: ["g", "i"] };
const result5 = applyKeyMap(sequenceMapper, event5);
// Expected Output: { key: "Insert", sequence: ["g", "i"] }
// Explanation: The "gi" sequence mapped to "Insert". The sequence itself is preserved in the output event.

const event6: KeyEvent = { key: "e", sequence: ["c", "o", "d", "e"] };
const result6 = applyKeyMap(sequenceMapper, event6);
// Expected Output: { key: "Enter", ctrl: true, sequence: ["c", "o", "d", "e"] }
// Explanation: The "code" sequence mapped to "Enter" with Ctrl modifier.

const event7: KeyEvent = { key: "p", sequence: ["k", "p"], ctrl: true };
const result7 = applyKeyMap(sequenceMapper, event7);
// Expected Output: { key: "Save", ctrl: true, sequence: ["k", "p"] }
// Explanation: Matched the combined key and sequence.

const event8: KeyEvent = { key: "p", sequence: ["k", "p"] }; // Missing ctrl
const result8 = applyKeyMap(sequenceMapper, event8);
// Expected Output: { key: "p", sequence: ["k", "p"] }
// Explanation: The mapping requires ctrl, but it's not present.

Example 3: Conditional Remapping and Partial Transformations

// ... (interfaces and types from Example 1)

// Example Usage
const conditionalMappings: KeyMapping[] = [
  {
    from: { key: "f", shift: true },
    to: { key: "F" },
    condition: (event) => event.key === "f" // Always true for this specific `from`
  },
  {
    from: { key: "b", alt: true },
    to: { ctrl: true }, // Only change the ctrl modifier
    condition: (event) => (event.key.length === 1 && event.key >= 'a' && event.key <= 'z') // Condition based on event properties
  },
  {
    from: { key: "x", ctrl: true },
    to: { key: "c", shift: false }, // Change key and unset shift
    condition: (event) => event.shift === true // Only apply if original shift was true
  }
];

const conditionalMapper = defineKeyMap(conditionalMappings);

const event9: KeyEvent = { key: "f", shift: true };
const result9 = applyKeyMap(conditionalMapper, event9);
// Expected Output: { key: "F", shift: true }
// Explanation: The condition was met, and 'f' transformed to 'F', shift preserved.

const event10: KeyEvent = { key: "b", alt: true, shift: true };
const result10 = applyKeyMap(conditionalMapper, event10);
// Expected Output: { key: "b", alt: true, shift: true, ctrl: true }
// Explanation: The condition was met, alt and shift were preserved, and ctrl was added.

const event11: KeyEvent = { key: "x", ctrl: true, shift: true };
const result11 = applyKeyMap(conditionalMapper, event11);
// Expected Output: { key: "c", ctrl: true, shift: false }
// Explanation: The condition (original shift was true) was met. 'x' became 'c', and shift was explicitly set to false.

const event12: KeyEvent = { key: "x", ctrl: true, shift: false };
const result12 = applyKeyMap(conditionalMapper, event12);
// Expected Output: { key: "x", ctrl: true, shift: false }
// Explanation: The condition (original shift was true) was NOT met.

Constraints

  • The key property of KeyEvent will always be a non-empty string.
  • Modifier properties (ctrl, alt, shift, meta) will be either true or undefined. If undefined, they should be treated as false for matching purposes and will not be explicitly set in the output unless they are present in the to property.
  • The sequence property will be an array of strings, where each string is a single character or a recognized key name. An empty sequence array is valid.
  • The maximum length of a sequence in a KeyMapping will not exceed 5.
  • The defineKeyMap function will be called with an array of at most 100 KeyMapping objects.
  • The applyKeyMap function should ideally process each event in O(N) time, where N is the number of mappings, due to the sequential matching requirement.

Notes

  • Consider how to handle the undefined state of modifiers. For matching, undefined should be treated as false. For transformations, if a modifier is undefined in the original event and not specified in the to property, it should remain undefined.
  • When transforming, ensure that all properties from the original KeyEvent are carried over to the to KeyEvent unless they are explicitly overridden or suppressed.
  • Think carefully about the precise logic for matching sequence against event.sequence and event.key. A from mapping with a sequence should ideally match the entire sequence of presses leading up to the current event.
  • Your defineKeyMap should efficiently store and allow retrieval of mappings.
  • The condition function provides an opportunity for dynamic remapping based on the state of the event itself.
Loading editor...
typescript