Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tab Filter Upgrade - Include/Exclude/Off, Filter on more things #19

Closed
5 tasks done
kgar opened this issue Nov 1, 2023 · 6 comments
Closed
5 tasks done

Tab Filter Upgrade - Include/Exclude/Off, Filter on more things #19

kgar opened this issue Nov 1, 2023 · 6 comments
Assignees
Labels
enhancement New feature or request

Comments

@kgar
Copy link
Owner

kgar commented Nov 1, 2023

Update, February 2024

With dnd5e 3.0.0, the filter sets have been moved or replaced by something else.
It is now time to go ahead and implement an alternative.

  • Migrate Functionality to Tidy-owned filter properties on the sheets
  • Support Include/Exclude/Off and proficiency-style cycling
    - [x] Support session storage of filters Deferred
  • Consolidate to a Filter Menu in the style of A5E, including a Clear All button
    - [x] Poll the community for desired filters (e.g., "Can Cast" in Spellbook to represent spells that can be cast, whether from being prepared, a cantrip, innate, at-will, a pact spell, etc.) Deferred until community members can use filters and offer up ideas.
  • Begin planning for API-driven custom filters, assigned by tab ID; this should move sheet filters to runtime data that can be manipulated, rather than being hardcoded to the sheet; create a github issue to track this feature api: Custom Actor Item Filters API #347
  • Begin planning for user-driven custom filters in GM/World Settings; create a github issue to track this feature feature: Custom Filters Admin UI #348

Original Notes

Upgrade tab filter buttons so that they can represent one of the following 3 states:

  • Include
  • Exclude
  • Off

Currently, the built-in tab filters change the count of spells throughout the sheet; this has proven to be misleading over the years. Instead,

  • Do not alter the spell count (prepared spells, etc.) when a filter is active; merely filter the view
  • When viewing prepared spells at the bottom of the Spellbook tab, it should read the correct number of prepared spells every time. This value excludes prepared cantrips.
  • The prepared filter button should contain the accurate count of prepared spells, excluding prepared cantrips.
  • When Cantrip Preparation is active, then unprepared cantrips should be filtered out when the Prepared filter button is pressed.

Example of filter button states:
image

User input:

  • Left Click: cycle forward
  • (when focus visible) Spacebar: cycle forward
  • Right Click: cycle backward
  • (when focus visible) Shift+Spacebar: cycle backward

Colors:

  • Off: --t5ek-light-color
  • Include: suggest a simple emphasized color, not the accent, because the accent can easily be confused as disabled
  • Exclude: maybe rgba(205, 92, 92, 1), as seen in the screenshot, open to suggestions, but something that denotes warning or exclusion

Persist these settings as part of sheet state #17 in sessionStorage.

@kgar kgar added the enhancement New feature or request label Nov 1, 2023
@kgar kgar self-assigned this Nov 26, 2023
@kgar kgar removed their assignment Jan 15, 2024
@kgar
Copy link
Owner Author

kgar commented Jan 15, 2024

Awaiting more changes to the A5E character sheet, as there is a great deal of inspiring work being done there which can help us with Tidy. We'll come back and draw inspiration later ✅

@kgar kgar self-assigned this Feb 7, 2024
@kgar kgar added this to the Tidy dnd5e 3.x Readiness milestone Feb 8, 2024
@kgar
Copy link
Owner Author

kgar commented Feb 12, 2024

2.4.x legacy implementation reference:

The base sheet holds filter sets which can be reference during item preparation. Item preparation is generically called during getData(). Implementing sheet classes then implement _prepareItems.

_filterItems on the base sheet class is housing all of the predicate logic for all filters when processing items.

