codecombat/app/views/play/level/tome/SpellView.coffee
Phoenix Eliot 663c220eaf Show wev-dev iFrame error messages like Aether's
This heavily refactors SpellView and adds infrastructure for receiving and reporting Errors raised by the web-dev iFrame. The web-dev error system, the Aether error system, and the Ace html-worker avoid disturbing each others' errors/annotations (though currently Aether+web-dev errors won't coexist), and they clear/update their own asynchronously.

Show web-dev iFrame errors as Ace annotations

Add functional error banners (with poor messages)

Improve error banners, don't allow duplicate Problems

Refactor setAnnotations override

Convert all constructor calls for Problems

Add comments, clean up

Clean up

Don't clear things unnecessarily

Clean up error message sending from iFrame

Add web-dev:error schema

Clarify error message attributes

Refactor displaying AetherProblems

Refactor displaying user problem banners

Refactor onWebDevError

Set ace styles on updating @problems

Clean up, fix off-by-1 error

Add comment

Show stale web-dev errors differently
Some web-dev errors are generated by "stale" code — code that's still running in the iFrame but doesn't have the player's recent changes.
This shows those errors differently than if they weren't "stale", and suggests they re-run their code.

Hook up web-dev event schema

Destroy ignored duplicate problems

Functionalize a bit of stuff

Fix ProblemAlertView never loading
2016-08-31 10:59:06 -07:00

1358 lines
59 KiB
CoffeeScript

