Browse Source

Merge branch 'spellbook'

Conflicts:
	src/org/leumasjaffe/charsheet/model/DDClass.java
	src/org/leumasjaffe/charsheet/view/ClassTab.java
	src/org/leumasjaffe/charsheet/view/builders/DialogBuilder.java
	src/org/leumasjaffe/charsheet/view/dev/DeveloperMenu.java
	src/org/leumasjaffe/format/StringFormatter.java
Sam Jaffe 8 years ago
parent
commit
4e1ec037bf
39 changed files with 2018 additions and 84 deletions
  1. 53 2
      resources/Potato.json
  2. 2 0
      resources/classes/Bard.json
  3. 1 0
      resources/classes/Cleric.json
  4. 22 0
      resources/spells/default.json
  5. 26 0
      src/org/leumasjaffe/charsheet/config/Config.java
  6. 3 2
      src/org/leumasjaffe/charsheet/model/DDCharacter.java
  7. 20 2
      src/org/leumasjaffe/charsheet/model/DDCharacterClass.java
  8. 9 1
      src/org/leumasjaffe/charsheet/model/DDClass.java
  9. 17 2
      src/org/leumasjaffe/charsheet/model/magic/DDSpell.java
  10. 1 0
      src/org/leumasjaffe/charsheet/model/magic/DDSpellList.java
  11. 25 10
      src/org/leumasjaffe/charsheet/model/magic/DDSpellbook.java
  12. 45 15
      src/org/leumasjaffe/charsheet/model/magic/Range.java
  13. 83 0
      src/org/leumasjaffe/charsheet/model/magic/impl/Inspired.java
  14. 15 0
      src/org/leumasjaffe/charsheet/model/magic/impl/Prepared.java
  15. 73 0
      src/org/leumasjaffe/charsheet/model/magic/impl/Researched.java
  16. 70 0
      src/org/leumasjaffe/charsheet/model/magic/impl/Spontaneous.java
  17. 5 0
      src/org/leumasjaffe/charsheet/util/StringHelper.java
  18. 13 9
      src/org/leumasjaffe/charsheet/view/ClassTab.java
  19. 42 2
      src/org/leumasjaffe/charsheet/view/D20Sheet.java
  20. 1 1
      src/org/leumasjaffe/charsheet/view/SkillTab.java
  21. 14 7
      src/org/leumasjaffe/charsheet/view/builders/DialogBuilder.java
  22. 3 2
      src/org/leumasjaffe/charsheet/view/dev/DeveloperMenu.java
  23. 105 0
      src/org/leumasjaffe/charsheet/view/magic/ChooseSpellsPerDayHeader.java
  24. 104 0
      src/org/leumasjaffe/charsheet/view/magic/PrepareSpellsDialog.java
  25. 235 0
      src/org/leumasjaffe/charsheet/view/magic/SelectPreparedSpellsPanel.java
  26. 342 0
      src/org/leumasjaffe/charsheet/view/magic/SpellInfoPanel.java
  27. 70 0
      src/org/leumasjaffe/charsheet/view/magic/SpellLevelPanel.java
  28. 29 0
      src/org/leumasjaffe/charsheet/view/magic/SpellLevelPerDayPanel.java
  29. 107 0
      src/org/leumasjaffe/charsheet/view/magic/SpellLine.java
  30. 48 0
      src/org/leumasjaffe/charsheet/view/magic/SpellMenu.java
  31. 83 0
      src/org/leumasjaffe/charsheet/view/magic/SpellPanel.java
  32. 91 0
      src/org/leumasjaffe/charsheet/view/magic/SpellsKnownHeader.java
  33. 107 0
      src/org/leumasjaffe/charsheet/view/magic/SpellsPerDayHeader.java
  34. 1 1
      src/org/leumasjaffe/charsheet/view/skills/SkillLevelUpLine.java
  35. 0 1
      src/org/leumasjaffe/charsheet/view/summary/DamageReductionLine.java
  36. 0 1
      src/org/leumasjaffe/charsheet/view/summary/SpellResistanceLine.java
  37. 87 26
      src/org/leumasjaffe/format/StringFormatter.java
  38. 36 0
      src/org/leumasjaffe/graphics/SelectTableRowPopupMenuListener.java
  39. 30 0
      test/org/leumasjaffe/format/StringFormatterTest.java

+ 53 - 2
resources/Potato.json

@@ -4,11 +4,60 @@
   "classes":[
     {
       "level":3,
-      "name":"Cleric"
+      "name":"Cleric",
+      "spellBook":{
+        "@class":"org.leumasjaffe.charsheet.model.magic.impl.Inspired",
+        "classRef":"Cleric",
+        "spellInfo":{
+          "0":{
+            "spellsPerDay":4,
+            "spellsPrepared":[
+              "Create Water"
+            ],
+            "spellsPreparedPreviously":[
+              "Create Water",
+              "Create Water",
+              "Create Water",
+              "Create Water"
+            ]
+          },
+          "1":{
+            "spellsPerDay":2,
+            "spellsPrepared":[],
+            "spellsPreparedPreviously":[]
+          },
+          "2":{
+            "spellsPerDay":1,
+            "spellsPrepared":[],
+            "spellsPreparedPreviously":[]
+          }
+        }
+      }
     },
     {
       "level":2,
-      "name":"Bard"
+      "name":"Bard",
+      "spellBook":{
+        "@class":"org.leumasjaffe.charsheet.model.magic.impl.Spontaneous",
+        "spellInfo":{
+          "0":{
+            "spellsPerDay":3,
+            "spellsPerDayRemaining":3,
+            "spellsKnown":[
+              "Know Direction",
+              "Know Direction",
+              "Know Direction",
+              "Know Direction",
+              "Know Direction"
+            ]
+          },
+          "1":{
+            "spellsPerDay":0,
+            "spellsPerDayRemaining":0,
+            "spellsKnown":[]
+          }
+        }
+      }
     }
   ],
   "race":"Half-Elemental (E)",
@@ -23,6 +72,8 @@
   "hair":"Plants",
   "skin":"Earthy",
   
+  "experience":10000,
+  
   "health":{
     "total":30,
     "rolled":20,

+ 2 - 0
resources/classes/Bard.json

@@ -44,6 +44,7 @@
   ],
   "spells":{
     "group":"ARCANE",
+    "ability":"CHA",
     "known":[
       [4],
       [5, 2]
@@ -53,6 +54,7 @@
       [3, 0]
     ],
     "spellList":[
+      ["Know Direction"]
     ]
   }
 }

+ 1 - 0
resources/classes/Cleric.json