base-sheet.mjs:

  /**
   * Track the set of item filters which are applied
   * @type {Object<string, Set>}
   * @protected
   */
  _filters = {
    inventory: new Set(),
    spellbook: new Set(),
    features: new Set(),
    effects: new Set()
  };

  // ...

  /**
   * Initialize Item list filters by activating the set of filters which are currently applied
   * @param {number} i  Index of the filter in the list.
   * @param {HTML} ul   HTML object for the list item surrounding the filter.
   * @private
   */
  _initializeFilterItemList(i, ul) {
    const set = this._filters[ul.dataset.filter];
    const filters = ul.querySelectorAll(".filter-item");
    for ( let li of filters ) {
      if ( set.has(li.dataset.filter) ) li.classList.add("active");
    }
  }

  // ...

  /**
   * Determine whether an Owned Item will be shown based on the current set of filters.
   * @param {object[]} items       Copies of item data to be filtered.
   * @param {Set<string>} filters  Filters applied to the item list.
   * @returns {object[]}           Subset of input items limited by the provided filters.
   * @protected
   */
  _filterItems(items, filters) {
    return items.filter(item => {

      // Action usage
      for ( let f of ["action", "bonus", "reaction"] ) {
        if ( filters.has(f) && (item.system.activation?.type !== f) ) return false;
      }

      // Spell-specific filters
      if ( filters.has("ritual") && (item.system.components.ritual !== true) ) return false;
      if ( filters.has("concentration") && (item.system.components.concentration !== true) ) return false;
      if ( filters.has("prepared") ) {
        if ( (item.system.level === 0) || ["innate", "always"].includes(item.system.preparation.mode) ) return true;
        if ( this.actor.type === "npc" ) return true;
        return item.system.preparation.prepared;
      }

      // Equipment-specific filters
      if ( filters.has("equipped") && (item.system.equipped !== true) ) return false;
      return true;
    });
  }

  // ...

  /**
   * Handle toggling of filters to display a different set of owned items.
   * @param {Event} event     The click event which triggered the toggle.
   * @returns {ActorSheet5e}  This actor sheet with toggled filters.
   * @private
   */
  _onToggleFilter(event) {
    event.preventDefault();
    const li = event.currentTarget;
    const set = this._filters[li.parentElement.dataset.filter];
    const filter = li.dataset.filter;
    if ( set.has(filter) ) set.delete(filter);
    else set.add(filter);
    return this.render();
  }

  activateListeners(html) {
    // Activate Item Filters
    const filterLists = html.find(".filter-list");
    filterLists.each(this._initializeFilterItemList.bind(this));
    filterLists.on("click", ".filter-item", this._onToggleFilter.bind(this));
    // ...
  }

@kgar
Copy link
Owner Author

kgar commented Feb 12, 2024

During _prepareItems, the character sheet deals the items into their categories. Then, it feed them through the base sheet's _filterItems function, passing the relevant filter set. The resulting items/spells/feats are further processed and distributed to their relevant context properties.