CocoView = require 'views/core/CocoView'
template = require 'templates/play/level/tome/spell'
{me} = require 'core/auth'
filters = require 'lib/image_filter'
Range = ace.require('ace/range').Range
UndoManager = ace.require('ace/undomanager').UndoManager
Problem = require './Problem'
SpellDebugView = require './SpellDebugView'
SpellTranslationView = require './SpellTranslationView'
SpellToolbarView = require './SpellToolbarView'
LevelComponent = require 'models/LevelComponent'
UserCodeProblem = require 'models/UserCodeProblem'
utils = require 'core/utils'
CodeLog = require 'models/CodeLog'
Autocomplete = require './editor/autocomplete'
module.exports = class SpellView extends CocoView
id: 'spell-view'
className: 'shown'
template: template
controlsEnabled: true
eventsSuppressed: true
writable: true
languagesThatUseWorkers: ['html']
keyBindings:
'default': null
'vim': 'ace/keyboard/vim'
'emacs': 'ace/keyboard/emacs'
subscriptions:
'level:disable-controls': 'onDisableControls'
'level:enable-controls': 'onEnableControls'
'surface:frame-changed': 'onFrameChanged'
'surface:coordinate-selected': 'onCoordinateSelected'
'god:new-world-created': 'onNewWorld'
'god:user-code-problem': 'onUserCodeProblem'
'god:non-user-code-problem': 'onNonUserCodeProblem'
'tome:manual-cast': 'onManualCast'
'tome:reload-code': 'onCodeReload'
'tome:spell-changed': 'onSpellChanged'
'level:session-will-save': 'onSessionWillSave'
'modal:closed': 'focus'
'tome:focus-editor': 'focus'
'tome:spell-statement-index-updated': 'onStatementIndexUpdated'
'tome:change-language': 'onChangeLanguage'
'tome:change-config': 'onChangeEditorConfig'
'tome:update-snippets': 'addAutocompleteSnippets'
'tome:insert-snippet': 'onInsertSnippet'
'tome:spell-beautify': 'onSpellBeautify'
'tome:maximize-toggled': 'onMaximizeToggled'
'tome:problems-updated': 'onProblemsUpdated'
'script:state-changed': 'onScriptStateChange'
'playback:ended-changed': 'onPlaybackEndedChanged'
'level:contact-button-pressed': 'onContactButtonPressed'
'level:show-victory': 'onShowVictory'
'web-dev:error': 'onWebDevError'
events:
'mouseout': 'onMouseOut'
constructor: (options) ->
super options
@supermodel = options.supermodel
@worker = options.worker
@session = options.session
@spell = options.spell
@problems = []
@savedProblems = {} # Cache saved user code problems to prevent duplicates
@writable = false unless me.team in @spell.permissions.readwrite # TODO: make this do anything
@highlightCurrentLine = _.throttle @highlightCurrentLine, 100
$(window).on 'resize', @onWindowResize
@observing = @session.get('creator') isnt me.id
afterRender: ->
super()
@createACE()
@createACEShortcuts()
@hookACECustomBehavior()
@fillACE()
@createOnCodeChangeHandlers()
@lockDefaultCode()
_.defer @onAllLoaded # Needs to happen after the code generating this view is complete
# This ACE is used for the code editor, and is only instantiated once per level.
createACE: ->
# Test themes and settings here: http://ace.ajax.org/build/kitchen-sink.html
aceConfig = me.get('aceConfig') ? {}
@destroyAceEditor(@ace)
@ace = ace.edit @$el.find('.ace')[0]
@aceSession = @ace.getSession()
# Override setAnnotations so the Ace html worker doesn't clobber our annotations
@reallySetAnnotations = @aceSession.setAnnotations.bind(@aceSession)
@aceSession.setAnnotations = (annotations) =>
previousAnnotations = @aceSession.getAnnotations()
newAnnotations = _.filter previousAnnotations, (annotation) -> annotation.createdBy? # Keep the ones we generated
.concat _.reject annotations, (annotation) -> # Ignore this particular info-annotation the html worker generates
annotation.text is 'Start tag seen without seeing a doctype first. Expected e.g. <!DOCTYPE html>.'
@reallySetAnnotations newAnnotations
@aceDoc = @aceSession.getDocument()
@aceSession.setUseWorker @spell.language in @languagesThatUseWorkers
@aceSession.setMode utils.aceEditModes[@spell.language]
@aceSession.setWrapLimitRange null
@aceSession.setUseWrapMode true
@aceSession.setNewLineMode 'unix'
@aceSession.setUseSoftTabs true
@ace.setTheme 'ace/theme/textmate'
@ace.setDisplayIndentGuides false
@ace.setShowPrintMargin false
@ace.setShowInvisibles aceConfig.invisibles
@ace.setBehavioursEnabled aceConfig.behaviors
@ace.setAnimatedScroll true
@ace.setShowFoldWidgets false
@ace.setKeyboardHandler @keyBindings[aceConfig.keyBindings ? 'default']
@ace.$blockScrolling = Infinity
@toggleControls null, @writable
@aceSession.selection.on 'changeCursor', @onCursorActivity
$(@ace.container).find('.ace_gutter').on 'click mouseenter', '.ace_error, .ace_warning, .ace_info', @onAnnotationClick
$(@ace.container).find('.ace_gutter').on 'click', @onGutterClick
@initAutocomplete aceConfig.liveCompletion ? true
return if @session.get('creator') isnt me.id or @session.fake
# Create a Spade to 'dig' into Ace.
@spade = new Spade()
@spade.track(@ace)
# If a user is taking longer than 10 minutes, let's log it.
saveSpadeDelay = 10 * 60 * 1000
@saveSpadeTimeout = setTimeout @saveSpade, saveSpadeDelay
createACEShortcuts: ->
@aceCommands = aceCommands = []
ace = @ace
addCommand = (c) ->
ace.commands.addCommand c
aceCommands.push c.name
addCommand
name: 'run-code'
bindKey: {win: 'Shift-Enter|Ctrl-Enter', mac: 'Shift-Enter|Command-Enter|Ctrl-Enter'}
exec: => Backbone.Mediator.publish 'tome:manual-cast', {realTime: @options.level.isType('game-dev')}
unless @observing
addCommand
name: 'run-code-real-time'
bindKey: {win: 'Ctrl-Shift-Enter', mac: 'Command-Shift-Enter|Ctrl-Shift-Enter'}
exec: =>
doneButton = @$('.done-button:visible')
if doneButton.length
doneButton.trigger 'click'
else if @options.level.get('replayable') and (timeUntilResubmit = @session.timeUntilResubmit()) > 0
Backbone.Mediator.publish 'tome:manual-cast-denied', timeUntilResubmit: timeUntilResubmit
else
Backbone.Mediator.publish 'tome:manual-cast', {realTime: true}
addCommand
name: 'no-op'
bindKey: {win: 'Ctrl-S', mac: 'Command-S|Ctrl-S'}
exec: -> # just prevent page save call
addCommand
name: 'toggle-playing'
bindKey: {win: 'Ctrl-P', mac: 'Command-P|Ctrl-P'}
readOnly: true
exec: -> Backbone.Mediator.publish 'level:toggle-playing', {}
addCommand
name: 'end-current-script'
bindKey: {win: 'Shift-Space', mac: 'Shift-Space'}
readOnly: true
exec: =>
if @scriptRunning
Backbone.Mediator.publish 'level:shift-space-pressed', {}
else
@ace.insert ' '
addCommand
name: 'end-all-scripts'
bindKey: {win: 'Escape', mac: 'Escape'}
readOnly: true
exec: ->
Backbone.Mediator.publish 'level:escape-pressed', {}
addCommand
name: 'toggle-grid'
bindKey: {win: 'Ctrl-G', mac: 'Command-G|Ctrl-G'}
readOnly: true
exec: -> Backbone.Mediator.publish 'level:toggle-grid', {}
addCommand
name: 'toggle-debug'
bindKey: {win: 'Ctrl-\\', mac: 'Command-\\|Ctrl-\\'}
readOnly: true
exec: -> Backbone.Mediator.publish 'level:toggle-debug', {}
addCommand
name: 'toggle-pathfinding'
bindKey: {win: 'Ctrl-O', mac: 'Command-O|Ctrl-O'}
readOnly: true
exec: -> Backbone.Mediator.publish 'level:toggle-pathfinding', {}
addCommand
name: 'level-scrub-forward'
bindKey: {win: 'Ctrl-]', mac: 'Command-]|Ctrl-]'}
readOnly: true
exec: -> Backbone.Mediator.publish 'level:scrub-forward', {}
addCommand
name: 'level-scrub-back'
bindKey: {win: 'Ctrl-[', mac: 'Command-[|Ctrl-]'}
readOnly: true
exec: -> Backbone.Mediator.publish 'level:scrub-back', {}
addCommand
name: 'spell-step-forward'
bindKey: {win: 'Ctrl-Alt-]', mac: 'Command-Alt-]|Ctrl-Alt-]'}
readOnly: true
exec: -> Backbone.Mediator.publish 'tome:spell-step-forward', {}
addCommand
name: 'spell-step-backward'
bindKey: {win: 'Ctrl-Alt-[', mac: 'Command-Alt-[|Ctrl-Alt-]'}
readOnly: true
exec: -> Backbone.Mediator.publish 'tome:spell-step-backward', {}
addCommand
name: 'spell-beautify'
bindKey: {win: 'Ctrl-Shift-B', mac: 'Command-Shift-B|Ctrl-Shift-B'}
exec: -> Backbone.Mediator.publish 'tome:spell-beautify', {}
addCommand
name: 'prevent-line-jump'
bindKey: {win: 'Ctrl-L', mac: 'Command-L'}
passEvent: true
exec: -> # just prevent default ACE go-to-line alert
addCommand
name: 'open-fullscreen-editor'
bindKey: {win: 'Ctrl-Shift-M', mac: 'Command-Shift-M|Ctrl-Shift-M'}
exec: -> Backbone.Mediator.publish 'tome:toggle-maximize', {}
addCommand
# TODO: Restrict to beginner campaign levels like we do backspaceThrottle
name: 'enter-skip-delimiters'
bindKey: 'Enter|Return'
exec: =>
if @aceSession.selection.isEmpty()
cursor = @ace.getCursorPosition()
line = @aceDoc.getLine(cursor.row)
if delimMatch = line.substring(cursor.column).match /^(["|']?\)+;?)/ # Yay for editors misreading regexes: "
newRange = @ace.getSelectionRange()
newRange.setStart newRange.start.row, newRange.start.column + delimMatch[1].length
newRange.setEnd newRange.end.row, newRange.end.column + delimMatch[1].length
@aceSession.selection.setSelectionRange newRange
@ace.execCommand 'insertstring', '\n'
addCommand
name: 'disable-spaces'
bindKey: 'Space'
exec: =>
disableSpaces = @options.level.get('disableSpaces') or false
aceConfig = me.get('aceConfig') ? {}
disableSpaces = false if aceConfig.keyBindings and aceConfig.keyBindings isnt 'default' # Not in vim/emacs mode
disableSpaces = false if @spell.language in ['lua', 'java', 'coffeescript', 'html'] # Don't disable for more advanced/experimental languages
if not disableSpaces or (_.isNumber(disableSpaces) and disableSpaces < me.level())
return @ace.execCommand 'insertstring', ' '
line = @aceDoc.getLine @ace.getCursorPosition().row
return @ace.execCommand 'insertstring', ' ' if @singleLineCommentRegex().test line
if @options.level.get 'backspaceThrottle'
addCommand
name: 'throttle-backspaces'
bindKey: 'Backspace'
exec: =>
# Throttle the backspace speed
# Slow to 500ms when whitespace at beginning of line is first encountered
# Slow to 100ms for remaining whitespace at beginning of line
# Rough testing showed backspaces happen at 150ms when tapping.
# Backspace speed varies by system when holding, 30ms on fastest Macbook setting.
nowDate = Date.now()
if @aceSession.selection.isEmpty()
cursor = @ace.getCursorPosition()
line = @aceDoc.getLine(cursor.row)
if /^\s*$/.test line.substring(0, cursor.column)
@backspaceThrottleMs ?= 500
# console.log "SpellView @backspaceThrottleMs=#{@backspaceThrottleMs}"
# console.log 'SpellView lastBackspace diff', nowDate - @lastBackspace if @lastBackspace?
if not @lastBackspace? or nowDate - @lastBackspace > @backspaceThrottleMs
@backspaceThrottleMs = 100
@lastBackspace = nowDate
@ace.remove "left"
return
@backspaceThrottleMs = null
@lastBackspace = nowDate
@ace.remove "left"
hookACECustomBehavior: ->
aceConfig = me.get('aceConfig') ? {}
@ace.commands.on 'exec', (e) =>
# When pressing enter with an active selection, just make a new line under it.
if e.command.name is 'enter-skip-delimiters'
selection = @ace.selection.getRange()
unless selection.start.column is selection.end.column and selection.start.row is selection.end.row
e.editor.execCommand 'gotolineend'
return true
# Add visual indent guides
language = @spell.language
ensureLineStartsBlock = (line) ->
return false unless language is "python"
match = /^\s*([^#]+)/.exec(line)
return false if not match?
return /:\s*$/.test(match[1])
@aceSession.addDynamicMarker
update: (html, markerLayer, session, config) =>
Range = ace.require('ace/range').Range
foldWidgets = @aceSession.foldWidgets
return if not foldWidgets?
lines = @aceDoc.getAllLines()
startOfRow = (r) ->
str = lines[r]
ar = str.match(/^\s*/)
ar.pop().length
colors = [{border: '74,144,226', fill: '108,162,226'}, {border: '132,180,235', fill: '230,237,245'}]
for row in [0..@aceSession.getLength()]
foldWidgets[row] = @aceSession.getFoldWidget(row) unless foldWidgets[row]?
continue unless foldWidgets? and foldWidgets[row] is "start"
try
docRange = @aceSession.getFoldWidgetRange(row)
catch error
console.warn "Couldn't find fold widget docRange for row #{row}:", error
if not docRange?
guess = startOfRow(row)
docRange = new Range(row,guess,row,guess+4)
continue unless ensureLineStartsBlock(lines[row])
if /^\s+$/.test lines[docRange.end.row+1]
docRange.end.row += 1
xstart = startOfRow(row)
if language is 'python'
requiredIndent = new RegExp '^' + new Array(Math.floor(xstart / 4 + 1)).join('( |\t)') + '( |\t)+(\\S|\\s*$)'
for crow in [docRange.start.row+1..docRange.end.row]
unless requiredIndent.test lines[crow]
docRange.end.row = crow - 1
break
rstart = @aceSession.documentToScreenPosition docRange.start.row, docRange.start.column
rend = @aceSession.documentToScreenPosition docRange.end.row, docRange.end.column
range = new Range rstart.row, rstart.column, rend.row, rend.column
level = Math.floor(xstart / 4)
color = colors[level % colors.length]
bw = 3
to = markerLayer.$getTop(range.start.row, config)
t = markerLayer.$getTop(range.start.row + 1, config)
h = config.lineHeight * (range.end.row - range.start.row)
l = markerLayer.$padding + xstart * config.characterWidth
# w = (data.i - data.b) * config.characterWidth
w = 4 * config.characterWidth
fw = config.characterWidth * ( @aceSession.getScreenLastRowColumn(range.start.row) - xstart )
html.push """
<div style=
"position: absolute; top: #{to}px; left: #{l}px; width: #{fw+bw}px; height: #{config.lineHeight}px;
border: #{bw}px solid rgba(#{color.border},1); border-left: none;"
></div>
<div style=
"position: absolute; top: #{t}px; left: #{l}px; width: #{w}px; height: #{h}px; background-color: rgba(#{color.fill},0.5);
border-right: #{bw}px solid rgba(#{color.border},1); border-bottom: #{bw}px solid rgba(#{color.border},1);"
></div>
"""
fillACE: ->
@ace.setValue @spell.source
@aceSession.setUndoManager(new UndoManager())
@ace.clearSelection()
lockDefaultCode: (force=false) ->
# TODO: Lock default indent for an empty line?
lockDefaultCode = @options.level.get('lockDefaultCode') or false
if not lockDefaultCode or (_.isNumber(lockDefaultCode) and lockDefaultCode < me.level())
return
return unless @spell.source is @spell.originalSource or force
return if @isIE() # Temporary workaround for #2512
aceConfig = me.get('aceConfig') ? {}
return if aceConfig.keyBindings and aceConfig.keyBindings isnt 'default' # Don't lock in vim/emacs mode
console.info 'Locking down default code.'
intersects = =>
return true for range in @readOnlyRanges when @ace.getSelectionRange().intersects(range)
false
intersectsLeft = =>
leftRange = @ace.getSelectionRange().clone()
if leftRange.start.column > 0
leftRange.setStart leftRange.start.row, leftRange.start.column - 1
else if leftRange.start.row > 0
leftRange.setStart leftRange.start.row - 1, 0
return true for range in @readOnlyRanges when leftRange.intersects(range)
false
intersectsRight = =>
rightRange = @ace.getSelectionRange().clone()
if rightRange.end.column < @aceDoc.getLine(rightRange.end.row).length
rightRange.setEnd rightRange.end.row, rightRange.end.column + 1
else if rightRange.start.row < @aceDoc.getLength() - 1
rightRange.setEnd rightRange.end.row + 1, 0
return true for range in @readOnlyRanges when rightRange.intersects(range)
false
pulseLockedCode = ->
$('.locked-code').finish().addClass('pulsating').effect('shake', times: 1, distance: 2, direction: 'down').removeClass('pulsating')
preventReadonly = (next) ->
if intersects()
pulseLockedCode()
return true
next?()
interceptCommand = (obj, method, wrapper) ->
orig = obj[method]
obj[method] = ->
args = Array.prototype.slice.call arguments
wrapper => orig.apply obj, args
obj[method]
finishRange = (row, startRow, startColumn) =>
range = new Range startRow, startColumn, row, @aceSession.getLine(row).length - 1
range.start = @aceDoc.createAnchor range.start
range.end = @aceDoc.createAnchor range.end
range.end.$insertRight = true
@readOnlyRanges.push range
# Remove previous locked code highlighting
if @lockedCodeMarkerIDs?
@aceSession.removeMarker marker for marker in @lockedCodeMarkerIDs
@lockedCodeMarkerIDs = []
# Create locked default code text ranges
@readOnlyRanges = []
if @spell.language in ['python', 'coffeescript']
# Lock contiguous section of default code
# Only works for languages without closing delimeters on blocks currently
lines = @aceDoc.getAllLines()
for line, row in lines when not /^\s*$/.test(line)
lastRow = row
if lastRow?
@readOnlyRanges.push new Range 0, 0, lastRow, lines[lastRow].length - 1
# TODO: Highlighting does not work for multiple ranges
# TODO: Everything looks correct except the actual result.
# TODO: https://github.com/codecombat/codecombat/issues/1852
# else
# # Create a read-only range for each chunk of text not separated by an empty line
# startRow = startColumn = null
# for row in [0...@aceSession.getLength()]
# unless /^\s*$/.test @aceSession.getLine(row)
# unless startRow? and startColumn?
# startRow = row
# startColumn = 0
# else
# if startRow? and startColumn?
# finishRange row - 1, startRow, startColumn
# startRow = startColumn = null
# if startRow? and startColumn?
# finishRange @aceSession.getLength() - 1, startRow, startColumn
# Highlight locked ranges
for range in @readOnlyRanges
@lockedCodeMarkerIDs.push @aceSession.addMarker range, 'locked-code', 'fullLine'
# Override write operations that intersect with default code
interceptCommand @ace, 'onPaste', preventReadonly
interceptCommand @ace, 'onCut', preventReadonly
# TODO: can we use interceptCommand for this too? 'exec' and 'onExec' did not work.
@ace.commands.on 'exec', (e) =>
e.stopPropagation()
e.preventDefault()
if (e.command.name is 'insertstring' and intersects()) or
(e.command.name in ['Backspace', 'throttle-backspaces'] and intersectsLeft()) or
(e.command.name is 'del' and intersectsRight())
@autocomplete?.off?()
pulseLockedCode()
return false
else if e.command.name in ['enter-skip-delimiters', 'Enter', 'Return']
if intersects()
e.editor.navigateDown 1
e.editor.navigateLineStart()
return false
else if e.command.name in ['Enter', 'Return'] and not e.editor?.completer?.popup?.isOpen
@autocomplete?.on?()
return e.editor.execCommand 'enter-skip-delimiters'
@autocomplete?.on?()
e.command.exec e.editor, e.args or {}
initAutocomplete: (@autocompleteOn) ->
# TODO: Turn on more autocompletion based on level sophistication
# TODO: E.g. using the language default snippets yields a bunch of crazy non-beginner suggestions
# TODO: Options logic shouldn't exist both here and in updateAutocomplete()
return if @spell.language is 'html'
popupFontSizePx = @options.level.get('autocompleteFontSizePx') ? 16
@autocomplete = new Autocomplete @ace,
basic: false
liveCompletion: false
snippetsLangDefaults: false
completers:
keywords: false
snippets: @autocompleteOn
autoLineEndings:
javascript: ';'
popupFontSizePx: popupFontSizePx
popupLineHeightPx: 1.5 * popupFontSizePx
popupWidthPx: 380
updateAutocomplete: (@autocompleteOn) ->
@autocomplete?.set 'snippets', @autocompleteOn
addAutocompleteSnippets: (e) ->
# Snippet entry format:
# content: code inserted into document
# meta: displayed right-justfied in popup
# name: displayed left-justified in popup, and what's being matched
# tabTrigger: fallback for name field
return unless @autocomplete and @autocompleteOn
@autocomplete.addCodeCombatSnippets @options.level, @, e
translateFindNearest: ->
# If they have advanced glasses but are playing a level which assumes earlier glasses, we'll adjust the sample code to use the more advanced APIs instead.
oldSource = @getSource()
newSource = oldSource.replace /(self:|self.|this.|@)findNearestEnemy\(\)/g, "$1findNearest($1findEnemies())"
newSource = newSource.replace /(self:|self.|this.|@)findNearestItem\(\)/g, "$1findNearest($1findItems())"
return if oldSource is newSource
@spell.originalSource = newSource
@updateACEText newSource
_.delay (=> @recompile?()), 1000
createFirepad: ->
# Currently not called; could be brought back for future multiplayer modes.
# Load from firebase or the original source if there's nothing there.
return if @firepadLoading
@eventsSuppressed = true
@loaded = false
@previousSource = @ace.getValue()
@ace.setValue('')
@aceSession.setUndoManager(new UndoManager())
fireURL = 'https://codecombat.firebaseio.com/' + @spell.pathComponents.join('/')
@fireRef = new Firebase fireURL
firepadOptions = userId: me.id
@firepad = Firepad.fromACE @fireRef, @ace, firepadOptions
@firepadLoading = true
@firepad.on 'ready', =>
return if @destroyed
@firepadLoading = false
firepadSource = @ace.getValue()
if firepadSource
@spell.source = firepadSource
else
@ace.setValue @previousSource
@aceSession.setUndoManager(new UndoManager())
@ace.clearSelection()
@onAllLoaded()
onAllLoaded: =>
@spell.transpile @spell.source
@spell.loaded = true
Backbone.Mediator.publish 'tome:spell-loaded', spell: @spell
@eventsSuppressed = false # Now that the initial change is in, we can start running any changed code
@createToolbarView()
@updateHTML create: true if @options.level.isType('web-dev')
createDebugView: ->
return if @options.level.isType('hero', 'hero-ladder', 'hero-coop', 'course', 'course-ladder', 'game-dev', 'web-dev') # We'll turn this on later, maybe, but not yet.
@debugView = new SpellDebugView ace: @ace, thang: @thang, spell:@spell
@$el.append @debugView.render().$el.hide()
createTranslationView: ->
@translationView = new SpellTranslationView { @ace, @supermodel }
@$el.append @translationView.render().$el.hide()
createToolbarView: ->
@toolbarView = new SpellToolbarView ace: @ace
@$el.append @toolbarView.render().$el
onMouseOut: (e) ->
@debugView?.onMouseOut e
onContactButtonPressed: (e) ->
@saveSpade()
getSource: ->
@ace.getValue()
setThang: (thang) ->
@focus()
@lastScreenLineCount = null
@updateLines()
return if thang.id is @thang?.id
@thang = thang
@spellThang = @spell.thang
@createDebugView() unless @debugView
@debugView?.thang = @thang
@createTranslationView() unless @translationView
@toolbarView?.toggleFlow false
@updateAether false, false
# @addAutocompleteSnippets()
@highlightCurrentLine()
cast: (preload=false, realTime=false, justBegin=false) ->
Backbone.Mediator.publish 'tome:cast-spell', { @spell, @thang, preload, realTime, justBegin }
notifySpellChanged: =>
return if @destroyed
Backbone.Mediator.publish 'tome:spell-changed', spell: @spell
notifyEditingEnded: =>
return if @destroyed or @aceDoc.undergoingFirepadOperation # from my Firepad ACE adapter
Backbone.Mediator.publish 'tome:editing-ended', {}
notifyEditingBegan: =>
return if @destroyed or @aceDoc.undergoingFirepadOperation # from my Firepad ACE adapter
Backbone.Mediator.publish 'tome:editing-began', {}
updateLines: =>
# Make sure there are always blank lines for the player to type on, and that the editor resizes to the height of the lines.
return if @destroyed
lineCount = @aceDoc.getLength()
lastLine = @aceDoc.$lines[lineCount - 1]
if lastLine isnt ''
cursorPosition = @ace.getCursorPosition()
wasAtEnd = cursorPosition.row is lineCount - 1 and cursorPosition.column is lastLine.length
@aceDoc.insertNewLine row: lineCount, column: 0 #lastLine.length
@ace.navigateLeft(1) if wasAtEnd
++lineCount
# Force the popup back
@ace?.completer?.showPopup(@ace)
screenLineCount = @aceSession.getScreenLength()
if screenLineCount isnt @lastScreenLineCount
@lastScreenLineCount = screenLineCount
lineHeight = @ace.renderer.lineHeight or 20
tomeHeight = $('#tome-view').innerHeight()
spellPaletteView = $('#spell-palette-view')
spellTopBarHeight = $('#spell-top-bar-view').outerHeight()
spellToolbarHeight = $('.spell-toolbar-view').outerHeight()
@spellPaletteHeight ?= spellPaletteView.outerHeight() # Remember this until resize, since we change it afterward
spellPaletteAllowedHeight = Math.min @spellPaletteHeight, tomeHeight / 3
maxHeight = tomeHeight - spellTopBarHeight - spellToolbarHeight - spellPaletteAllowedHeight
linesAtMaxHeight = Math.floor(maxHeight / lineHeight)
lines = Math.max 8, Math.min(screenLineCount + 2, linesAtMaxHeight)
# 2 lines buffer is nice
@ace.setOptions minLines: lines, maxLines: lines
# Move spell palette up, slightly overlapping us.
newTop = 185 + lineHeight * lines
spellPaletteView.css('top', newTop)
# Expand it to bottom of tome if too short.
newHeight = Math.max @spellPaletteHeight, tomeHeight - newTop + 10
spellPaletteView.css('height', newHeight) if @spellPaletteHeight isnt newHeight
hideProblemAlert: ->
return if @destroyed
Backbone.Mediator.publish 'tome:hide-problem-alert', {}
saveSpade: =>
return if @destroyed
spadeEvents = @spade.compile()
# Uncomment the below line for a debug panel to display inside the level
#@spade.debugPlay(spadeEvents)
condensedEvents = @spade.condense(spadeEvents)
return unless condensedEvents.length
compressedEvents = LZString.compressToUTF16(JSON.stringify(condensedEvents))
codeLog = new CodeLog({
sessionID: @options.session.id
level:
original: @options.level.get 'original'
majorVersion: (@options.level.get 'version').major
levelSlug: @options.level.get 'slug'
userID: @options.session.get 'creator'
log: compressedEvents
})
codeLog.save()
onShowVictory: (e) ->
if @saveSpadeTimeout?
window.clearTimeout @saveSpadeTimeout
@saveSpadeTimeout = null
onManualCast: (e) ->
cast = @$el.parent().length
@recompile cast, e.realTime
@focus() if cast
if @options.level.isType('web-dev')
@sourceAtLastCast = @getSource()
@ace.setStyle 'spell-cast'
@updateHTML create: true
onCodeReload: (e) ->
return unless e.spell is @spell or not e.spell
@reloadCode true
@ace.clearSelection()
_.delay (=> @ace?.clearSelection()), 500 # Make double sure this gets done (saw some timing issues?)
reloadCode: (cast=true) ->
@updateACEText @spell.originalSource
@lockDefaultCode true
@recompile cast
Backbone.Mediator.publish 'tome:spell-loaded', spell: @spell
@updateLines()
recompile: (cast=true, realTime=false) ->
hasChanged = @spell.source isnt @getSource()
if hasChanged
@spell.transpile @getSource()
@updateAether true, false
if cast #and (hasChanged or realTime) # just always cast now
@cast(false, realTime)
if hasChanged
@notifySpellChanged()
updateACEText: (source) ->
@eventsSuppressed = true
if @firepad
@firepad.setText source
else
@ace.setValue source
@aceSession.setUndoManager(new UndoManager())
@eventsSuppressed = false
try
@ace.resize true # hack: @ace may not have updated its text properly, so we force it to refresh
catch error
console.warn 'Error resizing ACE after an update:', error
createOnCodeChangeHandlers: ->
@aceDoc.removeListener 'change', @onCodeChangeMetaHandler if @onCodeChangeMetaHandler
onSignificantChange = []
onAnyChange = [
_.debounce @updateAether, if @options.level.isType('game-dev') then 10 else 500
_.debounce @notifyEditingEnded, 1000
_.throttle @notifyEditingBegan, 250
_.throttle @notifySpellChanged, 300
_.throttle @updateLines, 500
_.throttle @hideProblemAlert, 500
]
onSignificantChange.push _.debounce @checkRequiredCode, 750 if @options.level.get 'requiredCode'
onSignificantChange.push _.debounce @checkSuspectCode, 750 if @options.level.get 'suspectCode'
onAnyChange.push _.throttle @updateHTML, 10 if @options.level.isType('web-dev')
@onCodeChangeMetaHandler = =>
return if @eventsSuppressed
#@playSound 'code-change', volume: 0.5 # Currently not using this sound.
if @spellThang
@spell.hasChangedSignificantly @getSource(), @spellThang.aether.raw, (hasChanged) =>
if not @spellThang or hasChanged
callback() for callback in onSignificantChange # Do these first
callback() for callback in onAnyChange # Then these
@aceDoc.on 'change', @onCodeChangeMetaHandler
onCursorActivity: => # Used to refresh autocast delay; doesn't do anything at the moment.
updateHTML: (options={}) =>
# TODO: Merge with onSpellChanged
# NOTE: Consider what goes in onManualCast only
if @spell.hasChanged(@spell.getSource(), @sourceAtLastCast)
@ace.unsetStyle 'spell-cast' # NOTE: Doesn't do anything for web-dev as of this writing, including for consistency
@clearWebDevErrors()
Backbone.Mediator.publish 'tome:html-updated', html: @spell.constructHTML(@getSource()), create: Boolean(options.create)
# Design for a simpler system?
# * Keep Aether linting, debounced, on any significant change
# - All problems just vanish when you make any change to the code
# * You wouldn't accept any Aether updates/runtime information/errors unless its code was current when you got it
# * Store the last run Aether in each spellThang and use it whenever its code actually is current.
# Use dynamic markers for problem ranges and keep annotations/alerts in when insignificant
# changes happen, but always treat any change in the (trimmed) number of lines as a significant change.
# - All problems have a master representation as a Problem, and we can easily generate all Problems from
# any Aether instance. Then when we switch contexts in any way, we clear, recreate, and reapply the Problems.
# * Problem alerts have their own templated ProblemAlertViews.
# * We'll only show the first problem alert, and it will always be at the bottom.
# Annotations and problem ranges can show all, I guess.
# * The editor will reserve space for one annotation as a codeless area.
# - Problem alerts and ranges will only show on fully cast worlds. Annotations will show continually.
updateAether: (force=false, fromCodeChange=true) =>
# Depending on whether we have any code changes, significant code changes, or have switched
# to a new spellThang, we may want to refresh our Aether display.
return unless aether = @spellThang?.aether
source = @getSource()
@spell.hasChangedSignificantly source, aether.raw, (hasChanged) =>
codeHasChangedSignificantly = force or hasChanged
needsUpdate = codeHasChangedSignificantly or @spellThang isnt @lastUpdatedAetherSpellThang
return if not needsUpdate and aether is @displayedAether
castAether = @spellThang.castAether
codeIsAsCast = castAether and source is castAether.raw
aether = castAether if codeIsAsCast
return if not needsUpdate and aether is @displayedAether
# Now that that's figured out, perform the update.
# The web worker Aether won't track state, so don't have to worry about updating it
finishUpdatingAether = (aether) =>
@clearAetherDisplay() # In case problems were added since last clearing
@displayAether aether, codeIsAsCast
@lastUpdatedAetherSpellThang = @spellThang
@guessWhetherFinished aether if fromCodeChange
@clearAetherDisplay()
if codeHasChangedSignificantly and not codeIsAsCast
if @worker
workerMessage =
function: 'transpile'
spellKey: @spell.spellKey
source: source
@worker.addEventListener 'message', (e) =>
workerData = JSON.parse e.data
if workerData.function is 'transpile' and workerData.spellKey is @spell.spellKey
@worker.removeEventListener 'message', arguments.callee, false
aether.problems = workerData.problems
aether.raw = source
finishUpdatingAether(aether)
@worker.postMessage JSON.stringify(workerMessage)
else
aether.transpile source
finishUpdatingAether(aether)
else
finishUpdatingAether(aether)
# Each problem-generating piece (aether, web-dev, ace html worker) clears its own problems/annotations
clearAetherDisplay: ->
@clearProblemsCreatedBy 'aether'
@highlightCurrentLine {} # This'll remove all highlights
clearWebDevErrors: ->
@clearProblemsCreatedBy 'web-dev-iframe'
clearProblemsCreatedBy: (createdBy) ->
nonAetherAnnotations = _.reject @aceSession.getAnnotations(), (annotation) -> annotation.createdBy is createdBy
@reallySetAnnotations nonAetherAnnotations
problemsToClear = _.filter @problems, (p) -> p.createdBy is createdBy
problemsToClear.forEach (problem) -> problem.destroy()
@problems = _.difference @problems, problemsToClear
Backbone.Mediator.publish 'tome:problems-updated', spell: @spell, problems: @problems, isCast: false
convertAetherProblems: (aether, aetherProblems, isCast) ->
# TODO: Functional-ify
_.unique(aetherProblems, (p) -> p.userInfo?.key).map (aetherProblem) =>
new Problem { aether, aetherProblem, @ace, isCast, levelID: @options.levelID }
displayAether: (aether, isCast=false) ->
@displayedAether = aether
isCast = isCast or not _.isEmpty(aether.metrics) or _.some aether.getAllProblems(), {type: 'runtime'}
annotations = @aceSession.getAnnotations()
newProblems = @convertAetherProblems(aether, aether.getAllProblems(), isCast)
annotations.push problem.annotation for problem in newProblems when problem.annotation
if isCast
@displayProblemBanner(newProblems[0]) if newProblems[0]
@saveUserCodeProblem(aether, problem.aetherProblem) for problem in newProblems
@problems = @problems.concat(newProblems)
@aceSession.setAnnotations annotations
@highlightCurrentLine aether.flow unless _.isEmpty aether.flow
#console.log ' and we could do the metrics', aether.metrics unless _.isEmpty aether.metrics
#console.log ' and we could do the style', aether.style unless _.isEmpty aether.style
#console.log ' and we could do the visualization', aether.visualization unless _.isEmpty aether.visualization
Backbone.Mediator.publish 'tome:problems-updated', spell: @spell, problems: @problems, isCast: isCast
@ace.resize()
# Tell ProblemAlertView to display this problem (only)
displayProblemBanner: (problem) ->
lineOffsetPx = 0
if problem.row?
for i in [0...problem.row]
lineOffsetPx += @aceSession.getRowLength(i) * @ace.renderer.lineHeight
lineOffsetPx -= @ace.session.getScrollTop()
Backbone.Mediator.publish 'tome:show-problem-alert', problem: problem, lineOffsetPx: Math.max lineOffsetPx, 0
# Gets the number of lines before the start of <script> content in the usercode
# Because Errors report their line number relative to the <script> tag
linesBeforeScript: (html) ->
# TODO: refactor, make it work with multiple scripts. What to do when error is in level-creator's code?
_.size(html.split('<script>')[0].match(/\n/g))
addAnnotation: (annotation) ->
annotations = @aceSession.getAnnotations()
annotations.push annotation
@reallySetAnnotations annotations
# Handle errors from the web-dev iframe asynchronously
onWebDevError: (error) ->
# TODO: Refactor this and the Aether problem flow to share as much as possible.
# TODO: Handle when the error is in our code, not theirs
# Compensate for line number being relative to <script> tag
offsetError = _.merge {}, error, { line: error.line + @linesBeforeScript(@getSource()) }
userCodeHasChangedSinceLastCast = @spell.hasChanged(@spell.getSource(), @sourceAtLastCast)
problem = new Problem({ error: offsetError, @ace, levelID: @options.levelID, userCodeHasChangedSinceLastCast })
# Ignore the Problem if we already know about it
if _.any(@problems, (preexistingProblem) -> problem.isEqual(preexistingProblem))
problem.destroy()
else # Ok, the problem is worth keeping
@problems.push problem
@displayProblemBanner(problem)
# @saveUserCodeProblem(aether, aetherProblem) # TODO: Enable saving of web-dev user code problems
@addAnnotation(problem.annotation) if problem.annotation
Backbone.Mediator.publish 'tome:problems-updated', spell: @spell, problems: @problems, isCast: false
onProblemsUpdated: ({ spell, problems, isCast }) ->
# This just handles some ace styles for now; other things handle @problems changes elsewhere
@ace[if problems.length then 'setStyle' else 'unsetStyle'] 'user-code-problem'
@ace[if isCast then 'setStyle' else 'unsetStyle'] 'spell-cast' # Does this still do anything?
saveUserCodeProblem: (aether, aetherProblem) ->
# Skip duplicate problems
hashValue = aether.raw + aetherProblem.message
return if hashValue of @savedProblems
@savedProblems[hashValue] = true
return unless Math.random() < 0.01 # Let's only save a tiny fraction of these during HoC to reduce writes.
# Save new problem
@userCodeProblem = new UserCodeProblem()
@userCodeProblem.set 'code', aether.raw
if aetherProblem.range
rawLines = aether.raw.split '\n'
errorLines = rawLines.slice aetherProblem.range[0].row, aetherProblem.range[1].row + 1
@userCodeProblem.set 'codeSnippet', errorLines.join '\n'
@userCodeProblem.set 'errHint', aetherProblem.hint if aetherProblem.hint
@userCodeProblem.set 'errId', aetherProblem.id if aetherProblem.id
@userCodeProblem.set 'errLevel', aetherProblem.level if aetherProblem.level
if aetherProblem.message
@userCodeProblem.set 'errMessage', aetherProblem.message
# Save error message without 'Line N: ' prefix
messageNoLineInfo = aetherProblem.message
if lineInfoMatch = messageNoLineInfo.match /^Line [0-9]+\: /
messageNoLineInfo = messageNoLineInfo.slice(lineInfoMatch[0].length)
@userCodeProblem.set 'errMessageNoLineInfo', messageNoLineInfo
@userCodeProblem.set 'errRange', aetherProblem.range if aetherProblem.range
@userCodeProblem.set 'errType', aetherProblem.type if aetherProblem.type
@userCodeProblem.set 'language', aether.language.id if aether.language?.id
@userCodeProblem.set 'levelID', @options.levelID if @options.levelID
@userCodeProblem.save()
null
# Autocast (preload the world in the background):
# Goes immediately if the code is a) changed and b) complete/valid and c) the cursor is at beginning or end of a line
# We originally thought it would:
# - Go after specified delay if a) and b) but not c)
# - Go only when manually cast or deselecting a Thang when there are errors
# But the error message display was delayed, so now trying:
# - Go after specified delay if a) and not b) or c)
guessWhetherFinished: (aether) ->
valid = not aether.getAllProblems().length
return unless valid
cursorPosition = @ace.getCursorPosition()
currentLine = _.string.rtrim(@aceDoc.$lines[cursorPosition.row].replace(@singleLineCommentRegex(), '')) # trim // unless inside "
endOfLine = cursorPosition.column >= currentLine.length # just typed a semicolon or brace, for example
beginningOfLine = not currentLine.substr(0, cursorPosition.column).trim().length # uncommenting code, for example
incompleteThis = /^(s|se|sel|self|t|th|thi|this)$/.test currentLine.trim()
#console.log "finished=#{valid and (endOfLine or beginningOfLine) and not incompleteThis}", valid, endOfLine, beginningOfLine, incompleteThis, cursorPosition, currentLine.length, aether, new Date() - 0, currentLine
if not incompleteThis and @options.level.isType('game-dev')
# TODO: Improve gamedev autocast speed
@spell.transpile @getSource()
@cast(false, false, true)
else if (endOfLine or beginningOfLine) and not incompleteThis
@preload()
singleLineCommentRegex: ->
if @_singleLineCommentRegex
@_singleLineCommentRegex.lastIndex = 0
return @_singleLineCommentRegex
if @spell.language is 'html'
commentStart = "#{commentStarts.html}|#{commentStarts.css}|#{commentStarts.javascript}"
else
commentStart = commentStarts[@spell.language] or '//'
@_singleLineCommentRegex = new RegExp "[ \t]*(#{commentStart})[^\"'\n]*"
@_singleLineCommentRegex
singleLineCommentOnlyRegex: ->
if @_singleLineCommentOnlyRegex
@_singleLineCommentOnlyRegex.lastIndex = 0
return @_singleLineCommentOnlyRegex
@_singleLineCommentOnlyRegex = new RegExp( '^' + @singleLineCommentRegex().source)
@_singleLineCommentOnlyRegex
commentOutMyCode: ->
prefix = if @spell.language is 'javascript' then 'return; ' else 'return '
comment = prefix + commentStarts[@spell.language]
preload: ->
# Send this code over to the God for preloading, but don't change the cast state.
#console.log 'preload?', @spell.source.indexOf('while'), @spell.source.length, @spellThang?.castAether?.metrics?.statementsExecuted
return if @spell.source.indexOf('while') isnt -1 # If they're working with while-loops, it's more likely to be an incomplete infinite loop, so don't preload.
return if @spell.source.length > 500 # Only preload on really short methods
return if @spellThang?.castAether?.metrics?.statementsExecuted > 2000 # Don't preload if they are running significant amounts of user code
return if @options.level.isType('web-dev')
oldSource = @spell.source
oldSpellThangAether = @spell.thang?.aether.serialize()
@spell.transpile @getSource()
@cast true
@spell.source = oldSource
for key, value of oldSpellThangAether
@spell.thang.aether[key] = value
onSpellChanged: (e) ->
# TODO: Merge with updateHTML
@spellHasChanged = true
onSessionWillSave: (e) ->
return unless @spellHasChanged and me.isAdmin()
setTimeout(=>
unless @destroyed or @spellHasChanged
@$el.find('.save-status').finish().show().fadeOut(2000)
, 1000)
@spellHasChanged = false
onUserCodeProblem: (e) ->
return unless e.god is @options.god
return @onInfiniteLoop e if e.problem.id is 'runtime_InfiniteLoop'
return unless e.problem.userInfo.methodName is @spell.name
return unless @spell.thang?.thang.id is e.problem.userInfo.thangID
@spell.hasChangedSignificantly @getSource(), null, (hasChanged) =>
return if hasChanged
@spell.thang.aether.addProblem e.problem
@lastUpdatedAetherSpellThang = null # force a refresh without a re-transpile
@updateAether false, false
onNonUserCodeProblem: (e) ->
return unless e.god is @options.god
return unless @spellThang
problem = @spellThang.aether.createUserCodeProblem type: 'runtime', kind: 'Unhandled', message: "Unhandled error: #{e.problem.message}"
@spellThang.aether.addProblem problem
@spellThang.castAether?.addProblem problem
@lastUpdatedAetherSpellThang = null # force a refresh without a re-transpile
@updateAether false, false # TODO: doesn't work, error doesn't display
onInfiniteLoop: (e) ->
return unless @spellThang
@spellThang.aether.addProblem e.problem
@spellThang.castAether?.addProblem e.problem
@lastUpdatedAetherSpellThang = null # force a refresh without a re-transpile
@updateAether false, false
onNewWorld: (e) ->
if thang = e.world.getThangByID @spell.thang?.thang.id
aether = e.world.userCodeMap[thang.id]?[@spell.name]
@spell.thang.castAether = aether
@spell.thang.aether = @spell.createAether thang
#console.log thang.id, @spell.spellKey, 'ran', aether.metrics.callsExecuted, 'times over', aether.metrics.statementsExecuted, 'statements, with max recursion depth', aether.metrics.maxDepth, 'and full flow/metrics', aether.metrics, aether.flow
else
@spell.thang = null
@spell.transpile() # TODO: is there any way we can avoid doing this if it hasn't changed? Causes a slight hang.
@updateAether false, false
# --------------------------------------------------------------------------------------------------
focus: ->
# TODO: it's a hack checking if a modal is visible; the events should be removed somehow
# but this view is not part of the normal subview destroying because of how it's swapped
return unless @controlsEnabled and @writable and $('.modal:visible').length is 0
return if @ace.isFocused()
@ace.focus()
@ace.clearSelection()
onFrameChanged: (e) ->
return unless @spellThang and e.selectedThang?.id is @spellThang?.thang.id
@thang = e.selectedThang # update our thang to the current version
@highlightCurrentLine()
onCoordinateSelected: (e) ->
return unless @ace.isFocused() and e.x? and e.y?
if @spell.language is 'python'
@ace.insert "{\"x\": #{e.x}, \"y\": #{e.y}}"
else if @spell.language is 'lua'
@ace.insert "{x=#{e.x}, y=#{e.y}}"
else
@ace.insert "{x: #{e.x}, y: #{e.y}}"
@highlightCurrentLine()
onStatementIndexUpdated: (e) ->
return unless e.ace is @ace
@highlightCurrentLine()
highlightCurrentLine: (flow) =>
# TODO: move this whole thing into SpellDebugView or somewhere?
@highlightEntryPoints() unless @destroyed
flow ?= @spellThang?.castAether?.flow
return unless flow and @thang
executed = []
executedRows = {}
matched = false
states = flow.states ? []
currentCallIndex = null
for callState, callNumber in states
if not currentCallIndex? and callState.userInfo?.time > @thang.world.age
currentCallIndex = callNumber - 1
if matched
executed.pop()
break
executed.push []
for state, statementNumber in callState.statements
if state.userInfo?.time > @thang.world.age
matched = true
break
_.last(executed).push state
executedRows[state.range[0].row] = true
#state.executing = true if state.userInfo?.time is @thang.world.age # no work
currentCallIndex ?= callNumber - 1
#console.log 'got call index', currentCallIndex, 'for time', @thang.world.age, 'out of', states.length
@decoratedGutter = @decoratedGutter || {}
# TODO: don't redo the markers if they haven't actually changed
for markerRange in (@markerRanges ?= [])
markerRange.start.detach()
markerRange.end.detach()
@aceSession.removeMarker markerRange.id
@markerRanges = []
for row in [0 ... @aceSession.getLength()]
unless executedRows[row]
@aceSession.removeGutterDecoration row, 'executing'
@aceSession.removeGutterDecoration row, 'executed'
@decoratedGutter[row] = ''
lastExecuted = _.last executed
showToolbarView = executed.length and @spellThang.castAether.metrics.statementsExecuted > 3 and not @options.level.get 'hidesCodeToolbar' # Hide for a while
showToolbarView = false # TODO: fix toolbar styling in new design to have some space for it
if showToolbarView
statementIndex = Math.max 0, lastExecuted.length - 1
@toolbarView?.toggleFlow true
@toolbarView?.setCallState states[currentCallIndex], statementIndex, currentCallIndex, @spellThang.castAether.metrics
lastExecuted = lastExecuted[0 .. @toolbarView.statementIndex] if @toolbarView?.statementIndex?
else
@toolbarView?.toggleFlow false
@debugView?.setVariableStates {}
marked = {}
gotVariableStates = false
for state, i in lastExecuted ? []
[start, end] = state.range
clazz = if i is lastExecuted.length - 1 then 'executing' else 'executed'
if clazz is 'executed'
continue if marked[start.row]
marked[start.row] = true
markerType = 'fullLine'
else
@debugView?.setVariableStates state.variables
gotVariableStates = true
markerType = 'text'
markerRange = new Range start.row, start.col, end.row, end.col
markerRange.start = @aceDoc.createAnchor markerRange.start
markerRange.end = @aceDoc.createAnchor markerRange.end
markerRange.id = @aceSession.addMarker markerRange, clazz, markerType
@markerRanges.push markerRange
if executedRows[start.row] and @decoratedGutter[start.row] isnt clazz
@aceSession.removeGutterDecoration start.row, @decoratedGutter[start.row] if @decoratedGutter[start.row] isnt ''
@aceSession.addGutterDecoration start.row, clazz
@decoratedGutter[start.row] = clazz
Backbone.Mediator.publish("tome:highlight-line", line:start.row) if application.isIPadApp
@debugView?.setVariableStates {} unless gotVariableStates
null
highlightEntryPoints: ->
# Put a yellow arrow in the gutter pointing to each place we expect them to put in code.
# Usually, this is indicated by a blank line after a comment line, except for the first comment lines.
# If we need to indicate an entry point on a line that has code, we use ∆ in a comment on that line.
# If the entry point line has been changed (beyond the most basic shifted lines), we don't point it out.
lines = @aceDoc.$lines
originalLines = @spell.originalSource.split '\n'
session = @aceSession
commentStart = commentStarts[@spell.language] or '//'
seenAnEntryPoint = false
previousLine = null
previousLineHadComment = false
previousLineHadCode = false
previousLineWasBlank = false
pastIntroComments = false
for line, index in lines
session.removeGutterDecoration index, 'entry-point'
session.removeGutterDecoration index, 'next-entry-point'
session.removeGutterDecoration index, "entry-point-indent-#{i}" for i in [0, 4, 8, 12, 16]
lineHasComment = @singleLineCommentRegex().test line
lineHasCode = line.trim()[0] and not @singleLineCommentOnlyRegex().test line
lineIsBlank = /^[ \t]*$/.test line
lineHasExplicitMarker = line.indexOf('') isnt -1
originalLine = originalLines[index]
lineHasChanged = line isnt originalLine
isEntryPoint = lineIsBlank and previousLineHadComment and not previousLineHadCode and pastIntroComments
if isEntryPoint and lineHasChanged
# It might just be that the line was shifted around by the player inserting more code.
# We also look for the unchanged comment line in a new position to find what line we're really on.
movedIndex = originalLines.indexOf previousLine
if movedIndex isnt -1 and line is originalLines[movedIndex + 1]
lineHasChanged = false
else
isEntryPoint = false
if lineHasExplicitMarker
if lineHasChanged
if originalLines.indexOf(line) isnt -1
lineHasChanged = false
isEntryPoint = true
else
isEntryPoint = true
if isEntryPoint
session.addGutterDecoration index, 'entry-point'
unless seenAnEntryPoint
session.addGutterDecoration index, 'next-entry-point'
seenAnEntryPoint = true
# Shift pointer right based on current indentation
# TODO: tabs probably need different horizontal offsets than spaces
indent = 0
indent++ while /\s/.test(line[indent])
indent = Math.min(16, Math.floor(indent / 4) * 4)
session.addGutterDecoration index, "entry-point-indent-#{indent}"
previousLine = line
previousLineHadComment = lineHasComment
previousLineHadCode = lineHasCode
previousLineWasBlank = lineIsBlank
pastIntroComments ||= lineHasCode or previousLineWasBlank
onAnnotationClick: ->
# @ is the gutter element
Backbone.Mediator.publish 'tome:jiggle-problem-alert', {}
onGutterClick: =>
@ace.clearSelection()
onDisableControls: (e) -> @toggleControls e, false
onEnableControls: (e) -> @toggleControls e, @writable
toggleControls: (e, enabled) ->
return if @destroyed
return if e?.controls and not ('editor' in e.controls)
return if enabled is @controlsEnabled
@controlsEnabled = enabled and @writable
disabled = not enabled
wasFocused = @ace.isFocused()
@ace.setReadOnly disabled
@ace[if disabled then 'setStyle' else 'unsetStyle'] 'disabled'
@toggleBackground()
$('body').focus() if disabled and wasFocused
toggleBackground: =>
# TODO: make the background an actual background and do the CSS trick
# used in spell-top-bar-view.sass for disabling
background = @$el.find('img.code-background')[0]
if background.naturalWidth is 0 # not loaded yet
return _.delay @toggleBackground, 100
filters.revertImage background, 'span.code-background' if @controlsEnabled
filters.darkenImage background, 'span.code-background', 0.8 unless @controlsEnabled
onSpellBeautify: (e) ->
return unless @spellThang and (@ace.isFocused() or e.spell is @spell)
ugly = @getSource()
pretty = @spellThang.aether.beautify(ugly.replace /\bloop\b/g, 'while (__COCO_LOOP_CONSTRUCT__)').replace /while \(__COCO_LOOP_CONSTRUCT__\)/g, 'loop'
@ace.setValue pretty
onMaximizeToggled: (e) ->
_.delay (=> @resize()), 500 + 100 # Wait $level-resize-transition-time, plus a bit.
onWindowResize: (e) =>
@spellPaletteHeight = null
$('#spell-palette-view').css 'height', 'auto' # Let it go back to controlling its own height
_.delay (=> @resize?()), 500 + 100 # Wait $level-resize-transition-time, plus a bit.
resize: ->
@ace?.resize true
@lastScreenLineCount = null
@updateLines()
onChangeEditorConfig: (e) ->
aceConfig = me.get('aceConfig') ? {}
@ace.setDisplayIndentGuides aceConfig.indentGuides # default false
@ace.setShowInvisibles aceConfig.invisibles # default false
@ace.setKeyboardHandler @keyBindings[aceConfig.keyBindings ? 'default']
@updateAutocomplete(aceConfig.liveCompletion ? false)
onChangeLanguage: (e) ->
return unless @spell.canWrite()
@aceSession.setMode utils.aceEditModes[e.language]
@autocomplete?.set 'language', utils.aceEditModes[e.language].substr('ace/mode/')
wasDefault = @getSource() is @spell.originalSource
@spell.setLanguage e.language
@reloadCode true if wasDefault
onInsertSnippet: (e) ->
snippetCode = null
if e.doc.snippets?[e.language]?.code
snippetCode = e.doc.snippets[e.language].code
else if (e.formatted.type isnt 'snippet') and e.formatted.shortName?
snippetCode = e.formatted.shortName
return unless snippetCode?
snippetManager = ace.require('ace/snippets').snippetManager
snippetManager.insertSnippet @ace, snippetCode
return
dismiss: ->
@spell.hasChangedSignificantly @getSource(), null, (hasChanged) =>
@recompile() if hasChanged
onScriptStateChange: (e) ->
@scriptRunning = if e.currentScript is null then false else true
onPlaybackEndedChanged: (e) ->
$(@ace?.container).toggleClass 'playback-ended', e.ended
checkRequiredCode: =>
return if @destroyed
source = @getSource().replace @singleLineCommentRegex(), ''
requiredCodeFragments = @options.level.get 'requiredCode'
for requiredCodeFragment in requiredCodeFragments
# Could make this obey regular expressions like suspectCode if needed
if source.indexOf(requiredCodeFragment) is -1
@warnedCodeFragments ?= {}
unless @warnedCodeFragments[requiredCodeFragment]
Backbone.Mediator.publish 'tome:required-code-fragment-deleted', codeFragment: requiredCodeFragment
@warnedCodeFragments[requiredCodeFragment] = true
checkSuspectCode: =>
return if @destroyed
source = @getSource().replace @singleLineCommentRegex(), ''
suspectCodeFragments = @options.level.get 'suspectCode'
detectedSuspectCodeFragmentNames = []
for suspectCodeFragment in suspectCodeFragments
pattern = new RegExp suspectCodeFragment.pattern, 'm'
if pattern.test source
@warnedCodeFragments ?= {}
unless @warnedCodeFragments[suspectCodeFragment.name]
Backbone.Mediator.publish 'tome:suspect-code-fragment-added', codeFragment: suspectCodeFragment.name, codeLanguage: @spell.language
@warnedCodeFragments[suspectCodeFragment.name] = true
detectedSuspectCodeFragmentNames.push suspectCodeFragment.name
for lastDetectedSuspectCodeFragmentName in @lastDetectedSuspectCodeFragmentNames ? []
unless lastDetectedSuspectCodeFragmentName in detectedSuspectCodeFragmentNames
Backbone.Mediator.publish 'tome:suspect-code-fragment-deleted', codeFragment: lastDetectedSuspectCodeFragmentName, codeLanguage: @spell.language
@lastDetectedSuspectCodeFragmentNames = detectedSuspectCodeFragmentNames
destroy: ->
$(@ace?.container).find('.ace_gutter').off 'click', '.ace_error, .ace_warning, .ace_info', @onAnnotationClick
$(@ace?.container).find('.ace_gutter').off 'click', @onGutterClick
@firepad?.dispose()
@ace?.commands.removeCommand command for command in @aceCommands
@ace?.destroy()
@aceDoc?.off 'change', @onCodeChangeMetaHandler
@aceSession?.selection.off 'changeCursor', @onCursorActivity
@destroyAceEditor(@ace)
@debugView?.destroy()
@translationView?.destroy()
@toolbarView?.destroy()
@autocomplete?.addSnippets [], @editorLang if @editorLang?
$(window).off 'resize', @onWindowResize
window.clearTimeout @saveSpadeTimeout
@saveSpadeTimeout = null
super()
# Note: These need to be double-escaped for insertion into regexes
commentStarts =
javascript: '//'
python: '#'
coffeescript: '#'
lua: '--'
java: '//'
html: '<!--'
css: '/\\*'