@@ -26,6 +26,7 @@
   ],
   "spells":{
     "group":"DIVINE",
+    "ability":"WIS",
     "known":[ ],
     "perDay":[
       [3, 1],

+ 22 - 0
resources/spells/default.json

@@ -1,5 +1,11 @@
 {
   "Create Water":{
+    "name":"Create Water",
+    "classToLevel":{
+      "Cleric":0,
+      "Druid":0,
+      "Paladin":1
+    },
     "school":"Conjuration",
     "subSchool":"Creation",
     "keywords":["Water"],
@@ -11,5 +17,21 @@
     "savingThrow":"None",
     "spellResistence":false,
     "description":"This spell generates wholesome, drinkable water, just like clean rain water. Water can be created in an area as small as will actually contain the liquid, or in an area three times as large—possibly creating a downpour or filling many small receptacles. Note: Conjuration spells can't create substances or objects within a creature. Water weighs about 8 pounds per gallon. One cubic foot of water contains roughly 8 gallons and weighs about 60 pounds."
+  },
+  "Know Direction":{
+    "name":"Know Direction",
+    "classToLevel":{
+      "Bard":0,
+      "Druid":0
+    },
+    "school":"Divination",
+    "keywords":[],
+    "components":["V","S"],
+    "castingTime":"Standard",
+    "range":"Personal",
+    "target":"You",
+    "duration":"Instantaneous",
+    "savingThrow":"None",
+    "description":"You instantly know the direction of north from your current position. The spell is effective in any environment in which \"north\" exists, but it may not work in extraplanar settings. Your knowledge of north is correct at the moment of casting, but you can get lost again within moments if you don’t find some external reference point to help you keep track of direction."
   }
 }

+ 26 - 0
src/org/leumasjaffe/charsheet/config/Config.java

@@ -0,0 +1,26 @@
+package org.leumasjaffe.charsheet.config;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import lombok.experimental.UtilityClass;
+
+@UtilityClass
+public class Config {
+	public final String DISTANCE = "Distance Measurement Unit";
+	Map<String, Object> properties = new HashMap<>();
+	
+	public void set(String k, Object v) {
+		properties.put(k, v);
+	}
+	
+	@SuppressWarnings("unchecked")
+	public <T> T get(String k) {
+		return (T) properties.get(k);
+	}
+	
+	@SuppressWarnings("unchecked")
+	public <T> T get(String k, T or) {
+		return (T) properties.getOrDefault(k, or);
+	}
+}

+ 3 - 2
src/org/leumasjaffe/charsheet/model/DDCharacter.java

@@ -1,9 +1,10 @@
 package org.leumasjaffe.charsheet.model;
 
 import java.util.Collections;
-import java.util.HashSet;
 import java.util.Iterator;
 import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
 
 import org.leumasjaffe.charsheet.model.inventory.DDInventory;
 import org.leumasjaffe.charsheet.model.observable.IntValue;
@@ -28,7 +29,7 @@ public class DDCharacter {
 	
 	@NonNull String player = "";
 	
-	@NonNull @Getter(AccessLevel.NONE) Set<DDCharacterClass> classes = new HashSet<>();
+	@NonNull @Getter(AccessLevel.NONE) SortedSet<DDCharacterClass> classes = new TreeSet<>();
 	
 	@NonNull String race = "";
 	@NonNull Alignment alignment;

+ 20 - 2
src/org/leumasjaffe/charsheet/model/DDCharacterClass.java

@@ -1,5 +1,6 @@
 package org.leumasjaffe.charsheet.model;
 
+import java.util.List;
 import java.util.Optional;
 
 import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
@@ -13,7 +14,7 @@ import lombok.experimental.NonFinal;
 
 @Data
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
-public class DDCharacterClass {
+public class DDCharacterClass implements Comparable<DDCharacterClass> {
 	private static final class Reference {
 		DDClass base;
 		
@@ -43,7 +44,7 @@ public class DDCharacterClass {
 //	@NonNull List<Integer> healthRolls;
 	@Delegate @Getter(AccessLevel.NONE) Reference name;
 	
-	@Getter(AccessLevel.NONE) Optional<DDSpellbook> spellBook;
+	Optional<DDSpellbook> spellBook;
 	
 	public String toString() {
 		return getName() + " " + getLevel();
@@ -72,4 +73,21 @@ public class DDCharacterClass {
 	public boolean isClassSkill(final String skill) {
 		return name.base.isClassSkill(skill);
 	}
+
+	public DDClass getProto() {
+		return name.base;
+	}
+	
+	public int getHighestSpellLevel() {
+		// TODO: Bonus levels to spellsKnown/spellsPerDay?
+		// TODO: Bonus spellsPerDay for high ability scores
+		final List<Integer> list = getProto().getSpells().get().getPerDay().get(getLevel()-1);
+		final int level = list.size() - 1;
+		return list.get(level) == 0 ? level : level + 1;
+	}
+
+	@Override
+	public int compareTo(DDCharacterClass o) {
+		return getName().compareTo(o.getName());
+	}
 }

+ 9 - 1
src/org/leumasjaffe/charsheet/model/DDClass.java

@@ -2,13 +2,16 @@ package org.leumasjaffe.charsheet.model;
 
 import java.io.File;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
 
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
 import org.leumasjaffe.charsheet.model.magic.DDSpellList;
+import org.leumasjaffe.charsheet.model.magic.DDSpellList.SpellList;
 
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -37,7 +40,7 @@ public class DDClass {
 	
 	@NonNull Set<String> skills;
 	
-	@Getter(AccessLevel.NONE) @NonNull Optional<DDSpellList> spells;
+	@NonNull Optional<DDSpellList> spells;
 	
 	static Map<String, DDClass> store = new HashMap<>();
 	
@@ -51,6 +54,11 @@ public class DDClass {
 		return store.get(name);
 	}
 	
+	public Collection<DDSpell> getSpellList( int level ) {
+		List<SpellList> list = spells.get().getSpellList();
+		return list.size() <= level ? Collections.emptySet() : list.get( level ).getSpellList();
+	}
+	
 	public boolean isClassSkill(final String skillName) {
 		if (skillName.contains("(")) {
 			return skills.contains(skillName) || skills.contains(skillName.replaceFirst("\\(.*\\)", "(*)"));

+ 17 - 2
src/org/leumasjaffe/charsheet/model/magic/DDSpell.java

@@ -1,15 +1,18 @@
 package org.leumasjaffe.charsheet.model.magic;
 
 import java.util.EnumSet;
-import java.util.List;
+import java.util.Map;
 import java.util.Set;
 
+import com.fasterxml.jackson.annotation.JsonCreator;
 import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
 
 import lombok.AccessLevel;
 import lombok.Data;
+import lombok.Getter;
 import lombok.NonNull;
 import lombok.RequiredArgsConstructor;
+import lombok.Setter;
 import lombok.experimental.FieldDefaults;
 
 @Data
@@ -40,6 +43,8 @@ public class DDSpell {
 		S("Somatic"),
 		M("Material"), 
 		F("Focus"),
+		M_DF("Material/Divine Focus"),
+		F_DF("Focus/Divine Focus"),
 		DF("Divine Focus"),
 		XP("Experience Point");
 		
@@ -50,6 +55,8 @@ public class DDSpell {
 		}
 	}
 	
+	@NonNull String name;
+	@NonNull @Getter(AccessLevel.NONE) @Setter(AccessLevel.NONE) Map<String, Integer> classToLevel;
 	@NonNull School school;
 	SubSchool subSchool;
 	@NonNull Set<String> keywords;
@@ -59,7 +66,7 @@ public class DDSpell {
 	@NonNull Range range;
 	String effect; // TODO
 	Area area;
-	List<String> targets; // TODO
+	String target;
 	@NonNull String duration; // TODO
 	@NonNull String savingThrow; // TODO
 	boolean allowsSpellResistance;
@@ -68,4 +75,12 @@ public class DDSpell {
 	public String getSpellSchool() {
 		return subSchool == null ? school.toString() : school.toString() + " (" + subSchool.toString() + ")";
 	}
+	
+	public int getClassLevel(final String clas) {
+		return classToLevel.get(clas);
+	}
+	
+	@JsonCreator public static DDSpell fromString(String str) { 
+		return DDSpellFactory.loadSpell(str);
+	}
 }

+ 1 - 0
src/org/leumasjaffe/charsheet/model/magic/DDSpellList.java

@@ -16,6 +16,7 @@ import lombok.experimental.FieldDefaults;
 @JsonIgnoreProperties(ignoreUnknown=true)
 public class DDSpellList {
 	@NonNull Source group;
+	@NonNull String ability;
 	
 	@NonNull List<List<Integer>> known;
 	@NonNull List<List<Integer>> perDay;

+ 25 - 10
src/org/leumasjaffe/charsheet/model/magic/DDSpellbook.java

@@ -1,18 +1,33 @@
 package org.leumasjaffe.charsheet.model.magic;
 
+import java.util.Collection;
 import java.util.List;
 
-import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
 
-import lombok.AccessLevel;
-import lombok.Data;
 import lombok.NonNull;
-import lombok.experimental.FieldDefaults;
 
-@Data
-@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
-@JsonIgnoreProperties(ignoreUnknown=true)
-public class DDSpellbook {
-	@NonNull List<List<DDSpell>> spellsPrepared;
-	@NonNull List<List<DDSpell>> spellsAtCircle; 
+@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY)
+public interface DDSpellbook {
+	@NonNull Collection<DDSpell> spellsKnownAtLevel( int level );
+	@NonNull List<DDSpell> spellsPreparedAtLevel( int level );
+	
+	@NonNull default List<DDSpell> getSpellsPreparedPreviouslyForLevel(int level) { return spellsPreparedAtLevel(level); }	
+
+
+	default boolean preparesSpells() { return false; }
+	
+	default int numSpellsKnownAtLevel( int level ) {
+		return spellsKnownAtLevel( level ).size();
+	}
+	
+	int numSpellsPerDayAtLevel( int level );
+	
+	default int numSpellsPerDayRemainingAtLevel(int level) {
+		return spellsPreparedAtLevel( level ).size();
+	}
+	
+	void castSpell( int level, final DDSpell spell );
+	
+	void prepareSpells(int level, List<DDSpell> spells);
 }

+ 45 - 15
src/org/leumasjaffe/charsheet/model/magic/Range.java

@@ -2,14 +2,41 @@ package org.leumasjaffe.charsheet.model.magic;
 
 import java.util.Map;
 
+import org.leumasjaffe.charsheet.config.Config;
+import org.leumasjaffe.charsheet.util.StringHelper;
+
 import com.fasterxml.jackson.annotation.JsonCreator;
 
 import lombok.AccessLevel;
+import lombok.Getter;
 import lombok.NonNull;
 import lombok.RequiredArgsConstructor;
 import lombok.experimental.FieldDefaults;
 
 public interface Range {
+	public static final int DISTANCE_MEASUREMENT_FEET = 0x0, DISTANCE_MEASUREMENT_SQUARES = 0x1;
+	
+	@FieldDefaults(level=AccessLevel.PUBLIC, makeFinal=true)
+	public static final class __Pair {
+		int[] value;
+		String measure;
+		
+		__Pair(int... feet) {
+			value = new int[feet.length];
+			final int div;
+			if (Config.<Integer>get(Config.DISTANCE, DISTANCE_MEASUREMENT_FEET).equals(DISTANCE_MEASUREMENT_SQUARES)) {
+				div = 5;
+				measure = "squares";
+			} else {
+				div = 1;
+				measure = "ft.";
+			}
+			for (int i = 0; i < feet.length; ++i) {
+				value[i] = feet[i] / div;
+			}
+		}
+	}
+	
 	public static final Range Personal = new Basic("Personal"), 
 			Touch = new Basic("Touch"), 
 			Close = new WithLevelGrowth("Close", 25, 5, 1), 
@@ -17,6 +44,9 @@ public interface Range {
 			Long = new WithLevelGrowth("Long", 400, 40, 1), 
 			Unlimited = new Basic("Unlimited");
 	
+	public String getName();
+	public default String getResolved(int level) { return toString(); }
+
 	@JsonCreator
 	public static Range select(final String name) {
 		switch (name.toLowerCase()) {
@@ -45,7 +75,7 @@ public interface Range {
 	@RequiredArgsConstructor
 	@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 	public static class Basic implements Range {
-		@NonNull String name;
+		@NonNull @Getter String name;
 		
 		public String toString() {
 			return name;
@@ -55,31 +85,31 @@ public interface Range {
 	@RequiredArgsConstructor
 	@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 	public static class WithDistance implements Range {
-		@NonNull String name;
+		@NonNull @Getter String name;
 		int range;
 		
 		public String toString() {
-			final StringBuilder str = new StringBuilder(name);
-			str.append(" (").append(range).append(" ft.)");
-			return str.toString();
+			final __Pair p = new __Pair(range);
+			return StringHelper.format(" ({} {})", p.value[0], p.measure);
 		}
 	}
 	
 	@RequiredArgsConstructor
 	@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
 	public static class WithLevelGrowth implements Range {
-		@NonNull String name;
-		int range;
-		int per;
-		int step;
+		@NonNull @Getter String name;
+		int range, per, step;
+		
+		@Override
+		public String getResolved(int level) {
+			final __Pair p = new __Pair(range + (per * (level / step)));
+			return StringHelper.format("{} {}", p.value[0], p.measure);
+		}
 		
 		public String toString() {
-			final StringBuilder str = new StringBuilder(name);
-			str.append(" (").append(range).append(" ft.");
-			str.append(" + ").append(per).append(" ft./");
-			if ( step == 1 ) { str.append("level"); }
-			else { str.append(step).append(" levels"); }
-			str.append(')');
+			final __Pair p = new __Pair(range, per);
+			final StringBuilder str = new StringBuilder(StringHelper.format("{} ({} {3} + {} {}/{?level:{} levels})",
+					name, p.value[0], p.value[1], p.measure, step == 1, step));
 			return str.toString();
 		}
 	}

+ 83 - 0
src/org/leumasjaffe/charsheet/model/magic/impl/Inspired.java

@@ -0,0 +1,83 @@
+package org.leumasjaffe.charsheet.model.magic.impl;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.leumasjaffe.charsheet.model.DDClass;
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.NonNull;
+import lombok.experimental.FieldDefaults;
+import lombok.experimental.NonFinal;
+
+@AllArgsConstructor
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+public class Inspired implements Prepared {
+	@AllArgsConstructor
+	@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+	private static class Level {
+		@NonNull List<DDSpell> spellsPrepared, spellsPreparedPreviously;
+		@NonFinal int spellsPerDay;
+	}
+	
+	private static class ClassReference {
+		DDClass ref;
+		@SuppressWarnings("unused")
+		public ClassReference(String name) {
+			this.ref = DDClass.getFromResource(name);
+		}
+	}
+	
+	@NonNull Map<Integer, Inspired.Level> spellInfo;
+	@NonNull ClassReference classRef;
+
+	@Override
+	public int numSpellsPerDayAtLevel( int level ) {
+		return get(level).spellsPerDay;
+	}
+
+	@Override
+	public Collection<DDSpell> spellsKnownAtLevel(int level) {
+		return Collections.unmodifiableCollection(classRef.ref.getSpellList( level ));
+	}
+	
+	@Override
+	public int numSpellsKnownAtLevel( int level ) {
+		return numSpellsPerDayAtLevel(level) != 0 ? -1 : 0;
+	}
+
+	@Override
+	public List<DDSpell> spellsPreparedAtLevel(int level) {
+		return Collections.unmodifiableList(get(level).spellsPrepared);
+	}
+
+	@Override
+	public void castSpell(int level, DDSpell spell) {
+		if ( ! get(level).spellsPrepared.remove( spell ) ) {
+			throw new IllegalStateException("Attempting to cast an unprepared spell");
+		}
+		--get(level).spellsPerDay;
+	}
+	
+	private Level get(int level) {
+		return spellInfo.getOrDefault(level, new Level(Collections.emptyList(), Collections.emptyList(), 0));
+	}
+
+	@Override
+	public List<DDSpell> getSpellsPreparedPreviouslyForLevel(int level) {
+		return Collections.unmodifiableList(get(level).spellsPreparedPreviously);
+	}
+
+	@Override
+	public void prepareSpells(int level, List<DDSpell> spells) {
+		final Level lInfo = get(level);
+		lInfo.spellsPrepared.clear();
+		lInfo.spellsPreparedPreviously.clear();
+		lInfo.spellsPrepared.addAll(spells);
+		lInfo.spellsPreparedPreviously.addAll(spells);
+	}
+}

+ 15 - 0
src/org/leumasjaffe/charsheet/model/magic/impl/Prepared.java

@@ -0,0 +1,15 @@
+package org.leumasjaffe.charsheet.model.magic.impl;
+
+import java.util.List;
+
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
+
+import lombok.NonNull;
+
+public interface Prepared extends DDSpellbook {
+	@Override
+	default boolean preparesSpells() { return true; }
+
+	@NonNull List<DDSpell> getSpellsPreparedPreviouslyForLevel(int level);	
+}

+ 73 - 0
src/org/leumasjaffe/charsheet/model/magic/impl/Researched.java

@@ -0,0 +1,73 @@
+package org.leumasjaffe.charsheet.model.magic.impl;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.NonNull;
+import lombok.experimental.FieldDefaults;
+import lombok.experimental.NonFinal;
+
+@AllArgsConstructor
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+public class Researched implements Prepared {
+	@AllArgsConstructor
+	@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+	private static class Level {
+		@NonNull Collection<DDSpell> spellsKnown;
+		@NonNull List<DDSpell> spellsPrepared, spellsPreparedPreviously;
+		@NonFinal int spellsPerDay;
+	}
+	
+	@NonNull Map<Integer, Researched.Level> spellInfo;
+
+	@Override
+	public int numSpellsPerDayAtLevel( int level ) {
+		return get(level).spellsPerDay;
+	}
+
+	@Override
+	public Collection<DDSpell> spellsKnownAtLevel(int level) {
+		return Collections.unmodifiableCollection(get(level).spellsKnown);
+	}
+
+	@Override
+	public List<DDSpell> spellsPreparedAtLevel(int level) {
+		return Collections.unmodifiableList(get(level).spellsPrepared);
+	}
+
+	@Override
+	public void castSpell(int level, DDSpell spell) {
+		if ( ! get(level).spellsPrepared.remove( spell ) ) {
+			throw new IllegalStateException("Attempting to cast an unprepared spell");
+		}
+		--get(level).spellsPerDay;
+	}
+
+	private Level get(int level) {
+		return spellInfo.getOrDefault(level, new Level(Collections.emptySet(), Collections.emptyList(), Collections.emptyList(), 0));
+	}
+	
+
+	@Override
+	public List<DDSpell> getSpellsPreparedPreviouslyForLevel(int level) {
+		return Collections.unmodifiableList(get(level).spellsPreparedPreviously);
+	}
+
+	@Override
+	public void prepareSpells(int level, List<DDSpell> spells) {
+		final Level lInfo = get(level);
+		if (!lInfo.spellsKnown.containsAll(spells)) {
+			throw new IllegalArgumentException("Attempted to prepare spells that you don't know");
+		}
+		lInfo.spellsPrepared.clear();
+		lInfo.spellsPreparedPreviously.clear();
+		lInfo.spellsPrepared.addAll(spells);
+		lInfo.spellsPreparedPreviously.addAll(spells);
+	}
+}

+ 70 - 0
src/org/leumasjaffe/charsheet/model/magic/impl/Spontaneous.java

@@ -0,0 +1,70 @@
+package org.leumasjaffe.charsheet.model.magic.impl;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.NonNull;
+import lombok.experimental.FieldDefaults;
+import lombok.experimental.NonFinal;
+
+@AllArgsConstructor
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+public class Spontaneous implements DDSpellbook {
+	@AllArgsConstructor
+	@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+	private static class Level {
+		@NonNull List<DDSpell> spellsKnown;
+		@NonFinal int spellsPerDay, spellsPerDayRemaining;
+	}
+	
+	@NonNull Map<Integer, Spontaneous.Level> spellInfo;
+	
+	@Override
+	public int numSpellsPerDayAtLevel(int level) {
+		return get(level).spellsPerDay;
+	}
+	
+	@Override
+	public int numSpellsPerDayRemainingAtLevel(int level) {
+		return get(level).spellsPerDayRemaining;
+	}
+
+	@Override
+	public Collection<DDSpell> spellsKnownAtLevel(int level) {
+		return spellsPreparedAtLevel(level);
+	}
+
+	@Override
+	public List<DDSpell> spellsPreparedAtLevel(int level) {
+		return Collections.unmodifiableList(get(level).spellsKnown);
+	}
+
+	@Override
+	public void castSpell(int level, DDSpell spell) {
+		if (numSpellsPerDayRemainingAtLevel(level) == 0) {
+			throw new IllegalStateException("Attempting to cast a spell while out of slots");
+		}
+		--get(level).spellsPerDayRemaining;
+	}
+	
+	public void renew() {
+		spellInfo.values().stream().forEach(lInfo -> lInfo.spellsPerDayRemaining = lInfo.spellsPerDay);
+	}
+
+	private Level get(int level) {
+		return spellInfo.getOrDefault(level, new Level(Collections.emptyList(), 0, 0));
+	}
+
+	@Override
+	public void prepareSpells(int level, List<DDSpell> spells) {
+		final Level lInfo = get(level);
+		lInfo.spellsPerDayRemaining = lInfo.spellsPerDay;
+	}
+}

+ 5 - 0
src/org/leumasjaffe/charsheet/util/StringHelper.java

@@ -19,6 +19,11 @@ public class StringHelper {
 		return Integer.toString(i);
 	}
 	
+	public String toString(int i, int ignore, String elseValue) {
+		if ( i == ignore ) { return elseValue; }
+		else { return Integer.toString(i); }
+	}
+
 	public String toString(int i, int ignore) {
 		if ( i == ignore ) { return ""; }
 		else { return Integer.toString(i); }

+ 13 - 9
src/org/leumasjaffe/charsheet/view/ClassTab.java

@@ -7,8 +7,10 @@ import lombok.experimental.FieldDefaults;
 import java.awt.GridBagLayout;
 import javax.swing.JTextField;
 
+import org.leumasjaffe.charsheet.model.DDCharacter;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
 import org.leumasjaffe.charsheet.util.StringHelper;
+import org.leumasjaffe.charsheet.view.magic.SpellPanel;
 
 import java.awt.GridBagConstraints;
 import java.awt.Insets;
@@ -28,14 +30,14 @@ public class ClassTab extends JPanel {
 	String title;
 	private JTextField experienceField;
 	
-	public ClassTab(DDCharacterClass model) {
+	public ClassTab(DDCharacter chara, DDCharacterClass model) {
 		this.title = model.getName();
 		
 		GridBagLayout gridBagLayout = new GridBagLayout();
-		gridBagLayout.columnWidths = new int[]{0, 0, 0};
-		gridBagLayout.rowHeights = new int[]{0, 0};
-		gridBagLayout.columnWeights = new double[]{0.0, 1.0, Double.MIN_VALUE};
-		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		gridBagLayout.columnWidths = new int[]{0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0, 0, 0};
+		gridBagLayout.columnWeights = new double[]{1.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, 1.0, 1.0, Double.MIN_VALUE};
 		setLayout(gridBagLayout);
 		
 		JPanel header = new JPanel();
@@ -69,10 +71,10 @@ public class ClassTab extends JPanel {
 		GridBagConstraints gbc_nameField = new GridBagConstraints();
 		gbc_nameField.fill = GridBagConstraints.HORIZONTAL;
 		gbc_nameField.insets = new Insets(0, 0, 0, 5);
-		gbc_nameField.fill = GridBagConstraints.HORIZONTAL;
-		gbc_nameField.gridx = 0;
+		gbc_nameField.gridx = 1;
 		gbc_nameField.gridy = 0;
-		add(nameField, gbc_nameField);
+		header.add(nameField, gbc_nameField);
+		nameField.setEditable(false);
 		nameField.setColumns(10);
 		
 		nameField.setText(model.getName());
@@ -102,8 +104,10 @@ public class ClassTab extends JPanel {
 		gbc_levelField.insets = new Insets(0, 0, 0, 5);
 		gbc_levelField.gridx = 4;
 		gbc_levelField.gridy = 0;
-		add(levelField, gbc_levelField);
+		header.add(levelField, gbc_levelField);
+		levelField.setEditable(false);
 		levelField.setColumns(10);
+		levelField.setText(StringHelper.toString(model.getLevel()));
 		
 		Component horizontalStrut_1 = Box.createHorizontalStrut(20);
 		GridBagConstraints gbc_horizontalStrut_1 = new GridBagConstraints();

+ 42 - 2
src/org/leumasjaffe/charsheet/view/D20Sheet.java

@@ -19,8 +19,11 @@ import javax.swing.JMenuBar;
 import javax.swing.JMenu;
 import javax.swing.JMenuItem;
 import javax.swing.KeyStroke;
+import javax.swing.UIManager;
 
 import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.view.builders.DialogBuilder;
 import org.leumasjaffe.charsheet.view.dev.DeveloperMenu;
 
 import java.awt.event.KeyEvent;
@@ -49,6 +52,7 @@ public class D20Sheet extends JFrame {
 	private DeveloperMenu developerMenu;
 	
 	public D20Sheet() {
+		UIManager.put("CheckBox.disabledText", UIManager.get("CheckBox.foreground"));
 		// Set up local state variables
 		final JFileChooser fc = new JFileChooser("resources");
 		mapper.registerModule(new Jdk8Module());
@@ -102,7 +106,39 @@ public class D20Sheet extends JFrame {
 		mntmExit.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_Q, InputEvent.CTRL_MASK));
 		mnFile.add(mntmExit);
 		
-		developerMenu = new DeveloperMenu();
+		JMenu mnSession = new JMenu("Session");
+		menuBar.add(mnSession);
+		
+		JMenuItem mntmGainExperience = new JMenuItem("Gain Experience");
+		mnSession.add(mntmGainExperience);
+		
+		JMenuItem mntmTakeRest = new JMenuItem("Take Rest");
+		mntmTakeRest.addActionListener(e -> {
+			// Step 0: Gather information about the rest
+			// Step 0.1: Check if 8-hour or full day
+			// Step 0.2: Check if Long-Term-Care
+			// Step 1: Heal Character according to info
+			// Step 1.1: Heal non-lethal damage
+			// Step 1.2: Heal ability damage
+			// Step 2: Regenerate spells prepared
+			// Step 2.1: If Spontaneous, skip (2)
+			for (DDCharacterClass dclass : model.getClasses()) {
+				dclass.getSpellBook().ifPresent(sb -> {
+					if (sb.preparesSpells()) {
+						DialogBuilder.createPrepareSpellsDialog(this, model, dclass);
+					} else {
+						for (int i = 0; i < dclass.getHighestSpellLevel(); ++i) {
+							sb.prepareSpells(i, null);
+						}
+					}
+				});
+			}
+			// Step N: regenerate spellbooks
+			reload();
+		});
+		mnSession.add(mntmTakeRest);
+		
+		developerMenu = new DeveloperMenu(this);
 		menuBar.add(developerMenu);
 		
 		// Set up post-GUI dependencies
@@ -144,11 +180,15 @@ public class D20Sheet extends JFrame {
 		}
 	}
 	
+	public void reload() {
+		setModel(this.model); // TODO: make this not awful
+	}
+	
 	private void setModel(DDCharacter model) {
 		classTabs.clear();
 		this.model = model;
 		summaryTab.setModel(model);
-		model.getClasses().stream().forEach(cc -> classTabs.add(new ClassTab(cc)));
+		model.getClasses().stream().forEach(cc -> classTabs.add(new ClassTab(model, cc)));
 		equipmentTab.setModel(model);
 		skillTab.setModel(model);
 		developerMenu.setModel(model);

+ 1 - 1
src/org/leumasjaffe/charsheet/view/SkillTab.java

@@ -125,7 +125,7 @@ public class SkillTab extends JPanel {
 	}
 	
 	public void setModel(final DDCharacter model) {
-		// TODO (sjaffe): Observables
+		// TODO (sjaffe): Observables?
 		skillPanel.removeAll();
 		int[] totalPoints = {0};
 		final DDSkills skills = model.getSkills();

+ 14 - 7
src/org/leumasjaffe/charsheet/view/builders/DialogBuilder.java

@@ -1,28 +1,35 @@
 package org.leumasjaffe.charsheet.view.builders;
 
+import java.awt.Dialog.ModalityType;
+
 import javax.swing.JDialog;
+import javax.swing.JFrame;
 import javax.swing.JPanel;
 
 import org.leumasjaffe.charsheet.model.DDCharacter;
 import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.view.magic.PrepareSpellsDialog;
 import org.leumasjaffe.charsheet.view.skills.SkillLevelUpDialog;
 
 import lombok.experimental.UtilityClass;
 
 @UtilityClass
 public class DialogBuilder {
-	private void createDialogue(final String title, final int x, final int y, final JPanel panel) {
-		final JDialog dialog = new JDialog();
+	private void createDialogue(final JFrame parent, final String title, final JPanel panel) {
+		final JDialog dialog = new JDialog(parent);
 		dialog.setTitle(title);
-		dialog.setModalityType(JDialog.DEFAULT_MODALITY_TYPE);
-		dialog.setSize(x, y);
+		dialog.setModalityType(ModalityType.DOCUMENT_MODAL);
 
 		dialog.setContentPane(panel);
-		dialog.setVisible(true);
 		dialog.pack();
+		dialog.setVisible(true);
+	}
+	
+	public void createSkillDialog(final JFrame parent, DDCharacter chara, DDCharacterClass dclass) {
+		createDialogue(parent, "Level Up - Skill Allocation", new SkillLevelUpDialog(chara, dclass));
 	}
 	
-	public void createSkillDialog(DDCharacter chara, DDCharacterClass dclass) {
-		createDialogue("Level Up - Skill Allocation", 510, 600, new SkillLevelUpDialog(chara, dclass));
+	public void createPrepareSpellsDialog(final JFrame parent, DDCharacter chara, DDCharacterClass dclass) {
+		createDialogue(parent, "Prepare Spells - " + dclass.getName(), new PrepareSpellsDialog(chara, dclass));
 	}
 }

+ 3 - 2
src/org/leumasjaffe/charsheet/view/dev/DeveloperMenu.java

@@ -2,6 +2,7 @@ package org.leumasjaffe.charsheet.view.dev;
 
 import java.util.stream.Collectors;
 
+import javax.swing.JFrame;
 import javax.swing.JMenu;
 import javax.swing.JMenuItem;
 import javax.swing.JOptionPane;
@@ -17,7 +18,7 @@ public class DeveloperMenu extends JMenu {
 	private static final long serialVersionUID = 1L;
 	DDCharacter[] model = { null };
 	
-	public DeveloperMenu() {
+	public DeveloperMenu(final JFrame parent) {
 		super("Developer");
 		JMenuItem mntmLevelUp = new JMenuItem("Level Up - Skill");
 		mntmLevelUp.addActionListener( e -> {
@@ -25,7 +26,7 @@ public class DeveloperMenu extends JMenu {
 			String clazz = (String) JOptionPane.showInputDialog(this.getParent(), "Which Class is Leveling Up?", "Level Up - Skill", 
 					JOptionPane.QUESTION_MESSAGE, null, choices, choices[0]);
 			if (clazz != null) {
-				DialogBuilder.createSkillDialog(model[0], model[0].getClasses().stream().filter(c -> c.getName().equals(clazz)).findFirst().get());
+				DialogBuilder.createSkillDialog(parent, model[0], model[0].getClasses().stream().filter(c -> c.getName().equals(clazz)).findFirst().get());
 			}
 		});
 		add(mntmLevelUp);

+ 105 - 0
src/org/leumasjaffe/charsheet/view/magic/ChooseSpellsPerDayHeader.java

@@ -0,0 +1,105 @@
+package org.leumasjaffe.charsheet.view.magic;
+
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+
+import org.leumasjaffe.charsheet.model.Ability;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
+import org.leumasjaffe.charsheet.model.observable.IntValue;
+import org.leumasjaffe.charsheet.util.StringHelper;
+
+import java.awt.Dimension;
+
+class ChooseSpellsPerDayHeader extends JPanel {
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 1L;
+	private JTextField textFieldKnown;
+
+	public ChooseSpellsPerDayHeader(int level, DDSpellbook model, IntValue ability) {
+		setPreferredSize(new Dimension(450, 20));
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 30, 0, 30, 0, 30, 0, 0, 0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0};
+		gridBagLayout.columnWeights = new double[]{0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JLabel lblSpellLevel = new JLabel("Spell Level:");
+		GridBagConstraints gbc_lblSpellLevel = new GridBagConstraints();
+		gbc_lblSpellLevel.insets = new Insets(0, 0, 0, 5);
+		gbc_lblSpellLevel.anchor = GridBagConstraints.EAST;
+		gbc_lblSpellLevel.gridx = 0;
+		gbc_lblSpellLevel.gridy = 0;
+		add(lblSpellLevel, gbc_lblSpellLevel);
+		
+		JTextField textFieldLevel = new JTextField(Integer.toString(level));
+		textFieldLevel.setEditable(false);
+		GridBagConstraints gbc_textFieldLevel = new GridBagConstraints();
+		gbc_textFieldLevel.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textFieldLevel.insets = new Insets(0, 0, 0, 5);
+		gbc_textFieldLevel.gridx = 1;
+		gbc_textFieldLevel.gridy = 0;
+		add(textFieldLevel, gbc_textFieldLevel);
+		textFieldLevel.setColumns(10);
+		
+		JLabel lblSaveDc = new JLabel("Save DC:");
+		GridBagConstraints gbc_lblSaveDc = new GridBagConstraints();
+		gbc_lblSaveDc.anchor = GridBagConstraints.EAST;
+		gbc_lblSaveDc.insets = new Insets(0, 0, 0, 5);
+		gbc_lblSaveDc.gridx = 2;
+		gbc_lblSaveDc.gridy = 0;
+		add(lblSaveDc, gbc_lblSaveDc);
+		
+		JTextField textFieldSpellSave = new JTextField(Integer.toString(10 + level + Ability.modifier(ability.value())));
+		textFieldSpellSave.setEditable(false);
+		textFieldSpellSave.setColumns(10);
+		GridBagConstraints gbc_textFieldSpellSave = new GridBagConstraints();
+		gbc_textFieldSpellSave.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textFieldSpellSave.insets = new Insets(0, 0, 0, 5);
+		gbc_textFieldSpellSave.gridx = 3;
+		gbc_textFieldSpellSave.gridy = 0;
+		add(textFieldSpellSave, gbc_textFieldSpellSave);
+		
+		JLabel lblSpellsPerDay = new JLabel("Spells Per Day:");
+		GridBagConstraints gbc_lblSpellsPerDay = new GridBagConstraints();
+		gbc_lblSpellsPerDay.insets = new Insets(0, 0, 0, 5);
+		gbc_lblSpellsPerDay.gridx = 4;
+		gbc_lblSpellsPerDay.gridy = 0;
+		add(lblSpellsPerDay, gbc_lblSpellsPerDay);
+		
+		JTextField textFieldOutOf = new JTextField(Integer.toString(model.numSpellsPerDayAtLevel(level)));
+		GridBagConstraints gbc_textFieldOutOf = new GridBagConstraints();
+		gbc_textFieldOutOf.insets = new Insets(0, 0, 0, 5);
+		gbc_textFieldOutOf.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textFieldOutOf.gridx = 5;
+		gbc_textFieldOutOf.gridy = 0;
+		add(textFieldOutOf, gbc_textFieldOutOf);
+		textFieldOutOf.setEditable(false);
+		textFieldOutOf.setColumns(10);
+		
+		JLabel lblSpellsKnown = new JLabel("Spells Known:");
+		GridBagConstraints gbc_lblSpellsKnown = new GridBagConstraints();
+		gbc_lblSpellsKnown.anchor = GridBagConstraints.EAST;
+		gbc_lblSpellsKnown.insets = new Insets(0, 0, 0, 5);
+		gbc_lblSpellsKnown.gridx = 6;
+		gbc_lblSpellsKnown.gridy = 0;
+		add(lblSpellsKnown, gbc_lblSpellsKnown);
+		
+		textFieldKnown = new JTextField(StringHelper.toString(model.numSpellsKnownAtLevel(level), -1, "--"));
+		textFieldKnown.setEditable(false);
+		textFieldKnown.setColumns(10);
+		GridBagConstraints gbc_textFieldKnown = new GridBagConstraints();
+		gbc_textFieldKnown.insets = new Insets(0, 0, 0, 5);
+		gbc_textFieldKnown.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textFieldKnown.gridx = 7;
+		gbc_textFieldKnown.gridy = 0;
+		add(textFieldKnown, gbc_textFieldKnown);
+	}
+}

+ 104 - 0
src/org/leumasjaffe/charsheet/view/magic/PrepareSpellsDialog.java

@@ -0,0 +1,104 @@
+package org.leumasjaffe.charsheet.view.magic;
+
+import javax.swing.JPanel;
+
+import org.jdesktop.swingx.VerticalLayout;
+import org.leumasjaffe.charsheet.model.Ability;
+import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.magic.impl.Prepared;
+import org.leumasjaffe.charsheet.model.observable.IntValue;
+
+import lombok.AccessLevel;
+import lombok.experimental.FieldDefaults;
+
+import java.awt.GridBagLayout;
+import javax.swing.JScrollPane;
+
+import java.awt.GridBagConstraints;
+import java.awt.Insets;
+import java.util.ArrayList;
+import java.util.List;
+
+import javax.swing.JButton;
+import javax.swing.JDialog;
+import javax.swing.ScrollPaneConstants;
+
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+public class PrepareSpellsDialog extends JPanel {
+
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 1L;
+	
+	int[] ready = {0};
+	int highestSpellLevel;
+
+	public PrepareSpellsDialog(DDCharacter chara, DDCharacterClass dclass) {
+		highestSpellLevel = dclass.getHighestSpellLevel();
+		ready[0] = highestSpellLevel;
+		
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0, 0};
+		gridBagLayout.columnWeights = new double[]{1.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, 1.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JPanel panel_1 = new JPanel();
+		GridBagConstraints gbc_panel_1 = new GridBagConstraints();
+		gbc_panel_1.insets = new Insets(0, 0, 5, 0);
+		gbc_panel_1.fill = GridBagConstraints.BOTH;
+		gbc_panel_1.gridx = 0;
+		gbc_panel_1.gridy = 0;
+		add(panel_1, gbc_panel_1);
+		GridBagLayout gbl_panel_1 = new GridBagLayout();
+		gbl_panel_1.columnWidths = new int[]{0, 0};
+		gbl_panel_1.rowHeights = new int[]{0, 0};
+		gbl_panel_1.columnWeights = new double[]{0.0, Double.MIN_VALUE};
+		gbl_panel_1.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		panel_1.setLayout(gbl_panel_1);
+		
+		JButton btnPrepareTheseSpells = new JButton("Prepare These Spells");
+		GridBagConstraints gbc_btnPrepareTheseSpells = new GridBagConstraints();
+		gbc_btnPrepareTheseSpells.gridx = 0;
+		gbc_btnPrepareTheseSpells.gridy = 0;
+		panel_1.add(btnPrepareTheseSpells, gbc_btnPrepareTheseSpells);
+		
+		JScrollPane scrollPane = new JScrollPane();
+		scrollPane.setVerticalScrollBarPolicy(ScrollPaneConstants.VERTICAL_SCROLLBAR_ALWAYS);
+		scrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
+		GridBagConstraints gbc_scrollPane = new GridBagConstraints();
+		gbc_scrollPane.fill = GridBagConstraints.BOTH;
+		gbc_scrollPane.gridx = 0;
+		gbc_scrollPane.gridy = 1;
+		add(scrollPane, gbc_scrollPane);
+		
+		JPanel panel = new JPanel(new VerticalLayout(5));
+		scrollPane.setViewportView(panel);
+		
+		final IntValue value = Ability.fields.get(dclass.getProto().getSpells().get().getAbility()).apply(chara.getAbilities().getBase());
+
+		final Prepared spellBook = (Prepared) dclass.getSpellBook().get();
+		List<SelectPreparedSpellsPanel> panels = new ArrayList<>();
+		for (int i = 0; i < highestSpellLevel; ++i) {
+			SelectPreparedSpellsPanel lvl = new SelectPreparedSpellsPanel(i, dclass, value);
+			panels.add(lvl);
+			lvl.addPropertyChangeListener(SelectPreparedSpellsPanel.READY, e -> {
+				if ((Boolean) e.getNewValue()) ++ready[0];
+				else --ready[0];
+				btnPrepareTheseSpells.setEnabled(ready[0] == highestSpellLevel);
+			});
+			panel.add(lvl);
+		}
+		
+		btnPrepareTheseSpells.addActionListener(e -> {
+			((JDialog) this.getParent().getParent().getParent()).dispose();
+			for (int i = 0; i < highestSpellLevel; ++i) {
+				spellBook.prepareSpells(i, panels.get(i).getPrepared());
+			}
+		});
+	}
+
+}

+ 235 - 0
src/org/leumasjaffe/charsheet/view/magic/SelectPreparedSpellsPanel.java

@@ -0,0 +1,235 @@
+package org.leumasjaffe.charsheet.view.magic;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import javax.swing.JPanel;
+import javax.swing.JPopupMenu;
+import javax.swing.JTable;
+import javax.swing.ListSelectionModel;
+
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+import org.leumasjaffe.charsheet.model.magic.impl.Prepared;
+import org.leumasjaffe.charsheet.model.observable.IntValue;
+import org.leumasjaffe.charsheet.util.StringHelper;
+import org.leumasjaffe.graphics.SelectTableRowPopupMenuListener;
+
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.experimental.FieldDefaults;
+import java.awt.GridBagLayout;
+import java.awt.Dimension;
+import java.awt.GridBagConstraints;
+import java.awt.Insets;
+
+import javax.swing.border.BevelBorder;
+import javax.swing.border.SoftBevelBorder;
+import javax.swing.table.AbstractTableModel;
+import javax.swing.JButton;
+import javax.swing.JFrame;
+import javax.swing.JMenuItem;
+import javax.swing.JOptionPane;
+import javax.swing.JScrollPane;
+
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+class SelectPreparedSpellsPanel extends JPanel {
+	
+	@AllArgsConstructor
+	private static class SelectSpellModel extends AbstractTableModel {
+		/**
+		 * 
+		 */
+		private static final long serialVersionUID = 1L;
+		
+		final Object[] data;
+
+		@Override
+		public int getRowCount() {
+			return data.length;
+		}
+
+		@Override
+		public int getColumnCount() {
+			return 1;
+		}
+
+		@Override
+		public Object getValueAt(int rowIndex, int columnIndex) {
+			if (columnIndex != 0) { throw new IllegalArgumentException("There is only 1 column"); }
+			return data[rowIndex];
+		}
+		
+		@Override
+		public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
+			if (columnIndex != 0) { throw new IllegalArgumentException("There is only 1 column"); }
+			data[rowIndex] = aValue;
+		}
+	}
+
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 1L;
+	
+	public static final String READY = "Is Filled Out";
+	
+	@Getter List<DDSpell> prepared;
+	
+	SelectSpellModel modelPrepared, modelKnown;
+
+	public SelectPreparedSpellsPanel(int level, DDCharacterClass dclass, IntValue ability) {
+		putClientProperty(READY, true);
+		final Prepared spellBook = (Prepared) dclass.getSpellBook().get();
+		this.prepared = new ArrayList<>(spellBook.getSpellsPreparedPreviouslyForLevel(level));
+		final List<DDSpell> known = new ArrayList<>(spellBook.spellsKnownAtLevel(level));
+		this.modelPrepared = new SelectSpellModel(prepared.stream().map(DDSpell::getName).toArray());
+		this.modelKnown = new SelectSpellModel(known.stream().map(DDSpell::getName).toArray());
+		
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 40, 0, 0};
+		gridBagLayout.rowHeights = new int[]{20, 0, 0};
+		gridBagLayout.columnWeights = new double[]{1.0, 0.0, 1.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, 1.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JPanel panel = new ChooseSpellsPerDayHeader(level, spellBook, ability);
+		GridBagConstraints gbc_panel = new GridBagConstraints();
+		gbc_panel.gridwidth = 3;
+		gbc_panel.insets = new Insets(0, 0, 5, 5);
+		gbc_panel.fill = GridBagConstraints.BOTH;
+		gbc_panel.gridx = 0;
+		gbc_panel.gridy = 0;
+		add(panel, gbc_panel);
+		
+		JScrollPane scrollPane_1 = new JScrollPane();
+		scrollPane_1.setPreferredSize(new Dimension(175, 200));
+		scrollPane_1.setViewportBorder(new SoftBevelBorder(BevelBorder.LOWERED, null, null, null, null));
+		GridBagConstraints gbc_scrollPane_1 = new GridBagConstraints();
+		gbc_scrollPane_1.insets = new Insets(0, 0, 0, 5);
+		gbc_scrollPane_1.fill = GridBagConstraints.BOTH;
+		gbc_scrollPane_1.gridx = 0;
+		gbc_scrollPane_1.gridy = 1;
+		add(scrollPane_1, gbc_scrollPane_1);
+		
+		JTable tablePrepared = new JTable(modelPrepared);
+		tablePrepared.setTableHeader(null);
+		scrollPane_1.setViewportView(tablePrepared);
+		tablePrepared.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
+
+		JPanel panelDivider = new JPanel();
+		GridBagConstraints gbc_panelDivider = new GridBagConstraints();
+		gbc_panelDivider.insets = new Insets(0, 0, 0, 5);
+		gbc_panelDivider.fill = GridBagConstraints.BOTH;
+		gbc_panelDivider.gridx = 1;
+		gbc_panelDivider.gridy = 1;
+		add(panelDivider, gbc_panelDivider);
+		GridBagLayout gbl_panelDivider = new GridBagLayout();
+		gbl_panelDivider.columnWidths = new int[]{0, 0};
+		gbl_panelDivider.rowHeights = new int[]{0, 0, 0, 0, 0};
+		gbl_panelDivider.columnWeights = new double[]{0.0, Double.MIN_VALUE};
+		gbl_panelDivider.rowWeights = new double[]{1.0, 0.0, 0.0, 1.0, Double.MIN_VALUE};
+		panelDivider.setLayout(gbl_panelDivider);		
+		
+		JScrollPane scrollPane = new JScrollPane();
+		scrollPane.setPreferredSize(new Dimension(175, 200));
+		scrollPane.setViewportBorder(new SoftBevelBorder(BevelBorder.LOWERED, null, null, null, null));
+		GridBagConstraints gbc_scrollPane = new GridBagConstraints();
+		gbc_scrollPane.insets = new Insets(0, 0, 0, 5);
+		gbc_scrollPane.fill = GridBagConstraints.BOTH;
+		gbc_scrollPane.gridx = 2;
+		gbc_scrollPane.gridy = 1;
+		add(scrollPane, gbc_scrollPane);
+		
+		JTable tableKnown = new JTable(modelKnown);
+		tableKnown.setTableHeader(null);
+		scrollPane.setViewportView(tableKnown);
+		tableKnown.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
+		
+		JPopupMenu popup = new JPopupMenu();
+		popup.addPopupMenuListener(new SelectTableRowPopupMenuListener(popup, tableKnown));
+		JMenuItem mntmInfo = new JMenuItem("Info");
+		mntmInfo.addActionListener( e -> {
+			DDSpell spell = known.get(tableKnown.getSelectedRow());
+			JFrame frame = new JFrame(spell.getName() +  " (" + dclass.getName() + " " + level + ")");
+			frame.add(new SpellInfoPanel(dclass, spell));
+			frame.pack();
+			frame.setVisible(true);
+		});
+		popup.add(mntmInfo);
+		tableKnown.setComponentPopupMenu(popup);
+		
+		JButton button = new JButton(">>");
+		button.setMargin(new Insets(2, 8, 2, 8));
+		GridBagConstraints gbc_button = new GridBagConstraints();
+		gbc_button.insets = new Insets(0, 0, 5, 0);
+		gbc_button.gridx = 0;
+		gbc_button.gridy = 1;
+		panelDivider.add(button, gbc_button);
+		button.addActionListener(e -> {
+			final int row = tablePrepared.getSelectedRow();
+			if (row != -1) {
+				modelPrepared.setValueAt("<none>", row, 0);
+			}
+			tablePrepared.getSelectionModel().clearSelection();
+			tablePrepared.repaint();
+			if ((Boolean) getClientProperty(READY)) {
+				putClientProperty(READY, false);
+			}
+		});
+		
+		JButton button_1 = new JButton("<<");
+		button_1.setMargin(new Insets(2, 8, 2, 8));
+		GridBagConstraints gbc_button_1 = new GridBagConstraints();
+		gbc_button_1.insets = new Insets(0, 0, 5, 0);
+		gbc_button_1.gridx = 0;
+		gbc_button_1.gridy = 2;
+		panelDivider.add(button_1, gbc_button_1);
+		
+		button_1.addActionListener(e -> {
+			final int[] rows = tableKnown.getSelectedRows();
+			final int[] orows = tablePrepared.getSelectedRows();
+			if (orows.length >= rows.length) {
+				for (int i = 0; i < rows.length; ++i) {
+					modelPrepared.data[orows[i]] = modelKnown.data[rows[i]];
+				}
+			} else if (orows.length == 0 && countNone() >= rows.length) {
+				replace(rows);
+			} else {
+				final String message = StringHelper.format("Unable to assign new spells, more spells were selected ({}) than were avaliable ({})", 
+						rows.length, orows.length == 0 ? countNone() : orows.length);
+				JOptionPane.showMessageDialog(this, message, "Error", JOptionPane.ERROR_MESSAGE);
+			}
+			tableKnown.getSelectionModel().clearSelection();
+			tablePrepared.getSelectionModel().clearSelection();
+			tablePrepared.repaint();
+			
+			if (!(Boolean) getClientProperty(READY) && !Arrays.asList(modelPrepared.data).contains("<none>")) {
+				this.prepared.clear();
+				for (Object o : modelPrepared.data) {
+					this.prepared.add(DDSpell.fromString((String) o)); // TODO
+				}
+				putClientProperty(READY, true);
+			}
+		});
+	}
+
+	private void replace(int[] rows) {
+		for (int i = 0; i < rows.length; ++i) {
+			for (int j = 0; j < modelPrepared.data.length; ++j) {
+				if (!modelPrepared.data[j].equals("<none>")) continue;
+				modelPrepared.data[j] = modelKnown.data[i];
+			}
+		}
+	}
+	
+	private int countNone() {
+		int cnt = 0;
+		for (Object o : modelPrepared.data) {
+			if (o.equals("<none>")) ++cnt;
+		}
+		return cnt;
+	}
+}

+ 342 - 0
src/org/leumasjaffe/charsheet/view/magic/SpellInfoPanel.java

@@ -0,0 +1,342 @@
+package org.leumasjaffe.charsheet.view.magic;
+
+import javax.swing.JPanel;
+import java.awt.GridBagLayout;
+import java.awt.GridBagConstraints;
+import java.awt.Insets;
+import java.util.Set;
+
+import javax.swing.JTextArea;
+
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+import org.leumasjaffe.charsheet.model.magic.DDSpell.Component;
+import org.leumasjaffe.charsheet.model.magic.Source;
+
+import javax.swing.JScrollPane;
+import javax.swing.JLabel;
+import javax.swing.JTextField;
+import java.awt.Font;
+import java.awt.FlowLayout;
+import javax.swing.JCheckBox;
+
+import static org.leumasjaffe.charsheet.model.magic.DDSpell.Component.*; 
+
+class SpellInfoPanel extends JPanel {
+
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 1L;
+
+	public SpellInfoPanel(DDCharacterClass dclass, final DDSpell spell) {
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0, 0};
+		gridBagLayout.columnWeights = new double[]{1.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{1.0, 1.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JPanel panel = new JPanel();
+		GridBagConstraints gbc_panel = new GridBagConstraints();
+		gbc_panel.insets = new Insets(0, 0, 5, 0);
+		gbc_panel.fill = GridBagConstraints.BOTH;
+		gbc_panel.gridx = 0;
+		gbc_panel.gridy = 0;
+		add(panel, gbc_panel);
+		GridBagLayout gbl_panel = new GridBagLayout();
+		gbl_panel.columnWidths = new int[]{0, 0, 0, 0, 0};
+		gbl_panel.rowHeights = new int[]{0, 0, 0, 0, 0, 0, 0, 0, 0};
+		gbl_panel.columnWeights = new double[]{0.0, 1.0, 0.0, 1.0, Double.MIN_VALUE};
+		gbl_panel.rowWeights = new double[]{0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, Double.MIN_VALUE};
+		panel.setLayout(gbl_panel);
+		
+		JLabel lblName = new JLabel("Name:");
+		lblName.setFont(new Font("Tahoma", Font.BOLD, 14));
+		GridBagConstraints gbc_lblName = new GridBagConstraints();
+		gbc_lblName.insets = new Insets(0, 0, 5, 5);
+		gbc_lblName.anchor = GridBagConstraints.EAST;
+		gbc_lblName.gridx = 0;
+		gbc_lblName.gridy = 0;
+		panel.add(lblName, gbc_lblName);
+		
+		JTextField name = new JTextField(spell.getName());
+		name.setEditable(false);
+		GridBagConstraints gbc_name = new GridBagConstraints();
+		gbc_name.insets = new Insets(0, 0, 5, 5);
+		gbc_name.fill = GridBagConstraints.HORIZONTAL;
+		gbc_name.gridx = 1;
+		gbc_name.gridy = 0;
+		panel.add(name, gbc_name);
+		name.setColumns(10);
+		
+		JLabel lblSpellSchool = new JLabel("Spell School:");
+		lblSpellSchool.setFont(new Font("Tahoma", Font.BOLD, 14));
+		GridBagConstraints gbc_lblSpellSchool = new GridBagConstraints();
+		gbc_lblSpellSchool.anchor = GridBagConstraints.EAST;
+		gbc_lblSpellSchool.insets = new Insets(0, 0, 5, 5);
+		gbc_lblSpellSchool.gridx = 2;
+		gbc_lblSpellSchool.gridy = 0;
+		panel.add(lblSpellSchool, gbc_lblSpellSchool);
+		
+		JTextField school = new JTextField(spell.getSpellSchool());
+		school.setEditable(false);
+		school.setColumns(10);
+		GridBagConstraints gbc_school = new GridBagConstraints();
+		gbc_school.insets = new Insets(0, 0, 5, 0);
+		gbc_school.fill = GridBagConstraints.HORIZONTAL;
+		gbc_school.gridx = 3;
+		gbc_school.gridy = 0;
+		panel.add(school, gbc_school);
+		
+		JLabel lblKeywords = new JLabel("Keywords:");
+		lblKeywords.setFont(new Font("Tahoma", Font.BOLD, 14));
+		GridBagConstraints gbc_lblKeywords = new GridBagConstraints();
+		gbc_lblKeywords.anchor = GridBagConstraints.EAST;
+		gbc_lblKeywords.insets = new Insets(0, 0, 5, 5);
+		gbc_lblKeywords.gridx = 0;
+		gbc_lblKeywords.gridy = 1;
+		panel.add(lblKeywords, gbc_lblKeywords);
+		
+		JTextField keywords = new JTextField(asString(spell.getKeywords()));
+		keywords.setEditable(false);
+		keywords.setColumns(10);
+		GridBagConstraints gbc_keywords = new GridBagConstraints();
+		gbc_keywords.gridwidth = 3;
+		gbc_keywords.insets = new Insets(0, 0, 5, 0);
+		gbc_keywords.fill = GridBagConstraints.HORIZONTAL;
+		gbc_keywords.gridx = 1;
+		gbc_keywords.gridy = 1;
+		panel.add(keywords, gbc_keywords);
+		
+		JLabel lblComponents = new JLabel("Components:");
+		lblComponents.setFont(new Font("Tahoma", Font.BOLD, 14));
+		GridBagConstraints gbc_lblComponents = new GridBagConstraints();
+		gbc_lblComponents.anchor = GridBagConstraints.EAST;
+		gbc_lblComponents.insets = new Insets(0, 0, 5, 5);
+		gbc_lblComponents.gridx = 0;
+		gbc_lblComponents.gridy = 2;
+		panel.add(lblComponents, gbc_lblComponents);
+		
+		JPanel panel_1 = new JPanel();
+		GridBagConstraints gbc_panel_1 = new GridBagConstraints();
+		gbc_panel_1.insets = new Insets(0, 0, 5, 0);
+		gbc_panel_1.gridwidth = 3;
+		gbc_panel_1.fill = GridBagConstraints.BOTH;
+		gbc_panel_1.gridx = 1;
+		gbc_panel_1.gridy = 2;
+		panel.add(panel_1, gbc_panel_1);
+		panel_1.setLayout(new FlowLayout(FlowLayout.CENTER, 5, 5));
+		
+		final Set<Component> comps = spell.getComponents();
+		final Source src = dclass.getProto().getSpells().get().getGroup();
+		
+		JCheckBox chckbxVerbal = new JCheckBox("Verbal");
+		chckbxVerbal.setEnabled(false);
+		chckbxVerbal.setSelected(comps.contains(V));
+		panel_1.add(chckbxVerbal);
+		
+		JCheckBox chckbxSomatic = new JCheckBox("Somatic");
+		chckbxSomatic.setEnabled(false);
+		chckbxSomatic.setSelected(comps.contains(S));
+		panel_1.add(chckbxSomatic);
+		
+		JCheckBox chckbxMaterial = new JCheckBox("Material");
+		chckbxMaterial.setEnabled(false);
+		chckbxMaterial.setSelected(comps.contains(M) || (comps.contains(M_DF) && src == Source.ARCANE));
+		panel_1.add(chckbxMaterial);
+		
+		JCheckBox chckbxFocus = new JCheckBox("Focus");
+		chckbxFocus.setEnabled(false);
+		chckbxFocus.setSelected(comps.contains(F) || (comps.contains(F_DF) && src == Source.ARCANE));
+		panel_1.add(chckbxFocus);
+		
+		JCheckBox chckbxDivineFocus = new JCheckBox("Divine Focus");
+		chckbxDivineFocus.setEnabled(false);
+		chckbxDivineFocus.setSelected(comps.contains(DF) || ((comps.contains(M_DF) || comps.contains(F_DF)) && src == Source.DIVINE));
+		panel_1.add(chckbxDivineFocus);
+		
+		JCheckBox chckbxExperience = new JCheckBox("Experience");
+		chckbxExperience.setEnabled(false);
+		chckbxExperience.setSelected(comps.contains(XP));
+		panel_1.add(chckbxExperience);
+		
+		JLabel lblCastingTime = new JLabel("Casting Time:");
+		lblCastingTime.setFont(new Font("Tahoma", Font.BOLD, 14));
+		GridBagConstraints gbc_lblCastingTime = new GridBagConstraints();
+		gbc_lblCastingTime.anchor = GridBagConstraints.EAST;
+		gbc_lblCastingTime.insets = new Insets(0, 0, 5, 5);
+		gbc_lblCastingTime.gridx = 0;
+		gbc_lblCastingTime.gridy = 3;
+		panel.add(lblCastingTime, gbc_lblCastingTime);
+		
+		JTextField action = new JTextField(spell.getCastingTime().name());
+		action.setEditable(false);
+		action.setColumns(10);
+		GridBagConstraints gbc_action = new GridBagConstraints();
+		gbc_action.insets = new Insets(0, 0, 5, 5);
+		gbc_action.fill = GridBagConstraints.HORIZONTAL;
+		gbc_action.gridx = 1;
+		gbc_action.gridy = 3;
+		panel.add(action, gbc_action);
+		
+		JLabel lblRange = new JLabel("Range:");
+		lblRange.setFont(new Font("Tahoma", Font.BOLD, 14));
+		GridBagConstraints gbc_lblRange = new GridBagConstraints();
+		gbc_lblRange.anchor = GridBagConstraints.EAST;
+		gbc_lblRange.insets = new Insets(0, 0, 5, 5);
+		gbc_lblRange.gridx = 2;
+		gbc_lblRange.gridy = 3;
+		panel.add(lblRange, gbc_lblRange);
+		
+		JTextField range = new JTextField(spell.getRange().getResolved(dclass.getLevel()));
+		range.setToolTipText(spell.getRange().toString());
+		range.setEditable(false);
+		range.setColumns(10);
+		GridBagConstraints gbc_range = new GridBagConstraints();
+		gbc_range.insets = new Insets(0, 0, 5, 0);
+		gbc_range.fill = GridBagConstraints.HORIZONTAL;
+		gbc_range.gridx = 3;
+		gbc_range.gridy = 3;
+		panel.add(range, gbc_range);
+				
+		JLabel lblTarget = new JLabel("Target:");
+		lblTarget.setFont(new Font("Tahoma", Font.BOLD, 14));
+		GridBagConstraints gbc_lblTarget = new GridBagConstraints();
+		gbc_lblTarget.anchor = GridBagConstraints.EAST;
+		gbc_lblTarget.insets = new Insets(0, 0, 5, 5);
+		gbc_lblTarget.gridx = 0;
+		gbc_lblTarget.gridy = 4;
+		panel.add(lblTarget, gbc_lblTarget);
+		
+		JTextField target = new JTextField(asString(spell.getTarget()));
+		target.setEditable(false);
+		target.setColumns(10);
+		GridBagConstraints gbc_target = new GridBagConstraints();
+		gbc_target.insets = new Insets(0, 0, 5, 5);
+		gbc_target.fill = GridBagConstraints.HORIZONTAL;
+		gbc_target.gridx = 1;
+		gbc_target.gridy = 4;
+		panel.add(target, gbc_target);
+		
+		JLabel lblEffect = new JLabel("Effect:");
+		lblEffect.setFont(new Font("Tahoma", Font.BOLD, 14));
+		GridBagConstraints gbc_lblEffect = new GridBagConstraints();
+		gbc_lblEffect.anchor = GridBagConstraints.EAST;
+		gbc_lblEffect.insets = new Insets(0, 0, 5, 5);
+		gbc_lblEffect.gridx = 2;
+		gbc_lblEffect.gridy = 4;
+		panel.add(lblEffect, gbc_lblEffect);
+		
+		JTextField effect = new JTextField(asString(spell.getEffect()));
+		effect.setEditable(false);
+		effect.setColumns(10);
+		GridBagConstraints gbc_effect = new GridBagConstraints();
+		gbc_effect.insets = new Insets(0, 0, 5, 0);
+		gbc_effect.fill = GridBagConstraints.HORIZONTAL;
+		gbc_effect.gridx = 3;
+		gbc_effect.gridy = 4;
+		panel.add(effect, gbc_effect);
+		
+		JLabel lblArea = new JLabel("Area:");
+		lblArea.setFont(new Font("Tahoma", Font.BOLD, 14));
+		GridBagConstraints gbc_lblArea = new GridBagConstraints();
+		gbc_lblArea.anchor = GridBagConstraints.EAST;
+		gbc_lblArea.insets = new Insets(0, 0, 5, 5);
+		gbc_lblArea.gridx = 0;
+		gbc_lblArea.gridy = 5;
+		panel.add(lblArea, gbc_lblArea);
+		
+		JTextField area = new JTextField(asString(spell.getArea()));
+		area.setEditable(false);
+		area.setColumns(10);
+		GridBagConstraints gbc_area = new GridBagConstraints();
+		gbc_area.insets = new Insets(0, 0, 5, 5);
+		gbc_area.fill = GridBagConstraints.HORIZONTAL;
+		gbc_area.gridx = 1;
+		gbc_area.gridy = 5;
+		panel.add(area, gbc_area);
+		
+		JLabel lblDuration = new JLabel("Duration:");
+		lblDuration.setFont(new Font("Tahoma", Font.BOLD, 14));
+		GridBagConstraints gbc_lblDuration = new GridBagConstraints();
+		gbc_lblDuration.anchor = GridBagConstraints.EAST;
+		gbc_lblDuration.insets = new Insets(0, 0, 5, 5);
+		gbc_lblDuration.gridx = 2;
+		gbc_lblDuration.gridy = 5;
+		panel.add(lblDuration, gbc_lblDuration);
+		
+		JTextField duration = new JTextField(spell.getDuration());
+		duration.setEditable(false);
+		duration.setColumns(10);
+		GridBagConstraints gbc_duration = new GridBagConstraints();
+		gbc_duration.insets = new Insets(0, 0, 5, 0);
+		gbc_duration.fill = GridBagConstraints.HORIZONTAL;
+		gbc_duration.gridx = 3;
+		gbc_duration.gridy = 5;
+		panel.add(duration, gbc_duration);
+		
+		JLabel lblSavingThrow = new JLabel("Saving Throw:");
+		lblSavingThrow.setFont(new Font("Tahoma", Font.BOLD, 14));
+		GridBagConstraints gbc_lblSavingThrow = new GridBagConstraints();
+		gbc_lblSavingThrow.anchor = GridBagConstraints.EAST;
+		gbc_lblSavingThrow.insets = new Insets(0, 0, 5, 5);
+		gbc_lblSavingThrow.gridx = 0;
+		gbc_lblSavingThrow.gridy = 6;
+		panel.add(lblSavingThrow, gbc_lblSavingThrow);
+		
+		JTextField saving = new JTextField(spell.getSavingThrow());
+		saving.setEditable(false);
+		saving.setColumns(10);
+		GridBagConstraints gbc_saving = new GridBagConstraints();
+		gbc_saving.insets = new Insets(0, 0, 5, 5);
+		gbc_saving.fill = GridBagConstraints.HORIZONTAL;
+		gbc_saving.gridx = 1;
+		gbc_saving.gridy = 6;
+		panel.add(saving, gbc_saving);
+		
+		JLabel lblSpellResistance = new JLabel("Spell Resistance:");
+		lblSpellResistance.setFont(new Font("Tahoma", Font.BOLD, 12));
+		GridBagConstraints gbc_lblSpellResistance = new GridBagConstraints();
+		gbc_lblSpellResistance.anchor = GridBagConstraints.EAST;
+		gbc_lblSpellResistance.insets = new Insets(0, 0, 5, 5);
+		gbc_lblSpellResistance.gridx = 2;
+		gbc_lblSpellResistance.gridy = 6;
+		panel.add(lblSpellResistance, gbc_lblSpellResistance);
+		
+		JTextField resist = new JTextField(spell.isAllowsSpellResistance() ? "Yes" : "No");
+		resist.setEditable(false);
+		resist.setColumns(10);
+		GridBagConstraints gbc_resist = new GridBagConstraints();
+		gbc_resist.insets = new Insets(0, 0, 5, 0);
+		gbc_resist.fill = GridBagConstraints.HORIZONTAL;
+		gbc_resist.gridx = 3;
+		gbc_resist.gridy = 6;
+		panel.add(resist, gbc_resist);
+		
+		JScrollPane scrollPane = new JScrollPane();
+		GridBagConstraints gbc_scrollPane = new GridBagConstraints();
+		gbc_scrollPane.fill = GridBagConstraints.BOTH;
+		gbc_scrollPane.gridx = 0;
+		gbc_scrollPane.gridy = 1;
+		add(scrollPane, gbc_scrollPane);
+		
+		JTextArea description = new JTextArea(spell.getDescription());
+		description.setEditable(false);
+		description.setWrapStyleWord(true);
+		description.setRows(10);
+		scrollPane.setViewportView(description);
+		description.setColumns(30);
+		description.setLineWrap(true);
+	}
+
+	private String asString(Object obj) {
+		return obj == null ? "" : obj.toString();
+	}
+
+	private static String asString(Set<String> keys) {
+		if (keys.isEmpty()) return "<none>";
+		String str = keys.toString();
+		return str.substring(1, str.length()-1);
+	}
+}

+ 70 - 0
src/org/leumasjaffe/charsheet/view/magic/SpellLevelPanel.java

@@ -0,0 +1,70 @@
+package org.leumasjaffe.charsheet.view.magic;
+
+import javax.swing.JPanel;
+import java.awt.GridBagLayout;
+import java.awt.Component;
+import javax.swing.Box;
+import java.awt.GridBagConstraints;
+import java.awt.Insets;
+
+import org.jdesktop.swingx.VerticalLayout;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+
+import lombok.AccessLevel;
+import lombok.experimental.FieldDefaults;
+
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+class SpellLevelPanel extends JPanel {
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 1L;
+	JPanel panel;
+	DDCharacterClass dclass;
+	int level;
+
+	public SpellLevelPanel(JPanel header, DDCharacterClass dclass, int level) {
+		this.dclass = dclass;
+		this.level = level;
+		
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0, 0, 0};
+		gridBagLayout.columnWeights = new double[]{0.0, 0.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, 1.0, 0.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		GridBagConstraints gbc_panel_1 = new GridBagConstraints();
+		gbc_panel_1.gridwidth = 2;
+		gbc_panel_1.insets = new Insets(0, 0, 5, 0);
+		gbc_panel_1.fill = GridBagConstraints.BOTH;
+		gbc_panel_1.gridx = 0;
+		gbc_panel_1.gridy = 0;
+		add(header, gbc_panel_1);
+		
+		panel = new JPanel(new VerticalLayout());
+		GridBagConstraints gbc_panel = new GridBagConstraints();
+		gbc_panel.insets = new Insets(0, 0, 5, 0);
+		gbc_panel.fill = GridBagConstraints.BOTH;
+		gbc_panel.gridx = 1;
+		gbc_panel.gridy = 1;
+		add(panel, gbc_panel);
+		reload();
+		
+		Component horizontalStrut = Box.createHorizontalStrut(20);
+		GridBagConstraints gbc_horizontalStrut = new GridBagConstraints();
+		gbc_horizontalStrut.insets = new Insets(0, 0, 0, 5);
+		gbc_horizontalStrut.gridx = 0;
+		gbc_horizontalStrut.gridy = 2;
+		add(horizontalStrut, gbc_horizontalStrut);
+	}
+	
+	public boolean isCastableFromHere() { return false; }
+
+	protected void reload() {
+		panel.removeAll();
+		dclass.getSpellBook().get().spellsPreparedAtLevel(level).forEach(spell -> panel.add(new SpellLine(this.dclass, spell, isCastableFromHere())));
+		panel.repaint();
+	}
+
+}

+ 29 - 0
src/org/leumasjaffe/charsheet/view/magic/SpellLevelPerDayPanel.java

@@ -0,0 +1,29 @@
+package org.leumasjaffe.charsheet.view.magic;
+
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+
+import lombok.AccessLevel;
+import lombok.experimental.FieldDefaults;
+
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+class SpellLevelPerDayPanel extends SpellLevelPanel {
+
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 1L;
+
+	SpellsPerDayHeader header;
+	
+	public SpellLevelPerDayPanel(SpellsPerDayHeader header, DDCharacterClass dclass, int level) {
+		super(header, dclass, level);
+		this.header = header;
+	}
+	
+	public boolean isCastableFromHere() { return true; }
+
+	public void reload() {
+		if (header != null) { header.reload(); }
+		super.reload();
+	}
+}

+ 107 - 0
src/org/leumasjaffe/charsheet/view/magic/SpellLine.java

@@ -0,0 +1,107 @@
+package org.leumasjaffe.charsheet.view.magic;
+
+import javax.swing.JPanel;
+
+import org.leumasjaffe.charsheet.controller.inventory.PopClickListener;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+
+import java.awt.GridBagLayout;
+import javax.swing.JLabel;
+import java.awt.GridBagConstraints;
+import java.awt.Component;
+import javax.swing.Box;
+import java.awt.Insets;
+import java.util.stream.Collectors;
+
+class SpellLine extends JPanel {
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 1L;
+
+	public SpellLine(final DDCharacterClass dclass, final DDSpell spell, boolean isPrepared) {
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0};
+		gridBagLayout.columnWeights = new double[]{0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JLabel lblName = new JLabel(spell.getName());
+		GridBagConstraints gbc_lblName = new GridBagConstraints();
+		gbc_lblName.insets = new Insets(0, 0, 0, 5);
+		gbc_lblName.gridx = 0;
+		gbc_lblName.gridy = 0;
+		add(lblName, gbc_lblName);
+		
+		Component horizontalStrut = Box.createHorizontalStrut(20);
+		GridBagConstraints gbc_horizontalStrut = new GridBagConstraints();
+		gbc_horizontalStrut.insets = new Insets(0, 0, 0, 5);
+		gbc_horizontalStrut.gridx = 1;
+		gbc_horizontalStrut.gridy = 0;
+		add(horizontalStrut, gbc_horizontalStrut);
+		
+		JLabel lblSchool = new JLabel(spell.getSchool().toString());
+		String extra = "";
+		if (spell.getSubSchool() != null) {
+			extra = "SubSchool: " + spell.getSubSchool().toString();
+		}
+		if (!spell.getKeywords().isEmpty()) {
+			if (!extra.isEmpty()) extra += "<br>";
+			extra += "Keywords: " + spell.getKeywords().toString();
+		}
+		if (!extra.isEmpty()) { 
+			lblSchool.setToolTipText("<html>" + extra + "</html>");
+		}
+		GridBagConstraints gbc_lblSchool = new GridBagConstraints();
+		gbc_lblSchool.insets = new Insets(0, 0, 0, 5);
+		gbc_lblSchool.gridx = 2;
+		gbc_lblSchool.gridy = 0;
+		add(lblSchool, gbc_lblSchool);
+		
+		Component horizontalStrut_1 = Box.createHorizontalStrut(20);
+		GridBagConstraints gbc_horizontalStrut_1 = new GridBagConstraints();
+		gbc_horizontalStrut_1.insets = new Insets(0, 0, 0, 5);
+		gbc_horizontalStrut_1.gridx = 3;
+		gbc_horizontalStrut_1.gridy = 0;
+		add(horizontalStrut_1, gbc_horizontalStrut_1);
+		
+		JLabel lblComponents = new JLabel(spell.getComponents().stream().map(e -> e.name()).collect(Collectors.toList()).toString());
+		GridBagConstraints gbc_lblComponents = new GridBagConstraints();
+		gbc_lblComponents.insets = new Insets(0, 0, 0, 5);
+		gbc_lblComponents.gridx = 4;
+		gbc_lblComponents.gridy = 0;
+		add(lblComponents, gbc_lblComponents);
+		
+		Component horizontalStrut_2 = Box.createHorizontalStrut(20);
+		GridBagConstraints gbc_horizontalStrut_2 = new GridBagConstraints();
+		gbc_horizontalStrut_2.insets = new Insets(0, 0, 0, 5);
+		gbc_horizontalStrut_2.gridx = 5;
+		gbc_horizontalStrut_2.gridy = 0;
+		add(horizontalStrut_2, gbc_horizontalStrut_2);
+		
+		JLabel lblAction = new JLabel(spell.getCastingTime().name());
+		GridBagConstraints gbc_lblAction = new GridBagConstraints();
+		gbc_lblAction.insets = new Insets(0, 0, 0, 5);
+		gbc_lblAction.gridx = 6;
+		gbc_lblAction.gridy = 0;
+		add(lblAction, gbc_lblAction);
+		
+		Component horizontalStrut_3 = Box.createHorizontalStrut(20);
+		GridBagConstraints gbc_horizontalStrut_3 = new GridBagConstraints();
+		gbc_horizontalStrut_3.insets = new Insets(0, 0, 0, 5);
+		gbc_horizontalStrut_3.gridx = 7;
+		gbc_horizontalStrut_3.gridy = 0;
+		add(horizontalStrut_3, gbc_horizontalStrut_3);
+		
+		JLabel lblRange = new JLabel(spell.getRange().getName());
+		GridBagConstraints gbc_lblAction_1 = new GridBagConstraints();
+		gbc_lblAction_1.gridx = 8;
+		gbc_lblAction_1.gridy = 0;
+		add(lblRange, gbc_lblAction_1);
+		
+		addMouseListener(new PopClickListener(new SpellMenu(dclass, spell, isPrepared)));
+	}
+
+}

+ 48 - 0
src/org/leumasjaffe/charsheet/view/magic/SpellMenu.java

@@ -0,0 +1,48 @@
+package org.leumasjaffe.charsheet.view.magic;
+
+import javax.swing.JPopupMenu;
+
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.magic.DDSpell;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
+
+import javax.swing.JFrame;
+import javax.swing.JMenuItem;
+import javax.swing.JOptionPane;
+
+class SpellMenu extends JPopupMenu {
+
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 1L;
+
+	public SpellMenu(final DDCharacterClass dclass, final DDSpell spell, boolean isPrepared) {
+		final int spellLevel = spell.getClassLevel(dclass.getName());
+		
+		JMenuItem mntmInfo = new JMenuItem("Info");
+		mntmInfo.addActionListener( e -> {
+			JFrame frame = new JFrame(spell.getName() +  " (" + dclass.getName() + " " + spellLevel + ")");
+			frame.add(new SpellInfoPanel(dclass, spell));
+			frame.pack();
+			frame.setVisible(true);
+		});
+		add(mntmInfo);
+		
+		if (isPrepared) {
+			JMenuItem mntmCast = new JMenuItem("Cast");
+			mntmCast.addActionListener(e -> {
+				final DDSpellbook book = dclass.getSpellBook().get();
+				if (book.numSpellsPerDayRemainingAtLevel(spellLevel) == 0) {
+					JOptionPane.showMessageDialog(this, "Cannot cast any more spells", "Error", JOptionPane.ERROR_MESSAGE);
+					return;
+				}
+				book.castSpell(spellLevel, spell);
+				((SpellLevelPerDayPanel) this.getInvoker().getParent().getParent()).reload();
+			});
+			add(mntmCast);
+		}
+		
+	}
+
+}

+ 83 - 0
src/org/leumasjaffe/charsheet/view/magic/SpellPanel.java

@@ -0,0 +1,83 @@
+package org.leumasjaffe.charsheet.view.magic;
+
+import javax.swing.JPanel;
+import javax.swing.JScrollPane;
+
+import java.awt.GridBagLayout;
+import java.util.function.Function;
+
+import javax.swing.JTabbedPane;
+
+import org.jdesktop.swingx.VerticalLayout;
+import org.leumasjaffe.charsheet.model.Ability;
+import org.leumasjaffe.charsheet.model.DDCharacter;
+import org.leumasjaffe.charsheet.model.DDCharacterClass;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
+import org.leumasjaffe.charsheet.model.observable.IntValue;
+import org.leumasjaffe.function.VoidVoidFunction;
+
+import lombok.AccessLevel;
+import lombok.experimental.FieldDefaults;
+
+import java.awt.GridBagConstraints;
+
+@FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
+public class SpellPanel extends JPanel {
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 1L;
+	DDCharacterClass dclass;
+	VoidVoidFunction _reload;
+
+	public SpellPanel(DDCharacter chara, final DDCharacterClass cclass) {
+		dclass = cclass;
+		final DDSpellbook model = cclass.getSpellBook().get();
+		
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0};
+		gridBagLayout.columnWeights = new double[]{1.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{1.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JTabbedPane spellsPane = new JTabbedPane(JTabbedPane.TOP);
+		GridBagConstraints gbc_spells = new GridBagConstraints();
+		gbc_spells.fill = GridBagConstraints.BOTH;
+		gbc_spells.gridx = 0;
+		gbc_spells.gridy = 0;
+		add(spellsPane, gbc_spells);
+		
+		JScrollPane preparedPane = new JScrollPane();
+		spellsPane.addTab("Prepared", null, preparedPane, "Spells the character can use today");
+
+		JScrollPane knownPane = new JScrollPane();
+		spellsPane.addTab("Known", null, knownPane, "Spells the player knows for this class");
+		
+		final IntValue value = Ability.fields.get(dclass.getProto().getSpells().get().getAbility()).apply(chara.getAbilities().getBase());
+		_reload = () -> {
+			generateSpellTree((l) -> new SpellLevelPerDayPanel(new SpellsPerDayHeader(l, model, value), dclass, l),
+					preparedPane);
+			generateSpellTree((l) -> new SpellLevelPanel(new SpellsKnownHeader(l, model, value), dclass, l), 
+					knownPane);
+		};
+		reload();
+	}
+	
+	public void reload() {
+		_reload.apply();
+	}
+
+	private void generateSpellTree(final Function<Integer, JPanel> getPanel, final JScrollPane preparedPane) {
+		JPanel root = new JPanel();
+		root.setLayout(new VerticalLayout());
+		
+		for (int i = 0; i < dclass.getHighestSpellLevel(); ++i) {
+			if (dclass.getSpellBook().get().numSpellsKnownAtLevel(i) == 0) continue;
+			root.add(getPanel.apply(i));
+		}
+		
+		preparedPane.setViewportView(root);
+	}
+
+}

+ 91 - 0
src/org/leumasjaffe/charsheet/view/magic/SpellsKnownHeader.java

@@ -0,0 +1,91 @@
+package org.leumasjaffe.charsheet.view.magic;
+
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+
+import org.leumasjaffe.charsheet.model.Ability;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
+import org.leumasjaffe.charsheet.model.observable.IntValue;
+import org.leumasjaffe.charsheet.util.StringHelper;
+import java.awt.Dimension;
+
+class SpellsKnownHeader extends JPanel {
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 1L;
+
+	public SpellsKnownHeader(int level, DDSpellbook model, IntValue ability) {
+		setPreferredSize(new Dimension(350, 20));
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 30, 0, 30, 0, 30, 0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0};
+		gridBagLayout.columnWeights = new double[]{0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JLabel lblSpellLevel = new JLabel("Spell Level:");
+		GridBagConstraints gbc_lblSpellLevel = new GridBagConstraints();
+		gbc_lblSpellLevel.insets = new Insets(0, 0, 0, 5);
+		gbc_lblSpellLevel.anchor = GridBagConstraints.EAST;
+		gbc_lblSpellLevel.gridx = 0;
+		gbc_lblSpellLevel.gridy = 0;
+		add(lblSpellLevel, gbc_lblSpellLevel);
+		
+		JTextField textFieldLevel = new JTextField(Integer.toString(level));
+		textFieldLevel.setPreferredSize(new Dimension(30, 20));
+		textFieldLevel.setMaximumSize(new Dimension(30, 20));
+		textFieldLevel.setEditable(false);
+		GridBagConstraints gbc_textFieldLevel = new GridBagConstraints();
+		gbc_textFieldLevel.insets = new Insets(0, 0, 0, 5);
+		gbc_textFieldLevel.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textFieldLevel.gridx = 1;
+		gbc_textFieldLevel.gridy = 0;
+		add(textFieldLevel, gbc_textFieldLevel);
+		textFieldLevel.setColumns(10);
+		
+		JLabel lblSaveDc = new JLabel("Save DC:");
+		GridBagConstraints gbc_lblSaveDc = new GridBagConstraints();
+		gbc_lblSaveDc.anchor = GridBagConstraints.EAST;
+		gbc_lblSaveDc.insets = new Insets(0, 0, 0, 5);
+		gbc_lblSaveDc.gridx = 2;
+		gbc_lblSaveDc.gridy = 0;
+		add(lblSaveDc, gbc_lblSaveDc);
+		
+		JTextField textFieldSpellSave = new JTextField(Integer.toString(10 + level + Ability.modifier(ability.value())));
+		textFieldSpellSave.setPreferredSize(new Dimension(30, 20));
+		textFieldSpellSave.setMaximumSize(new Dimension(30, 20));
+		textFieldSpellSave.setEditable(false);
+		textFieldSpellSave.setColumns(10);
+		GridBagConstraints gbc_textFieldSpellSave = new GridBagConstraints();
+		gbc_textFieldSpellSave.insets = new Insets(0, 0, 0, 5);
+		gbc_textFieldSpellSave.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textFieldSpellSave.gridx = 3;
+		gbc_textFieldSpellSave.gridy = 0;
+		add(textFieldSpellSave, gbc_textFieldSpellSave);
+		
+		JLabel lblSpellsPerDay = new JLabel("Spells Known:");
+		GridBagConstraints gbc_lblSpellsPerDay = new GridBagConstraints();
+		gbc_lblSpellsPerDay.insets = new Insets(0, 0, 0, 5);
+		gbc_lblSpellsPerDay.gridx = 4;
+		gbc_lblSpellsPerDay.gridy = 0;
+		add(lblSpellsPerDay, gbc_lblSpellsPerDay);
+		
+		JTextField textFieldRemaining = new JTextField(StringHelper.toString(model.numSpellsKnownAtLevel(level), -1, "--"));
+		textFieldRemaining.setPreferredSize(new Dimension(30, 20));
+		textFieldRemaining.setMaximumSize(new Dimension(30, 20));
+		GridBagConstraints gbc_textFieldRemaining = new GridBagConstraints();
+		gbc_textFieldRemaining.insets = new Insets(0, 0, 0, 5);
+		gbc_textFieldRemaining.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textFieldRemaining.gridx = 5;
+		gbc_textFieldRemaining.gridy = 0;
+		add(textFieldRemaining, gbc_textFieldRemaining);
+		textFieldRemaining.setEditable(false);
+		textFieldRemaining.setColumns(10);
+	}
+}

+ 107 - 0
src/org/leumasjaffe/charsheet/view/magic/SpellsPerDayHeader.java

@@ -0,0 +1,107 @@
+package org.leumasjaffe.charsheet.view.magic;
+
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JTextField;
+
+import org.leumasjaffe.charsheet.model.Ability;
+import org.leumasjaffe.charsheet.model.magic.DDSpellbook;
+import org.leumasjaffe.charsheet.model.observable.IntValue;
+import java.awt.Dimension;
+
+class SpellsPerDayHeader extends JPanel {
+	/**
+	 * 
+	 */
+	private static final long serialVersionUID = 1L;
+	private JTextField textFieldRemaining;
+
+	public SpellsPerDayHeader(int level, DDSpellbook model, IntValue ability) {
+		setPreferredSize(new Dimension(350, 20));
+		GridBagLayout gridBagLayout = new GridBagLayout();
+		gridBagLayout.columnWidths = new int[]{0, 30, 0, 30, 0, 30, 0, 30, 0, 0};
+		gridBagLayout.rowHeights = new int[]{0, 0};
+		gridBagLayout.columnWeights = new double[]{0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, Double.MIN_VALUE};
+		gridBagLayout.rowWeights = new double[]{0.0, Double.MIN_VALUE};
+		setLayout(gridBagLayout);
+		
+		JLabel lblSpellLevel = new JLabel("Spell Level:");
+		GridBagConstraints gbc_lblSpellLevel = new GridBagConstraints();
+		gbc_lblSpellLevel.insets = new Insets(0, 0, 0, 5);
+		gbc_lblSpellLevel.anchor = GridBagConstraints.EAST;
+		gbc_lblSpellLevel.gridx = 0;
+		gbc_lblSpellLevel.gridy = 0;
+		add(lblSpellLevel, gbc_lblSpellLevel);
+		
+		JTextField textFieldLevel = new JTextField(Integer.toString(level));
+		textFieldLevel.setEditable(false);
+		GridBagConstraints gbc_textFieldLevel = new GridBagConstraints();
+		gbc_textFieldLevel.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textFieldLevel.insets = new Insets(0, 0, 0, 5);
+		gbc_textFieldLevel.gridx = 1;
+		gbc_textFieldLevel.gridy = 0;
+		add(textFieldLevel, gbc_textFieldLevel);
+		textFieldLevel.setColumns(10);
+		
+		JLabel lblSaveDc = new JLabel("Save DC:");
+		GridBagConstraints gbc_lblSaveDc = new GridBagConstraints();
+		gbc_lblSaveDc.anchor = GridBagConstraints.EAST;
+		gbc_lblSaveDc.insets = new Insets(0, 0, 0, 5);
+		gbc_lblSaveDc.gridx = 2;
+		gbc_lblSaveDc.gridy = 0;
+		add(lblSaveDc, gbc_lblSaveDc);
+		
+		JTextField textFieldSpellSave = new JTextField(Integer.toString(10 + level + Ability.modifier(ability.value())));
+		textFieldSpellSave.setEditable(false);
+		textFieldSpellSave.setColumns(10);
+		GridBagConstraints gbc_textFieldSpellSave = new GridBagConstraints();
+		gbc_textFieldSpellSave.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textFieldSpellSave.insets = new Insets(0, 0, 0, 5);
+		gbc_textFieldSpellSave.gridx = 3;
+		gbc_textFieldSpellSave.gridy = 0;
+		add(textFieldSpellSave, gbc_textFieldSpellSave);
+		
+		JLabel lblSpellsPerDay = new JLabel("Spells Per Day:");
+		GridBagConstraints gbc_lblSpellsPerDay = new GridBagConstraints();
+		gbc_lblSpellsPerDay.insets = new Insets(0, 0, 0, 5);
+		gbc_lblSpellsPerDay.gridx = 4;
+		gbc_lblSpellsPerDay.gridy = 0;
+		add(lblSpellsPerDay, gbc_lblSpellsPerDay);
+		
+		textFieldRemaining = new JTextField(Integer.toString(model.numSpellsPerDayRemainingAtLevel(level)));
+		GridBagConstraints gbc_textFieldRemaining = new GridBagConstraints();
+		gbc_textFieldRemaining.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textFieldRemaining.insets = new Insets(0, 0, 0, 5);
+		gbc_textFieldRemaining.gridx = 5;
+		gbc_textFieldRemaining.gridy = 0;
+		add(textFieldRemaining, gbc_textFieldRemaining);
+		textFieldRemaining.setEditable(false);
+		textFieldRemaining.setColumns(10);
+		
+		JLabel label = new JLabel("/");
+		GridBagConstraints gbc_label = new GridBagConstraints();
+		gbc_label.insets = new Insets(0, 0, 0, 5);
+		gbc_label.gridx = 6;
+		gbc_label.gridy = 0;
+		add(label, gbc_label);
+		
+		JTextField textFieldOutOf = new JTextField(Integer.toString(model.numSpellsPerDayAtLevel(level)));
+		GridBagConstraints gbc_textFieldOutOf = new GridBagConstraints();
+		gbc_textFieldOutOf.insets = new Insets(0, 0, 0, 5);
+		gbc_textFieldOutOf.fill = GridBagConstraints.HORIZONTAL;
+		gbc_textFieldOutOf.gridx = 7;
+		gbc_textFieldOutOf.gridy = 0;
+		add(textFieldOutOf, gbc_textFieldOutOf);
+		textFieldOutOf.setEditable(false);
+		textFieldOutOf.setColumns(10);
+	}
+
+	// Technically unsafe, but since this should only get called when a spell is cast, the number will not drop below 0
+	public void reload() {
+		textFieldRemaining.setText(Integer.toString(Integer.parseInt(textFieldRemaining.getText())-1));
+	}
+}

+ 1 - 1
src/org/leumasjaffe/charsheet/view/skills/SkillLevelUpLine.java

@@ -31,7 +31,7 @@ import javax.swing.JButton;
 import java.awt.Font;
 
 @FieldDefaults(level=AccessLevel.PRIVATE, makeFinal=true)
-public class SkillLevelUpLine extends JPanel {
+class SkillLevelUpLine extends JPanel {
 	/**
 	 * 
 	 */

+ 0 - 1
src/org/leumasjaffe/charsheet/view/summary/DamageReductionLine.java

@@ -60,6 +60,5 @@ public class DamageReductionLine extends JPanel {
 		gbc_numberTextField.gridx = 1;
 		gbc_numberTextField.gridy = 0;
 		add(numberTextField, gbc_numberTextField);
-		// TODO Auto-generated constructor stub
 	}
 }

+ 0 - 1
src/org/leumasjaffe/charsheet/view/summary/SpellResistanceLine.java

@@ -59,6 +59,5 @@ public class SpellResistanceLine extends JPanel {
 		gbc_numberTextField.gridx = 1;
 		gbc_numberTextField.gridy = 0;
 		add(numberTextField, gbc_numberTextField);
-		// TODO Auto-generated constructor stub
 	}
 }

+ 87 - 26
src/org/leumasjaffe/format/StringFormatter.java

@@ -24,6 +24,92 @@ public class StringFormatter {
 		}
 	}
 	
+	private static final class FmtStateMachine {
+		final StringBuilder str;
+		
+		String fmt;
+		Object[] args;
+		int currentIdx;
+		final NameMap named;
+		int lastCh, endPos = 0;
+
+		FmtStateMachine(String fmt, Object... args) {
+			this(fmt, 0, args, new NameMap(args), '\0');
+		}
+		
+		FmtStateMachine(String fmt, int idx, Object[] args, NameMap named, int lastCh) {
+			this.str = new StringBuilder(fmt.length());
+			this.fmt = fmt;
+			this.currentIdx = idx;
+			this.args = args;
+			this.named = named;
+			this.lastCh = lastCh;
+		}
+		
+		FmtStateMachine formatMain() {
+			int lpos = 0;
+			for (int pos = fmt.indexOf('{'); pos != -1 && hasMore(pos); lpos = pos+1, pos = fmt.indexOf('{', lpos)) {
+				str.append(fmt.substring(lpos, pos).replaceAll("}}", "}"));
+				int epos = fmt.indexOf('}', pos);
+
+				if (fmt.charAt(pos+1) == '{') { 
+					// Literal '{'
+					str.append('{');
+					epos = pos + 1;
+				} else if (epos == pos+1) {
+					// Unmarked '{}' -> get the next argument
+					str.append(getToken(null));
+				} else {
+					epos = formatToken(pos, epos);
+				}
+				pos = epos;
+			}
+			str.append(remaining(lpos).replaceAll("}}", "}"));
+			return this;
+		}
+
+		private String remaining(int lpos) {
+			this.endPos = fmt.indexOf(lastCh, lpos);
+			return lastCh == '\0' ? fmt.substring(lpos) : fmt.substring(lpos, endPos++);
+		}
+
+		private boolean hasMore(int pos) {
+			return lastCh == '\0' || pos < fmt.indexOf(lastCh);
+		}
+		
+		private int formatToken(int pos, int epos) {
+			final String token = fmt.substring(++pos, epos);
+			// If token contains a '.', use reflection?
+			// If token starts with '%', make it a c-format expression
+			if (token.indexOf('?') != -1) {
+				final int b1 = fmt.indexOf('?', pos)+1;
+				boolean _if = (Boolean) getToken(token.substring(0, b1-pos-1));
+				FmtStateMachine then = new FmtStateMachine(fmt.substring(b1), currentIdx, args, named, ':').formatMain();
+				FmtStateMachine els = new FmtStateMachine(fmt.substring(then.endPos+b1), then.currentIdx, args, named, '}').formatMain();
+				currentIdx = els.currentIdx;
+				epos = els.endPos+then.endPos+b1-1;
+				str.append(_if ? then : els);
+			} else {
+				str.append(getToken(token));
+			}
+			return epos;
+		}
+
+		private Object getToken(final String token) {
+			if (token == null || token.isEmpty()) {
+				return args[currentIdx++];
+			} else if (Character.isDigit(token.charAt(0))) {
+				return args[Integer.parseInt(token)];
+			} else {
+				return named.get(token);
+			}
+		}
+		
+		public String toString() {
+			return str.toString();
+		}
+	}
+	
 	String fmt;
 	
 	public StringFormatter(String logFmtString) {
@@ -31,31 +117,6 @@ public class StringFormatter {
 	}
 	
 	public String format(Object... args) {
-		final StringBuilder str = new StringBuilder(fmt.length());
-		
-		int currentIdx = 0;
-		final NameMap named = new NameMap(args);
-		
-		int lpos = 0;
-		for (int pos = fmt.indexOf('{'); pos != -1; lpos = pos+1, pos = fmt.indexOf('{', lpos)) {
-			str.append(fmt.substring(lpos, pos));
-			int epos = fmt.indexOf('}', pos);
-
-			if (fmt.charAt(pos+1) == '{') { 
-				str.append(fmt.substring(++pos, ++epos));
-			} else if (epos == pos+1) {
-				str.append(args[currentIdx++]);
-			} else {
-				final String token = fmt.substring(pos+1, epos);
-				if (Character.isDigit(token.charAt(0))) {
-					str.append(args[Integer.parseInt(token)]);
-				} else { // In theory, I could add a format-mode here too
-					str.append(named.get(token));
-				}
-			}
-			pos = epos;
-		}
-		str.append(fmt.substring(lpos));
-		return str.toString();
+		return new FmtStateMachine(fmt, args).formatMain().toString();
 	}
 }

+ 36 - 0
src/org/leumasjaffe/graphics/SelectTableRowPopupMenuListener.java

@@ -0,0 +1,36 @@
+package org.leumasjaffe.graphics;
+
+import java.awt.Point;
+
+import javax.swing.JPopupMenu;
+import javax.swing.JTable;
+import javax.swing.SwingUtilities;
+import javax.swing.event.PopupMenuEvent;
+import javax.swing.event.PopupMenuListener;
+
+import lombok.AllArgsConstructor;
+
+@AllArgsConstructor
+public final class SelectTableRowPopupMenuListener implements PopupMenuListener {
+	JPopupMenu popupMenu;
+	JTable table;
+
+	@Override
+	public void popupMenuWillBecomeVisible(PopupMenuEvent e) {
+	    SwingUtilities.invokeLater(new Runnable() {
+	        @Override
+	        public void run() {
+	            int rowAtPoint = table.rowAtPoint(SwingUtilities.convertPoint(popupMenu, new Point(0, 0), table));
+	            if (rowAtPoint > -1) {
+	            	table.setRowSelectionInterval(rowAtPoint, rowAtPoint);
+	            }
+	        }
+	    });
+	}
+
+	@Override
+	public void popupMenuWillBecomeInvisible(PopupMenuEvent e) {}
+
+	@Override
+	public void popupMenuCanceled(PopupMenuEvent e) {}
+}

+ 30 - 0
test/org/leumasjaffe/format/StringFormatterTest.java

@@ -0,0 +1,30 @@
+package org.leumasjaffe.format;
+
+import static org.junit.Assert.*;
+
+import org.junit.Test;
+import org.leumasjaffe.charsheet.util.StringHelper;
+
+public class StringFormatterTest {
+
+	@Test
+	public void testFormatLiteral() {
+		assertEquals("{}", new StringFormatter("{{}}").format());
+	}
+	
+	@Test
+	public void testFormatNumbered() {
+		assertEquals("1,0", new StringFormatter("{1},{0}").format(0, 1));
+	}
+	
+	@Test
+	public void testFormatCondition() {
+		assertEquals("1", new StringFormatter("{?{}:{}}").format(false, 0, 1));
+	}
+	
+	@Test
+	public void testFormatNumberedCondition() {
+		assertEquals("0 word + 1 word/2 levels", StringHelper.format("{0} {2} + {1} {2}/{3?level:{4} levels}", 0, 1, "word", false, 2));
+	}
+
+}