character-sheet.mjs:

  _prepareItems(context) {

    // Categorize items as inventory, spellbook, features, and classes
    const inventory = {};
    for ( const type of ["weapon", "equipment", "consumable", "tool", "backpack", "loot"] ) {
      inventory[type] = {label: `${CONFIG.Item.typeLabels[type]}Pl`, items: [], dataset: {type}};
    }

    // Partition items by category
    let {items, spells, feats, races, backgrounds, classes, subclasses} = context.items.reduce((obj, item) => {
      const {quantity, uses, recharge} = item.system;

      // Item details
      const ctx = context.itemContext[item.id] ??= {};
      ctx.isStack = Number.isNumeric(quantity) && (quantity !== 1);
      ctx.attunement = {
        [CONFIG.DND5E.attunementTypes.REQUIRED]: {
          icon: "fa-sun",
          cls: "not-attuned",
          title: "DND5E.AttunementRequired"
        },
        [CONFIG.DND5E.attunementTypes.ATTUNED]: {
          icon: "fa-sun",
          cls: "attuned",
          title: "DND5E.AttunementAttuned"
        }
      }[item.system.attunement];

      // Prepare data needed to display expanded sections
      ctx.isExpanded = this._expanded.has(item.id);

      // Item usage
      ctx.hasUses = item.hasLimitedUses;
      ctx.isOnCooldown = recharge && !!recharge.value && (recharge.charged === false);
      ctx.isDepleted = ctx.isOnCooldown && ctx.hasUses && (uses.value > 0);
      ctx.hasTarget = item.hasAreaTarget || item.hasIndividualTarget;

      // Item toggle state
      this._prepareItemToggleState(item, ctx);

      // Classify items into types
      if ( item.type === "spell" ) obj.spells.push(item);
      else if ( item.type === "feat" ) obj.feats.push(item);
      else if ( item.type === "race" ) obj.races.push(item);
      else if ( item.type === "background" ) obj.backgrounds.push(item);
      else if ( item.type === "class" ) obj.classes.push(item);
      else if ( item.type === "subclass" ) obj.subclasses.push(item);
      else if ( Object.keys(inventory).includes(item.type) ) obj.items.push(item);
      return obj;
    }, { items: [], spells: [], feats: [], races: [], backgrounds: [], classes: [], subclasses: [] });

    // Apply active item filters
    items = this._filterItems(items, this._filters.inventory);
    spells = this._filterItems(spells, this._filters.spellbook);
    feats = this._filterItems(feats, this._filters.features);

    // Organize items
    for ( let i of items ) {
      const ctx = context.itemContext[i.id] ??= {};
      ctx.totalWeight = (i.system.quantity * i.system.weight).toNearest(0.1);
      inventory[i.type].items.push(i);
    }

    // Organize Spellbook and count the number of prepared spells (excluding always, at will, etc...)
    const spellbook = this._prepareSpellbook(context, spells);
    const nPrepared = spells.filter(spell => {
      const prep = spell.system.preparation;
      return (spell.system.level > 0) && (prep.mode === "prepared") && prep.prepared;
    }).length;

    // Sort classes and interleave matching subclasses, put unmatched subclasses into features so they don't disappear
    classes.sort((a, b) => b.system.levels - a.system.levels);
    const maxLevelDelta = CONFIG.DND5E.maxLevel - this.actor.system.details.level;
    classes = classes.reduce((arr, cls) => {
      const ctx = context.itemContext[cls.id] ??= {};
      ctx.availableLevels = Array.fromRange(CONFIG.DND5E.maxLevel + 1).slice(1).map(level => {
        const delta = level - cls.system.levels;
        return { level, delta, disabled: delta > maxLevelDelta };
      });
      arr.push(cls);
      const identifier = cls.system.identifier || cls.name.slugify({strict: true});
      const subclass = subclasses.findSplice(s => s.system.classIdentifier === identifier);
      if ( subclass ) arr.push(subclass);
      return arr;
    }, []);
    for ( const subclass of subclasses ) {
      feats.push(subclass);
      const message = game.i18n.format("DND5E.SubclassMismatchWarn", {
        name: subclass.name, class: subclass.system.classIdentifier
      });
      context.warnings.push({ message, type: "warning" });
    }

    // Organize Features
    const features = {
      race: {
        label: CONFIG.Item.typeLabels.race, items: races,
        hasActions: false, dataset: {type: "race"} },
      background: {
        label: CONFIG.Item.typeLabels.background, items: backgrounds,
        hasActions: false, dataset: {type: "background"} },
      classes: {
        label: `${CONFIG.Item.typeLabels.class}Pl`, items: classes,
        hasActions: false, dataset: {type: "class"}, isClass: true },
      active: {
        label: "DND5E.FeatureActive", items: [],
        hasActions: true, dataset: {type: "feat", "activation.type": "action"} },
      passive: {
        label: "DND5E.FeaturePassive", items: [],
        hasActions: false, dataset: {type: "feat"} }
    };
    for ( const feat of feats ) {
      if ( feat.system.activation?.type ) features.active.items.push(feat);
      else features.passive.items.push(feat);
    }

    // Assign and return
    context.inventoryFilters = true;
    context.inventory = Object.values(inventory);
    context.spellbook = spellbook;
    context.preparedSpells = nPrepared;
    context.features = Object.values(features);
  }

@kgar
Copy link
Owner Author

kgar commented Feb 12, 2024

Much the same as character sheet logic, but tuned for NPCs. Notably, there is not an inventory context props and thus no inventory filtering.

npc-sheet.mjs:

/** @override */
  _prepareItems(context) {

    // Categorize Items as Features and Spells
    const features = {
      weapons: { label: game.i18n.localize("DND5E.AttackPl"), items: [], hasActions: true,
        dataset: {type: "weapon", "weapon-type": "natural"} },
      actions: { label: game.i18n.localize("DND5E.ActionPl"), items: [], hasActions: true,
        dataset: {type: "feat", "activation.type": "action"} },
      passive: { label: game.i18n.localize("DND5E.Features"), items: [], dataset: {type: "feat"} },
      equipment: { label: game.i18n.localize("DND5E.Inventory"), items: [], dataset: {type: "loot"}}
    };

    // Start by classifying items into groups for rendering
    let [spells, other] = context.items.reduce((arr, item) => {
      const {quantity, uses, recharge, target} = item.system;
      const ctx = context.itemContext[item.id] ??= {};
      ctx.isStack = Number.isNumeric(quantity) && (quantity !== 1);
      ctx.isExpanded = this._expanded.has(item.id);
      ctx.hasUses = uses && (uses.max > 0);
      ctx.isOnCooldown = recharge && !!recharge.value && (recharge.charged === false);
      ctx.isDepleted = item.isOnCooldown && (uses.per && (uses.value > 0));
      ctx.hasTarget = !!target && !(["none", ""].includes(target.type));
      ctx.canToggle = false;
      if ( item.type === "spell" ) arr[0].push(item);
      else arr[1].push(item);
      return arr;
    }, [[], []]);

    // Apply item filters
    spells = this._filterItems(spells, this._filters.spellbook);
    other = this._filterItems(other, this._filters.features);

    // Organize Spellbook
    const spellbook = this._prepareSpellbook(context, spells);

    // Organize Features
    for ( let item of other ) {
      if ( item.type === "weapon" ) features.weapons.items.push(item);
      else if ( item.type === "feat" ) {
        if ( item.system.activation.type ) features.actions.items.push(item);
        else features.passive.items.push(item);
      }
      else features.equipment.items.push(item);
    }

    // Assign and return
    context.inventoryFilters = true;
    context.features = Object.values(features);
    context.spellbook = spellbook;
  }

@kgar
Copy link
Owner Author

kgar commented Feb 12, 2024

Vehicles currently have no filters.

vehicle-sheet.mjs:

  /** @override */
  _prepareItems(context) {
    const cargoColumns = [{
      label: game.i18n.localize("DND5E.Quantity"),
      css: "item-qty",
      property: "quantity",
      editable: "Number"
    }];

    const equipmentColumns = [{
      label: game.i18n.localize("DND5E.Quantity"),
      css: "item-qty",
      property: "system.quantity",
      editable: "Number"
    }, {
      label: game.i18n.localize("DND5E.AC"),
      css: "item-ac",
      property: "system.armor.value"
    }, {
      label: game.i18n.localize("DND5E.HP"),
      css: "item-hp",
      property: "system.hp.value",
      editable: "Number"
    }, {
      label: game.i18n.localize("DND5E.Threshold"),
      css: "item-threshold",
      property: "threshold"
    }];

    const features = {
      actions: {
        label: game.i18n.localize("DND5E.ActionPl"),
        items: [],
        hasActions: true,
        crewable: true,
        dataset: {type: "feat", "activation.type": "crew"},
        columns: [{
          label: game.i18n.localize("DND5E.Cover"),
          css: "item-cover",
          property: "cover"
        }]
      },
      equipment: {
        label: game.i18n.localize(CONFIG.Item.typeLabels.equipment),
        items: [],
        crewable: true,
        dataset: {type: "equipment", "armor.type": "vehicle"},
        columns: equipmentColumns
      },
      passive: {
        label: game.i18n.localize("DND5E.Features"),
        items: [],
        dataset: {type: "feat"}
      },
      reactions: {
        label: game.i18n.localize("DND5E.ReactionPl"),
        items: [],
        dataset: {type: "feat", "activation.type": "reaction"}
      },
      weapons: {
        label: game.i18n.localize(`${CONFIG.Item.typeLabels.weapon}Pl`),
        items: [],
        crewable: true,
        dataset: {type: "weapon", "weapon-type": "siege"},
        columns: equipmentColumns
      }
    };

    context.items.forEach(item => {
      const {uses, recharge} = item.system;
      const ctx = context.itemContext[item.id] ??= {};
      ctx.canToggle = false;
      ctx.isExpanded = this._expanded.has(item.id);
      ctx.hasUses = uses && (uses.max > 0);
      ctx.isOnCooldown = recharge && !!recharge.value && (recharge.charged === false);
      ctx.isDepleted = item.isOnCooldown && (uses.per && (uses.value > 0));
    });

    const cargo = {
      crew: {
        label: game.i18n.localize("DND5E.VehicleCrew"),
        items: context.actor.system.cargo.crew,
        css: "cargo-row crew",
        editableName: true,
        dataset: {type: "crew"},
        columns: cargoColumns
      },
      passengers: {
        label: game.i18n.localize("DND5E.VehiclePassengers"),
        items: context.actor.system.cargo.passengers,
        css: "cargo-row passengers",
        editableName: true,
        dataset: {type: "passengers"},
        columns: cargoColumns
      },
      cargo: {
        label: game.i18n.localize("DND5E.VehicleCargo"),
        items: [],
        dataset: {type: "loot"},
        columns: [{
          label: game.i18n.localize("DND5E.Quantity"),
          css: "item-qty",
          property: "system.quantity",
          editable: "Number"
        }, {
          label: game.i18n.localize("DND5E.Price"),
          css: "item-price",
          property: "system.price.value",
          editable: "Number"
        }, {
          label: game.i18n.localize("DND5E.Weight"),
          css: "item-weight",
          property: "system.weight",
          editable: "Number"
        }]
      }
    };

    // Classify items owned by the vehicle and compute total cargo weight
    let totalWeight = 0;
    for ( const item of context.items ) {
      const ctx = context.itemContext[item.id] ??= {};
      this._prepareCrewedItem(item, ctx);

      // Handle cargo explicitly
      const isCargo = item.flags.dnd5e?.vehicleCargo === true;
      if ( isCargo ) {
        totalWeight += (item.system.weight || 0) * item.system.quantity;
        cargo.cargo.items.push(item);
        continue;
      }

      // Handle non-cargo item types
      switch ( item.type ) {
        case "weapon":
          features.weapons.items.push(item);
          break;
        case "equipment":
          features.equipment.items.push(item);
          break;
        case "feat":
          const act = item.system.activation;
          if ( !act.type || (act.type === "none") ) features.passive.items.push(item);
          else if (act.type === "reaction") features.reactions.items.push(item);
          else features.actions.items.push(item);
          break;
        default:
          totalWeight += (item.system.weight || 0) * item.system.quantity;
          cargo.cargo.items.push(item);
      }
    }

    // Update the rendering context data
    context.inventoryFilters = false;
    context.features = Object.values(features);
    context.cargo = Object.values(cargo);
    context.encumbrance = this._computeEncumbrance(totalWeight, context);
  }

kgar added a commit that referenced this issue Feb 15, 2024
* Began drafting ItemFilterService.

* Minimally prototyped on the character inventory tab with action economy filters.

Began ItemFilterRuntime service.

* Extracted new filter buttons to V2 temp utility bar toggle, to stand in while I formalize the filters prototype.

* Made temp toggle buttons respect tabindex config.

* Generalized the UtilityItemFiltersV2 component to whatever group name is passed in.

* Updated ItemFilterService to be capable of stitching together actor item filter data for rendering.

* Extracted default item filters to their own file.

Established actor/tab filter associations by actor type and tab ID.

Added function to ItemFilterRuntime to get actor fitlers for a given actor.

* Removed prototype code from character sheets and adjusted it for the changes to ItemFilterService.

Added filter setup to NPC sheet class.

* Updated ActorSheetContext to include actor item filter data and to remove the prototype prop itemFilters.

* Implemented character features, inventory, and spellbook filters.

* Plugged in NPC filters.

* Did a quick fix for static getters that invoke Foundry-ready-dependent Tidy content.

* Added FilterMenu component and swapped it into all filtering locations.

Began laying out filters as pill buttons in the filter menu component, as FilterToggleButton components.

Removed prototypal code.

Added Clear All functionality.

* Added pill-button class.

* Added loc keys for Filters menu tooltip and Clear All button text.

* Added Clear All pill button.
Updated filter toggle buttons to be pill buttons and added the current, standard colors.

* Added groups to item filters in preparation to break filters out into sections.

* Changed the term "group" to "category" for item filters.

Adjusted actor tab filters to also subdivide data into categories from tabs.

Updated FilterMenu to section the filters into categories.

* Added init function for ItemFilterRuntime.

Added initRuntime function that will init all runtime classes that needed.

Updated main to init runtime when Foundry is ready and before the API is available.

Fixed verbal filter predicate.

Added data-driven CONFIG filter for itemRarity.

Updated actor item filter data fetching to invoke the dynamic filters when a function is detected.

* Added capitalization to FilterToggleButtons.

* Added some error handling when evaluating filter predicates.

* Added spell school filters to spellbook tabs.

* Added attunement filters.

* Fine-tuned the row-gap for filter buttons.

Adjusted the button menu divider color.

* Resolved TODO.

* Resolved TODO.

* Resolved TODO for moving item runtime types to their own file as a first step to segmenting out / organizing runtime types.

* Resolved subscription stacking issue that occurs when subscribing to stores within the constructor. Delegated to activateListeners with an unsubscribe upon registering new subscriptions, and added unsubscribe to window close.

* Unexported private types in ItemFilterService.

* Optimized filtering code so that all items of a group are filtered together.

Promoted section item filtering from individual items to sections of items, with future plans to move search one level further up to prefilter items.

* Corrected new filter logic to use the configured filter value instead of just `true`.
@kgar kgar added awaiting-release This is awaiting release and removed awaiting-release This is awaiting release labels Feb 15, 2024
@kgar
Copy link
Owner Author

kgar commented Feb 15, 2024

Closing as completed.

@kgar kgar closed this as completed Feb 15, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

1 participant